diff --git a/CHANGELOG.md b/CHANGELOG.md index b05308ab..cbee6a26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Change Log +## Unreleased + +- Added ability to specify above one server in the `simvue.toml` file using `profiles`. +- Enforced keyword arguments for readability and certainty in intent within initialiser for `simvue.Run`. + ## [v2.3.0](https://github.com/simvue-io/client/releases/tag/v2.3.0) - 2025-12-11 - Refactored sender functionality introducing new `Sender` class. diff --git a/simvue/config/user.py b/simvue/config/user.py index 11ea57a6..4733777c 100644 --- a/simvue/config/user.py +++ b/simvue/config/user.py @@ -55,10 +55,14 @@ class SimvueConfiguration(pydantic.BaseModel): server: ServerSpecifications = pydantic.Field( ..., description="Specifications for Simvue server" ) + profiles: dict[str, ServerSpecifications] = pydantic.Field( + default_factory=dict[str, ServerSpecifications] + ) run: DefaultRunSpecifications = DefaultRunSpecifications() offline: OfflineSpecifications = OfflineSpecifications() metrics: MetricsSpecifications = MetricsSpecifications() eco: EcoConfig = EcoConfig() + current_profile: str | None = None @classmethod def _load_pyproject_configs(cls) -> dict | None: @@ -158,6 +162,7 @@ def fetch( mode: typing.Literal["offline", "online", "disabled"], server_url: str | None = None, server_token: str | None = None, + profile: str | None = None, ) -> "SimvueConfiguration": """Retrieve the Simvue configuration from this project @@ -175,6 +180,8 @@ def fetch( * online - send metrics and data to a server. * offline - run in offline mode. * disabled - run in disabled mode. + profile : str | None, optional + specify server profile to user for URL and token. Return ------ @@ -184,16 +191,26 @@ def fetch( """ _config_dict: dict[str, dict[str, str]] = cls._load_pyproject_configs() or {} + profile = os.environ.get("SIMVUE_SERVER_PROFILE", profile) + try: # NOTE: Legacy INI support has been removed _config_dict |= toml.load(cls.config_file()) except FileNotFoundError: if not server_token or not server_url: - _config_dict = {"server": {}} + _config_dict |= {"server": {}} logger.debug("No config file found, checking environment variables") - _config_dict["server"] = _config_dict.get("server", {}) + if not profile: + _config_dict["server"] = _config_dict.get("server", {}) + elif not _config_dict.get("profiles", {}).get(profile): + raise RuntimeError( + f"Cannot load server configuration for '{profile}', " + "profile not found in configurations." + ) + else: + _config_dict["server"] = _config_dict["profiles"][profile] _config_dict["offline"] = _config_dict.get("offline", {}) @@ -234,7 +251,7 @@ def fetch( _config_dict["server"]["url"] = _server_url _config_dict["run"]["mode"] = _run_mode - return SimvueConfiguration(**_config_dict) + return SimvueConfiguration(profile=profile, **_config_dict) @classmethod @functools.lru_cache diff --git a/simvue/run.py b/simvue/run.py index 7e3e96fb..b37c93e8 100644 --- a/simvue/run.py +++ b/simvue/run.py @@ -118,11 +118,13 @@ class Run: @pydantic.validate_call def __init__( self, + *, mode: typing.Literal["online", "offline", "disabled"] = "online", abort_callback: typing.Callable[[Self], None] | None = None, server_token: pydantic.SecretStr | None = None, server_url: str | None = None, debug: bool = False, + server_profile: str | None = None, ) -> None: """Initialise a new Simvue run @@ -143,6 +145,10 @@ def __init__( overwrite value for server URL, default is None debug : bool, optional run in debug mode, default is False + server_profile : str | None, optional + specify alternative profile to use for server, this assumes + additional profiles have been specified in the configuration. + Default is to use the main server. Examples -------- @@ -185,7 +191,10 @@ def __init__( self._step: int = 0 self._active: bool = False self._user_config: SimvueConfiguration = SimvueConfiguration.fetch( - server_url=server_url, server_token=server_token, mode=mode + server_url=server_url, + server_token=server_token, + mode=mode, + profile=server_profile, ) logging.getLogger(self.__class__.__module__).setLevel( diff --git a/tests/conftest.py b/tests/conftest.py index 5c94e50f..aee96c6f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -158,7 +158,7 @@ def create_test_run_offline(request, monkeypatch: pytest.MonkeyPatch, prevent_sc _ = prevent_script_exit with tempfile.TemporaryDirectory() as temp_d: monkeypatch.setenv("SIMVUE_OFFLINE_DIRECTORY", temp_d) - with sv_run.Run("offline") as run: + with sv_run.Run(mode="offline") as run: _test_run_data = setup_test_run(run, temp_dir=pathlib.Path(temp_d), create_objects=True, request=request) yield run, _test_run_data with contextlib.suppress(ObjectNotFoundError): @@ -195,7 +195,7 @@ def create_plain_run_offline(request,prevent_script_exit,monkeypatch) -> Generat _ = prevent_script_exit with tempfile.TemporaryDirectory() as temp_d: monkeypatch.setenv("SIMVUE_OFFLINE_DIRECTORY", temp_d) - with sv_run.Run("offline") as run: + with sv_run.Run(mode="offline") as run: _temporary_directory = pathlib.Path(temp_d) yield run, setup_test_run(run, temp_dir=_temporary_directory, create_objects=False, request=request) clear_out_files() diff --git a/tests/functional/test_config.py b/tests/functional/test_config.py index dcbb013d..25e2fc70 100644 --- a/tests/functional/test_config.py +++ b/tests/functional/test_config.py @@ -21,18 +21,25 @@ "use_args", (True, False), ids=("args", "no_args") ) +@pytest.mark.parametrize( + "profile", (None, "other"), + ids=("default_profile", "alt_profile") +) def test_config_setup( use_env: bool, - use_file: str | None, + use_file: typing.Literal["basic", "extended", "pyproject.toml"] | None, use_args: bool, + profile: typing.Literal[None, "other"], monkeypatch: pytest.MonkeyPatch, mocker: pytest_mock.MockerFixture ) -> None: _token: str = f"{uuid.uuid4()}".replace('-', '') _other_token: str = f"{uuid.uuid4()}".replace('-', '') _arg_token: str = f"{uuid.uuid4()}".replace('-', '') + _alt_token: str = f"{uuid.uuid4()}".replace("-", "") _url: str = "https://simvue.example.com/" _other_url: str = "http://simvue.example.com/" + _alt_url: str = "https://simvue-dev.example.com/" _arg_url: str = "http://simvue.example.io/" _description: str = "test case for runs" _description_ppt: str = "test case for runs using pyproject.toml" @@ -75,6 +82,10 @@ def test_config_setup( url = "{_url}" token = "{_token}" + [profiles.other] + url = "{_alt_url}" + token = "{_alt_token}" + [offline] cache = "{_windows_safe}" """ @@ -104,14 +115,22 @@ def _mocked_find(file_names: list[str], *_, ppt_file=_ppt_file, conf_file=_confi simvue.config.user.SimvueConfiguration.fetch(mode="online") return elif use_args: - _config = simvue.config.user.SimvueConfiguration.fetch( + _config: SimvueConfiguration = simvue.config.user.SimvueConfiguration.fetch( server_url=_arg_url, server_token=_arg_token, mode="online" ) + elif profile == "other": + if not use_file: + with pytest.raises(RuntimeError): + _ = simvue.config.user.SimvueConfiguration.fetch(mode="online", profile="other") + return + else: + _config = simvue.config.user.SimvueConfiguration.fetch(mode="online", profile="other") + else: _config = simvue.config.user.SimvueConfiguration.fetch(mode="online") - + if use_file and use_file != "pyproject.toml": assert _config.config_file() == _config_file @@ -121,6 +140,10 @@ def _mocked_find(file_names: list[str], *_, ppt_file=_ppt_file, conf_file=_confi elif use_args: assert _config.server.url == f"{_arg_url}api" assert _config.server.token.get_secret_value() == _arg_token + elif use_file and profile == "other": + assert _config.server.url == f"{_alt_url}api" + assert _config.server.token.get_secret_value() == _alt_token + assert f"{_config.offline.cache}" == temp_d elif use_file and use_file != "pyproject.toml": assert _config.server.url == f"{_url}api" assert _config.server.token.get_secret_value() == _token