From 9320847fcf5499f0ef1984f4e42400cdbd2c0a6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Zar=C4=99bski?= Date: Wed, 15 Jul 2026 08:34:45 +0100 Subject: [PATCH 1/2] Finalised offline server version retrieval tests --- simvue/config/user.py | 35 ++++++++++++--- simvue/sender/actions.py | 79 +++++++++++++++++++++++++++++++++ tests/functional/test_config.py | 44 ++++++++++++++---- tests/unit/test_sender.py | 17 +++++++ uv.lock | 2 +- 5 files changed, 160 insertions(+), 17 deletions(-) diff --git a/simvue/config/user.py b/simvue/config/user.py index 5c89ba3f..0000e392 100644 --- a/simvue/config/user.py +++ b/simvue/config/user.py @@ -7,6 +7,7 @@ import contextlib import functools import http +import json import logging import os import pathlib @@ -125,18 +126,40 @@ def _load_pyproject_configs(cls) -> dict | None: return _simvue_setup - @classmethod + @staticmethod @functools.lru_cache def _check_server( - cls, *, token: str, + offline_cache: pathlib.Path | None, url: str, mode: typing.Literal["offline", "online", "disabled"], verify: str | bool, ) -> tuple[semver.Version | None, semver.Version | None]: - if mode in {"offline", "disabled"}: - return None, None + _offline_version_data: tuple[semver.Version | None, semver.Version | None] = ( + None, + None, + ) + + if ( + offline_cache + and (_version_file := offline_cache.joinpath("version.json")).exists() + ): + with _version_file.open() as in_f: + _local_version_data = json.load(in_f) + try: + _offline_version_data = ( + semver.Version.parse(_local_version_data.get("server")), + semver.Version.parse(_local_version_data.get("nosim")), + ) + except ValueError as e: + raise AssertionError( + "Failed to parse local server version information, " + f"is '{_version_file}' a valid JSON file?" + ) from e + + if mode in {"offline", "disabled"} or os.environ.get("SIMVUE_NO_SERVER_CHECK"): + return _offline_version_data headers: dict[str, str] = { "Authorization": f"Bearer {token}", @@ -204,9 +227,6 @@ def write(self, out_directory: pydantic.DirectoryPath) -> None: @pydantic.model_validator(mode="after") def check_valid_server(self) -> Self: - if os.environ.get("SIMVUE_NO_SERVER_CHECK"): - return self - if not self.server.token: raise ValueError("No token provided.") @@ -215,6 +235,7 @@ def check_valid_server(self) -> Self: url=self.server.url, verify=self.server_verify, mode=self.run.mode, + offline_cache=self.offline.cache, ) return self diff --git a/simvue/sender/actions.py b/simvue/sender/actions.py index 7513f943..91af41a7 100644 --- a/simvue/sender/actions.py +++ b/simvue/sender/actions.py @@ -1141,6 +1141,84 @@ def upload( cls.logger.exception("CO2 Monitor initialisation failed") +class ServerVersionAction(UploadAction): + object_type: str = "version" + + @override + @classmethod + def initialise_object(cls, online_id: ObjectID | None, **data) -> None: + """No initialiser for this action.""" + _ = online_id + _ = data + + @override + @classmethod + def pre_tasks( + cls, + offline_id: str, + data: dict[str, typing.Any], + cache_directory: pathlib.Path, + ) -> None: + """No pre-tasks for this action.""" + _ = offline_id + _ = data + _ = cache_directory + + @override + @classmethod + def post_tasks( + cls, + offline_id: str, + online_id: ObjectID | None, + data: dict[str, typing.Any], + cache_directory: pathlib.Path, + ) -> None: + """No post-tasks for this action.""" + _ = offline_id + _ = data + _ = cache_directory + + @override + @classmethod + def upload( + cls, + *, + cache_directory: pathlib.Path, + throw_exceptions: bool = False, + retry_failed: bool = False, + **_: object, + ) -> None: + """Download latest version.""" + _config: SimvueConfiguration = SimvueConfiguration.fetch(mode="online") + + with cls.json_file(cache_directory).open("w") as out_f: + json.dump( + { + "server": f"{_config.server_version}", + "nosim": f"{_config.nosim_version}", + }, + out_f, + indent=2, + ) + + @classmethod + def json_file(cls, cache_directory: pathlib.Path, *_: object) -> pathlib.Path: + """Returns the local cache JSON file for an upload. + + Parameters + ---------- + cache_directory : pathlib.Path + the cache directory to search + + Returns + ------- + pathlib.Path + path of local JSON file + + """ + return cache_directory.joinpath(f"{cls.object_type}.json") + + # Define the upload action ordering UPLOAD_ACTION_ORDER: tuple[type[UploadAction], ...] = ( TenantUploadAction, @@ -1157,4 +1235,5 @@ def upload( EventsUploadAction, HeartbeatUploadAction, CO2IntensityUploadAction, + ServerVersionAction, ) diff --git a/tests/functional/test_config.py b/tests/functional/test_config.py index e7dcac90..bf75f146 100644 --- a/tests/functional/test_config.py +++ b/tests/functional/test_config.py @@ -1,5 +1,8 @@ +import json + import pytest import typing +import semver import uuid import pathlib import pytest_mock @@ -24,13 +27,18 @@ "profile", (None, "other"), ids=("default_profile", "alt_profile") ) +@pytest.mark.parametrize( + "mode", ("offline", "online"), +) def test_config_setup( use_env: bool, use_file: typing.Literal["basic", "extended", "pyproject.toml"] | None, use_args: bool, profile: typing.Literal[None, "other"], + mode: typing.Literal["offline", "online"], monkeypatch: pytest.MonkeyPatch, - mocker: pytest_mock.MockerFixture + mocker: pytest_mock.MockerFixture, + offline_cache_setup: tempfile.TemporaryDirectory ) -> None: _token: str = f"{uuid.uuid4()}".replace('-', '') _other_token: str = f"{uuid.uuid4()}".replace('-', '') @@ -56,8 +64,19 @@ def test_config_setup( _tags: list[str] = ["tag-test", "other-tag"] _tags_ppt: list[str] = ["tag-test-ppt", "other-tag-ppt"] + _offline_sv_version = semver.Version.parse("1.4.5") + _offline_ns_version = semver.Version.parse("1.0.2") + + + if mode == "offline": + with pathlib.Path(offline_cache_setup.name).joinpath("version.json").open("w") as out_f: + json.dump({ + "server": f"{_offline_sv_version}", + "nosim": f"{_offline_ns_version}" + }, out_f, indent=2) + + # Deactivate the server checks for this test - monkeypatch.setenv("SIMVUE_NO_SERVER_CHECK", "True") monkeypatch.delenv("SIMVUE_TOKEN", False) monkeypatch.delenv("SIMVUE_URL", False) @@ -65,6 +84,9 @@ def test_config_setup( monkeypatch.setenv("SIMVUE_TOKEN", _other_token) monkeypatch.setenv("SIMVUE_URL", _other_url) + if use_args or use_env or use_file: + monkeypatch.setenv("SIMVUE_NO_SERVER_CHECK", "true") + with tempfile.TemporaryDirectory() as temp_d: _config_file = None _ppt_file = None @@ -84,7 +106,7 @@ def test_config_setup( with open((_ppt_file := pathlib.Path(temp_d).joinpath("pyproject.toml")), "w") as out_f: out_f.write(_lines_ppt) with open(_config_file := pathlib.Path(temp_d).joinpath("simvue.toml"), "w") as out_f: - _windows_safe = temp_d.replace("\\", "\\\\") + _windows_safe = offline_cache_setup.name.replace("\\", "\\\\") _lines: str = f""" [server] url = "{_url}" @@ -132,18 +154,18 @@ def _mocked_find(file_names: list[str], *_, ppt_file=_ppt_file, conf_file=_confi _config: SimvueConfiguration = simvue.config.user.SimvueConfiguration.fetch( server_url=_arg_url, server_token=_arg_token, - mode="online" + mode=mode ) elif profile == "other": if not use_file: with pytest.raises(RuntimeError): - _ = simvue.config.user.SimvueConfiguration.fetch(mode="online", profile="other") + _ = simvue.config.user.SimvueConfiguration.fetch(profile="other", mode=mode) return else: - _config = simvue.config.user.SimvueConfiguration.fetch(mode="online", profile="other") + _config = simvue.config.user.SimvueConfiguration.fetch(mode=mode, profile="other") else: - _config = simvue.config.user.SimvueConfiguration.fetch(mode="online") + _config = simvue.config.user.SimvueConfiguration.fetch(mode=mode) if use_file and use_file != "pyproject.toml": assert _config.config_file() == _config_file @@ -160,12 +182,12 @@ def _mocked_find(file_names: list[str], *_, ppt_file=_ppt_file, conf_file=_confi assert _config.server.url == f"{_alt_url}api" assert _config.server.token assert _config.server.token.get_secret_value() == _alt_token - assert f"{_config.offline.cache}" == temp_d + assert f"{_config.offline.cache}" == offline_cache_setup.name elif use_file and use_file != "pyproject.toml": assert _config.server.url == f"{_url}api" assert _config.server.token assert _config.server.token.get_secret_value() == _token - assert f"{_config.offline.cache}" == temp_d + assert f"{_config.offline.cache}" == offline_cache_setup.name if use_file == "extended": assert _config.run.description == _description @@ -185,5 +207,9 @@ def _mocked_find(file_names: list[str], *_, ppt_file=_ppt_file, conf_file=_confi for key, value in _custom_env.items(): assert _config.server.env.get(key) == f"{value}" + if mode == "offline": + assert _config.server_version == _offline_sv_version + assert _config.nosim_version == _offline_ns_version + simvue.config.user.SimvueConfiguration.config_file.cache_clear() diff --git a/tests/unit/test_sender.py b/tests/unit/test_sender.py index 9177903d..4cccd8e7 100644 --- a/tests/unit/test_sender.py +++ b/tests/unit/test_sender.py @@ -1,8 +1,11 @@ import json +import tempfile import pytest import time import datetime import uuid + +import semver from simvue.sender import Sender from simvue.api.objects import Run, Metrics, Folder from simvue.models import DATETIME_FORMAT @@ -156,6 +159,20 @@ def test_sender_server_ids(offline_cache_setup, caplog, parallel): assert len(list(pathlib.Path(offline_cache_setup.name).joinpath("runs").iterdir())) == 0 assert len(list(pathlib.Path(offline_cache_setup.name).joinpath("metrics").iterdir())) == 0 + +@pytest.mark.offline +def test_sender_version_get(offline_cache_setup: tempfile.TemporaryDirectory) -> None: + _sender = Sender() + _sender.upload() + assert (_version_file := pathlib.Path(offline_cache_setup.name).joinpath("version.json")).exists() + with _version_file.open() as in_f: + _data = json.load(in_f) + assert "server" in _data + assert "nosim" in _data + _ = semver.Version.parse(_data["server"]) + _ = semver.Version.parse(_data["nosim"]) + + @pytest.mark.parametrize("parallel", (True, False)) def test_send_heartbeat(offline_cache_setup, parallel, mocker): # Create an offline run diff --git a/uv.lock b/uv.lock index 2f1b58e2..81f446a9 100644 --- a/uv.lock +++ b/uv.lock @@ -1859,7 +1859,7 @@ wheels = [ [[package]] name = "simvue" -version = "2.5.7" +version = "2.5.9" source = { editable = "." } dependencies = [ { name = "click" }, From c256128f622812a7d78e92ca7b44cfaeeb25e56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Zar=C4=99bski?= Date: Mon, 20 Jul 2026 11:00:49 +0100 Subject: [PATCH 2/2] Ensure version is uploadable objects --- simvue/sender/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/simvue/sender/base.py b/simvue/sender/base.py index 6fb11200..84892294 100644 --- a/simvue/sender/base.py +++ b/simvue/sender/base.py @@ -33,6 +33,7 @@ "events", "heartbeat", "co2_intensity", + "version", ] UPLOAD_ORDER: list[str] = [action.object_type for action in UPLOAD_ACTION_ORDER]