Skip to content

Commit 2ce81d3

Browse files
authored
Merge pull request #190 from MiraGeoscience/GEOPY-2049-ui-json-version-tests
GEOPY-2128: fix UI json version tests (part of GEOPY-2049)
2 parents 9e925c4 + 7c01224 commit 2ce81d3

4 files changed

Lines changed: 151 additions & 61 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ include = [
3030
[tool.poetry.dependencies]
3131
python = "^3.10"
3232

33-
dask-core = {version = "2025.3.*"} # also in simpeg[dask]
33+
dask-core = "2025.3.*" # also in simpeg[dask]
3434
discretize = "0.11.*" # also in simpeg, octree-creation-app
35-
distributed = "2025.3.*" # because conda-lock doesn't take dask extras into account
35+
distributed = "2025.3.*" # conda needs explicit dask-core etc for equivalent dask[distributed]
3636
numpy = "~1.26.0" # also in geoh5py, simpeg
3737
pydantic = "^2.5.2" # also in geoh5py, curve-apps, geoapps-utils
3838
scikit-learn = "~1.4.0"

recipe.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,11 @@ tests:
6060
- geoh5py
6161
- dask
6262
- distributed
63-
pip_check: false # pip checks fails on missing dask-core because it only sees name 'dask'
63+
64+
# `pip check` fails on missing dask-core because it only sees name 'dask'
65+
# Possibly, use custom mapping for dask => dask-core
66+
# See `conda-lock --pypi_to_conda_lookup_file`, or the `pixi` option "conda-pypi-map"
67+
pip_check: false
6468

6569
- script:
6670
- pytest --ignore=tests/version_test.py

simpeg_drivers/uijson.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import logging
1313

1414
from geoh5py.ui_json.ui_json import BaseUIJson
15+
from packaging.version import Version
1516
from pydantic import field_validator
1617

1718
import simpeg_drivers
@@ -29,17 +30,41 @@ class SimPEGDriversUIJson(BaseUIJson):
2930
@field_validator("version", mode="before")
3031
@classmethod
3132
def verify_and_update_version(cls, value: str) -> str:
32-
version = simpeg_drivers.__version__
33-
if value != version:
33+
package_version = cls.comparable_version(simpeg_drivers.__version__)
34+
input_version = cls.comparable_version(value)
35+
if input_version != package_version:
3436
logger.warning(
35-
"Provided ui.json file version %s does not match the the current"
36-
"simpeg-drivers version %s. This may lead to unpredictable"
37-
"behavior.",
37+
"Provided ui.json file version '%s' does not match the current "
38+
"simpeg-drivers version '%s'. This may lead to unpredictable behavior.",
3839
value,
39-
version,
40+
simpeg_drivers.__version__,
4041
)
4142
return value
4243

44+
@staticmethod
45+
def comparable_version(value: str) -> str:
46+
"""Normalize the version string for comparison.
47+
48+
Remove the post-release information, or the pre-release information if it is an rc version.
49+
For example, if the version is "0.2.0.post1", it will return "0.2.0".
50+
If the version is "0.2.0rc1", it will return "0.2.0".
51+
52+
Then, it will return the public version of the version object.
53+
For example, if the version is "0.2.0+local", it will return "0.2.0".
54+
"""
55+
version = Version(value)
56+
57+
# Extract the base version (major.minor.patch)
58+
base_version = version.base_version
59+
60+
# If it's not an RC, keep any pre-release info (alpha/beta)
61+
if version.pre is not None and version.pre[0] != "rc": # pylint: disable=unsubscriptable-object
62+
# Recreate version with pre-release but no post or local
63+
return f"{base_version}{version.pre[0]}{version.pre[1]}"
64+
65+
# No pre-release info or it's an RC, return just the base version
66+
return base_version
67+
4368
@classmethod
4469
def write_default(cls):
4570
"""Write the default UIJson file to disk with updated version."""

tests/uijson_test.py

Lines changed: 113 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
from typing import ClassVar
1515

1616
import numpy as np
17+
import pytest
1718
from geoh5py import Workspace
1819
from geoh5py.ui_json.annotations import Deprecated
20+
from packaging.version import Version
1921
from pydantic import AliasChoices, Field
2022

2123
import simpeg_drivers
@@ -29,12 +31,28 @@
2931
logger = logging.getLogger(__name__)
3032

3133

32-
def test_version_warning(tmp_path, caplog):
33-
workspace = Workspace.create(tmp_path / "test.geoh5")
34+
def _current_version() -> Version:
35+
"""Get the package version."""
36+
return Version(simpeg_drivers.__version__)
3437

35-
with caplog.at_level(logging.WARNING):
36-
_ = SimPEGDriversUIJson(
37-
version="0.2.0",
38+
39+
@pytest.fixture(name="workspace")
40+
def workspace_fixture(tmp_path):
41+
"""Create a workspace for testing."""
42+
return Workspace.create(tmp_path / "test.geoh5")
43+
44+
45+
@pytest.fixture(name="simpeg_uijson_factory")
46+
def simpeg_uijson_factory_fixture(workspace):
47+
"""Create a SimPEGDriversUIJson object with configurable version."""
48+
49+
def _create_uijson(version: str | None = None, **kwargs):
50+
"""Create a SimPEGDriversUIJson with the given version and custom fields."""
51+
if version is None:
52+
version = _current_version().public
53+
54+
return SimPEGDriversUIJson(
55+
version=version,
3856
title="My app",
3957
icon="",
4058
documentation="",
@@ -43,8 +61,87 @@ def test_version_warning(tmp_path, caplog):
4361
monitoring_directory="",
4462
conda_environment="my-app",
4563
workspace_geoh5="",
64+
**kwargs,
4665
)
4766

67+
return _create_uijson
68+
69+
70+
@pytest.mark.parametrize(
71+
"version_input,expected",
72+
[
73+
# Normal version
74+
("1.2.3", "1.2.3"),
75+
# Post-release version
76+
("1.2.3.post1", "1.2.3"),
77+
# RC pre-release version
78+
("1.2.3rc1", "1.2.3"),
79+
# Alpha pre-release version (should not normalize)
80+
("1.2.3a1", "1.2.3a1"),
81+
# Beta pre-release version (should not normalize)
82+
("1.2.3b1", "1.2.3b1"),
83+
# Local version
84+
("1.2.3+local", "1.2.3"),
85+
# Combined cases
86+
("1.2.3rc1.post2+local", "1.2.3"),
87+
],
88+
)
89+
def test_comparable_version(version_input, expected):
90+
"""Test the comparable_version method of SimPEGDriversUIJson."""
91+
assert SimPEGDriversUIJson.comparable_version(version_input) == expected
92+
93+
94+
@pytest.mark.parametrize(
95+
"version_input,package_version,should_warn",
96+
[
97+
# Different version (should warn)
98+
("1.0.0", "2.0.0", True),
99+
# Same version (should not warn)
100+
("2.0.0", "2.0.0", False),
101+
# Post-release variant (should not warn)
102+
("2.0.0.post1", "2.0.0", False),
103+
("2.0.0", "2.0.0.post1", False),
104+
# RC variant (should not warn)
105+
("2.0.0rc1", "2.0.0", False),
106+
("2.0.0", "2.0.0rc1", False),
107+
("2.0.0rc1", "2.0.0rc2", False),
108+
# differ by the pre-release number, non RC (should warn)
109+
("2.0.0a1", "2.0.0a2", True),
110+
("2.0.0b1", "2.0.0b2", True),
111+
("2.0.0a1", "2.0.0", True),
112+
("2.0.0", "2.0.0a1", True),
113+
("2.0.0a1", "2.0.0b1", True),
114+
("2.0.0b1", "2.0.0a1", True),
115+
("2.0.0rc1", "2.0.0b1", True),
116+
("2.0.0b1", "2.0.0rc1", True),
117+
# same normalized versions (should not warn)
118+
("2.0.0-beta.1", "2.0.0b1", False),
119+
("2.0.0b1", "2.0.0-beta.1", False),
120+
],
121+
)
122+
def test_version_warning(
123+
monkeypatch,
124+
caplog,
125+
simpeg_uijson_factory,
126+
version_input,
127+
package_version,
128+
should_warn,
129+
):
130+
"""Test version warning behavior with mocked package version."""
131+
# Mock the package version
132+
monkeypatch.setattr(simpeg_drivers, "__version__", package_version)
133+
134+
with caplog.at_level(logging.WARNING):
135+
caplog.clear()
136+
_ = simpeg_uijson_factory(version=version_input)
137+
138+
warning_message = f"version '{version_input}' does not match the current simpeg-drivers version"
139+
warning_found = any(
140+
warning_message in record.message for record in caplog.records
141+
)
142+
143+
assert warning_found == should_warn
144+
48145

49146
def test_write_default(tmp_path):
50147
default_path = tmp_path / "default.ui.json"
@@ -69,70 +166,34 @@ class MyUIJson(SimPEGDriversUIJson):
69166
with open(default_path, encoding="utf-8") as f:
70167
data = json.load(f)
71168

72-
assert data["version"] == "0.3.0-alpha.1"
73-
169+
# Use comparable_version for comparison to handle pre/post-release versions
170+
assert SimPEGDriversUIJson.comparable_version(
171+
data["version"]
172+
) == SimPEGDriversUIJson.comparable_version(simpeg_drivers.__version__)
74173

75-
def test_deprecations(tmp_path, caplog):
76-
workspace = Workspace.create(tmp_path / "test.geoh5")
77174

175+
def test_deprecations(caplog, simpeg_uijson_factory):
78176
class MyUIJson(SimPEGDriversUIJson):
79177
my_param: Deprecated
80178

81179
with caplog.at_level(logging.WARNING):
82-
_ = MyUIJson(
83-
version="0.3.0-alpha.1",
84-
title="My app",
85-
icon="",
86-
documentation="",
87-
geoh5=str(workspace.h5file),
88-
run_command="myapp.driver",
89-
monitoring_directory="",
90-
conda_environment="my-app",
91-
workspace_geoh5="",
92-
my_param="whoopsie",
93-
)
180+
_ = MyUIJson(**simpeg_uijson_factory().model_dump(), my_param="whoopsie")
94181
assert "Skipping deprecated field: my_param." in caplog.text
95182

96183

97-
def test_pydantic_deprecation(tmp_path):
98-
workspace = Workspace.create(tmp_path / "test.geoh5")
99-
184+
def test_pydantic_deprecation(simpeg_uijson_factory):
100185
class MyUIJson(SimPEGDriversUIJson):
101186
my_param: str = Field(deprecated="Use my_param2 instead.", exclude=True)
102187

103-
uijson = MyUIJson(
104-
version="0.3.0-alpha.1",
105-
title="My app",
106-
icon="",
107-
documentation="",
108-
geoh5=str(workspace.h5file),
109-
run_command="myapp.driver",
110-
monitoring_directory="",
111-
conda_environment="my-app",
112-
workspace_geoh5="",
113-
my_param="whoopsie",
114-
)
188+
uijson = MyUIJson(**simpeg_uijson_factory(my_param="whoopsie").model_dump())
115189
assert "my_param" not in uijson.model_dump()
116190

117191

118-
def test_alias(tmp_path):
119-
workspace = Workspace.create(tmp_path / "test.geoh5")
120-
192+
def test_alias(simpeg_uijson_factory):
121193
class MyUIJson(SimPEGDriversUIJson):
122194
my_param: str = Field(validation_alias=AliasChoices("my_param", "myParam"))
123195

124-
uijson = MyUIJson(
125-
version="0.3.0-alpha.1",
126-
title="My app",
127-
icon="",
128-
documentation="",
129-
geoh5=str(workspace.h5file),
130-
run_command="myapp.driver",
131-
monitoring_directory="",
132-
conda_environment="my-app",
133-
workspace_geoh5="",
134-
myParam="hello",
135-
)
196+
uijson = MyUIJson(**simpeg_uijson_factory(myParam="hello").model_dump())
136197
assert uijson.my_param == "hello"
137198
assert "myParam" not in uijson.model_fields_set
138199
assert "my_param" in uijson.model_dump()
@@ -170,7 +231,7 @@ def test_gravity_uijson(tmp_path):
170231
uijson.write(uijson_path)
171232
with open(params_uijson_path, encoding="utf-8") as f:
172233
params_data = json.load(f)
173-
assert params_data["version"] == simpeg_drivers.__version__
234+
assert Version(params_data["version"]) == Version(_current_version().public)
174235
with open(uijson_path, encoding="utf-8") as f:
175236
uijson_data = json.load(f)
176237

0 commit comments

Comments
 (0)