diff --git a/doc/source/getting_started/index.rst b/doc/source/getting_started/index.rst index 7ea6a30f3..f6688b221 100644 --- a/doc/source/getting_started/index.rst +++ b/doc/source/getting_started/index.rst @@ -58,4 +58,28 @@ PyHPS requires these packages as dependencies: * `PyJWT `_ .. LINKS AND REFERENCES -.. _pip: https://pypi.org/project/pip/ \ No newline at end of file +.. _pip: https://pypi.org/project/pip/ + + +Compatibility with HPS releases +------------------------------- + +The following table summarizes the compatibility between PyHPS versions and HPS releases. + ++------------------------------+-------------------------------+-------------------------------+------------------------------+ +| PyHPS version / HPS release | ``1.0.2`` | ``1.1.1`` | ``1.2.0`` | ++==============================+===============================+===============================+==============================+ +| ``0.7.X`` | :octicon:`check-circle-fill` | :octicon:`check-circle-fill` | :octicon:`check-circle-fill` | ++------------------------------+-------------------------------+-------------------------------+------------------------------+ +| ``0.8.X`` | :octicon:`check-circle-fill` | :octicon:`check-circle-fill` | :octicon:`check-circle-fill` | ++------------------------------+-------------------------------+-------------------------------+------------------------------+ +| ``0.9.X`` | :octicon:`check-circle` | :octicon:`check-circle-fill` | :octicon:`check-circle-fill` | ++------------------------------+-------------------------------+-------------------------------+------------------------------+ +| ``0.10.X`` | :octicon:`x` | :octicon:`x` | :octicon:`check-circle-fill` | ++------------------------------+-------------------------------+-------------------------------+------------------------------+ + +Legend: + +- :octicon:`check-circle-fill` Compatible +- :octicon:`check-circle` Backward compatible (new features exposed in PyHPS may not be available in older HPS releases) +- :octicon:`x` Incompatible \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 310fa3315..81dafe88f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "backoff>=2.0.0", "pydantic>=1.10.0", "PyJWT>=2.8.0", - "ansys-hps-data-transfer-client@git+https://github.com/ansys-internal/hps-data-transfer-client.git@main#egg=ansys-hps-data-transfer-client" + "ansys-hps-data-transfer-client@git+https://github.com/ansys/pyhps-data-transfer.git@main" ] [project.optional-dependencies] diff --git a/src/ansys/hps/client/__version__.py b/src/ansys/hps/client/__version__.py index c983de159..a3f864bb2 100644 --- a/src/ansys/hps/client/__version__.py +++ b/src/ansys/hps/client/__version__.py @@ -31,4 +31,4 @@ # this is only a convenience to default the version # of Ansys simulation applications in PyHPS examples -__ansys_apps_version__ = "2024 R1" +__ansys_apps_version__ = "2025 R1" diff --git a/src/ansys/hps/client/check_version.py b/src/ansys/hps/client/check_version.py new file mode 100644 index 000000000..8e0e4a9ae --- /dev/null +++ b/src/ansys/hps/client/check_version.py @@ -0,0 +1,131 @@ +# Copyright (C) 2022 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +""" +Version compatibility checks. +""" + +from enum import Enum +from functools import wraps +from typing import Protocol + +from .exceptions import VersionCompatibilityError + + +class HpsRelease(Enum): + """HPS release versions.""" + + v1_0_2 = "1.0.2" + v1_1_1 = "1.1.1" + v1_2_0 = "1.2.0" + + +"""HPS to JMS version mapping.""" +JMS_VERSIONS: dict[HpsRelease, str] = { + HpsRelease.v1_0_2: "1.0.12", + HpsRelease.v1_1_1: "1.0.20", + HpsRelease.v1_2_0: "1.1.4", +} + + +"""HPS to RMS version mapping.""" +RMS_VERSIONS: dict[HpsRelease, str] = { + HpsRelease.v1_0_2: "1.0.0", + HpsRelease.v1_1_1: "1.1.5", + HpsRelease.v1_2_0: "1.1.10", +} + + +class ApiProtocol(Protocol): + """Protocol for API classes.""" + + @property + def version(self) -> str: + pass + + +def check_min_version(version: str, min_version: str) -> bool: + """Check if a version string meets a minimum version.""" + from packaging.version import parse + + return parse(version) >= parse(min_version) + + +def check_max_version(version: str, max_version: str) -> bool: + """Check if a version string meets a maximum version.""" + from packaging.version import parse + + return parse(version) <= parse(max_version) + + +def check_min_version_and_raise(version, min_version: str, msg=None): + """Check if a version meets a minimum version, raise an exception if not.""" + + if not check_min_version(version, min_version): + + if msg is None: + msg = f"Version {version} is not supported. Minimum version required: {min_version}" + raise VersionCompatibilityError(msg) + + +def check_max_version_and_raise(version, max_version: str, msg=None): + """Check if a version meets a maximum version, raise an exception if not.""" + + if not check_max_version(version, max_version): + + if msg is None: + msg = f"Version {version} is not supported. Maximum version required: {max_version}" + raise VersionCompatibilityError(msg) + + +def check_version_and_raise( + version, min_version: str | None = None, max_version: str | None = None, msg=None +): + """Check if a version meets the min/max requirements, raise an exception if not.""" + + if min_version is not None: + check_min_version_and_raise(version, min_version, msg) + + if max_version is not None: + check_max_version_and_raise(version, max_version, msg) + + +def version_required(min_version=None, max_version=None): + """Decorator for API methods to check version requirements.""" + + def decorator(func): + + @wraps(func) + def wrapper(self: ApiProtocol, *args, **kwargs): + if min_version is not None: + msg = f"{func.__name__} requires {type(self).__name__} version >= " + min_version + check_min_version_and_raise(self.version, min_version, msg) + + if max_version is not None: + msg = f"{func.__name__} requires {type(self).__name__} version <= " + max_version + check_max_version_and_raise(self.version, max_version, msg) + + return func(self, *args, **kwargs) + + return wrapper + + return decorator diff --git a/src/ansys/hps/client/client.py b/src/ansys/hps/client/client.py index d8226bc85..62614c1ad 100644 --- a/src/ansys/hps/client/client.py +++ b/src/ansys/hps/client/client.py @@ -29,7 +29,7 @@ from typing import Union import warnings -from ansys.hps.data_transfer.client import Client as DTClient +from ansys.hps.data_transfer.client import Client as DataTransferClient from ansys.hps.data_transfer.client import DataTransferApi import jwt import requests @@ -163,7 +163,9 @@ def __init__( self.client_secret = client_secret self.verify = verify self.data_transfer_url = url + f"/dt/api/v1" - self.dt_client = None + + self._dt_client: DataTransferClient | None = None + self._dt_api: DataTransferApi | None = None if self.verify is None: self.verify = False @@ -265,9 +267,9 @@ def __init__( self._unauthorized_max_retry = 1 def exit_handler(): - if self.dt_client is not None: + if self._dt_client is not None: log.info("Stopping the data transfer client gracefully.") - self.dt_client.stop() + self._dt_client.stop() atexit.register(exit_handler) @@ -295,25 +297,26 @@ def rep_url(self) -> str: log.warning(msg) return self.url - def _start_dt_worker(self): + def initialize_data_transfer_client(self): + """Initialize the Data Transfer client.""" - if self.dt_client is None: + if self._dt_client is None: try: log.info("Starting Data Transfer client.") # start Data transfer client - self.dt_client = DTClient(download_dir=self._get_download_dir("Ansys")) + self._dt_client = DataTransferClient(download_dir=self._get_download_dir("Ansys")) - self.dt_client.binary_config.update( + self._dt_client.binary_config.update( verbosity=3, debug=False, insecure=True, token=self.access_token, data_transfer_url=self.data_transfer_url, ) - self.dt_client.start() + self._dt_client.start() - self.dt_api = DataTransferApi(self.dt_client) - self.dt_api.status(wait=True) + self._dt_api = DataTransferApi(self._dt_client) + self._dt_api.status(wait=True) except Exception as ex: log.debug(ex) raise HPSError("Error occurred when starting Data Transfer client.") @@ -377,8 +380,8 @@ def _auto_refresh_token(self, response, *args, **kwargs): response.request.headers.update( {"Authorization": self.session.headers["Authorization"]} ) - if self.dt_client is not None: - self.dt_client.binary_config.update(token=self.access_token) + if self._dt_client is not None: + self._dt_client.binary_config.update(token=self.access_token) log.debug(f"Retrying request with updated access token.") return self.session.send(response.request) @@ -413,3 +416,17 @@ def refresh_access_token(self): self.access_token = tokens["access_token"] self.refresh_token = tokens.get("refresh_token", None) self.session.headers.update({"Authorization": "Bearer %s" % tokens["access_token"]}) + + @property + def data_transfer_client(self) -> DataTransferClient: + """Data Transfer client. If the client is not initialized, it will be started.""" + if self._dt_client is None: + self.initialize_data_transfer_client() + return self._dt_client + + @property + def data_transfer_api(self) -> DataTransferApi: + """Data Transfer API. If the client is not initialized, it will be started.""" + if self._dt_client is None: + self.initialize_data_transfer_client() + return self._dt_api diff --git a/src/ansys/hps/client/exceptions.py b/src/ansys/hps/client/exceptions.py index 7cecf56e6..b80584b43 100644 --- a/src/ansys/hps/client/exceptions.py +++ b/src/ansys/hps/client/exceptions.py @@ -63,6 +63,13 @@ def __init__(self, *args, **kwargs): super(ClientError, self).__init__(*args, **kwargs) +class VersionCompatibilityError(ClientError): + """Provides version compatibility errors.""" + + def __init__(self, *args, **kwargs): + super(VersionCompatibilityError, self).__init__(*args, **kwargs) + + def raise_for_status(response, *args, **kwargs): """Automatically checks HTTP errors. diff --git a/src/ansys/hps/client/jms/api/jms_api.py b/src/ansys/hps/client/jms/api/jms_api.py index 85ec67d8f..900bbfa28 100644 --- a/src/ansys/hps/client/jms/api/jms_api.py +++ b/src/ansys/hps/client/jms/api/jms_api.py @@ -19,17 +19,20 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from ansys.hps.data_transfer.client.models.msg import SrcDst, StoragePath -from ansys.hps.data_transfer.client.models.ops import OperationState """Module wrapping around the JMS root endpoints.""" + +from functools import cache import json import logging import os from typing import Dict, List, Union +from ansys.hps.data_transfer.client.models.msg import SrcDst, StoragePath +from ansys.hps.data_transfer.client.models.ops import OperationState import backoff +from ansys.hps.client.check_version import JMS_VERSIONS, HpsRelease, version_required from ansys.hps.client.client import Client from ansys.hps.client.common import Object from ansys.hps.client.exceptions import HPSError @@ -42,7 +45,7 @@ log = logging.getLogger(__name__) -class JmsApi(object): +class JmsApi: """Wraps around the JMS root endpoints. Parameters @@ -68,13 +71,13 @@ class JmsApi(object): def __init__(self, client: Client): """Initialize JMS API.""" self.client = client - self._fs_url = None @property def url(self) -> str: """URL of the API.""" return f"{self.client.url}/jms/api/v1" + @cache def get_api_info(self): """Get information of the JMS API that the client is connected to. @@ -83,6 +86,11 @@ def get_api_info(self): r = self.client.session.get(self.url) return r.json() + @property + def version(self) -> str: + """API version.""" + return self.get_api_info()["build"]["version"] + ################################################################ # Projects def get_projects(self, as_objects=True, **query_params) -> List[Project]: @@ -121,6 +129,7 @@ def delete_project(self, project): """Delete a project.""" return delete_project(self.client, self.url, project) + @version_required(min_version=JMS_VERSIONS[HpsRelease.v1_2_0]) def restore_project(self, path: str) -> Project: """Restore a project from an archive. @@ -442,8 +451,8 @@ def _restore_project(jms_api, archive_path): # Delete archive file on server log.info(f"Delete temporary bucket {bucket}") - op = jms_api.client.dt_api.rmdir([StoragePath(path=bucket)]) - op = jms_api.client.dt_api.wait_for([op.id]) + op = jms_api.client.data_transfer_api.rmdir([StoragePath(path=bucket)]) + op = jms_api.client.data_transfer_api.wait_for([op.id]) if op[0].state != OperationState.Succeeded: raise HPSError(f"Delete temporary bucket {bucket} failed") @@ -451,17 +460,14 @@ def _restore_project(jms_api, archive_path): def _upload_archive(jms_api: JmsApi, archive_path, bucket): - """ - Uploads archive using data transfer worker. - - """ - jms_api.client._start_dt_worker() + """Uploads archive using data transfer worker.""" + jms_api.client.initialize_data_transfer_client() src = StoragePath(path=archive_path, remote="local") dst = StoragePath(path=f"{bucket}/{os.path.basename(archive_path)}") - op = jms_api.client.dt_api.copy([SrcDst(src=src, dst=dst)]) - op = jms_api.client.dt_api.wait_for(op.id) + op = jms_api.client.data_transfer_api.copy([SrcDst(src=src, dst=dst)]) + op = jms_api.client.data_transfer_api.wait_for(op.id) log.info(f"Operation {op[0].state}") if op[0].state != OperationState.Succeeded: diff --git a/src/ansys/hps/client/jms/api/project_api.py b/src/ansys/hps/client/jms/api/project_api.py index 4e8ef6078..10644a51c 100644 --- a/src/ansys/hps/client/jms/api/project_api.py +++ b/src/ansys/hps/client/jms/api/project_api.py @@ -32,6 +32,12 @@ from ansys.hps.data_transfer.client.models.msg import SrcDst, StoragePath from ansys.hps.data_transfer.client.models.ops import OperationState +from ansys.hps.client.check_version import ( + JMS_VERSIONS, + HpsRelease, + check_version_and_raise, + version_required, +) from ansys.hps.client.client import Client from ansys.hps.client.common import Object from ansys.hps.client.exceptions import ClientError, HPSError @@ -105,8 +111,7 @@ def __init__(self, client: Client, project_id: str): """Initialize project API.""" self.client = client self.project_id = project_id - self._fs_url = None - self._fs_project_id = None + self._jms_api = JmsApi(self.client) @property def jms_api_url(self) -> str: @@ -119,16 +124,9 @@ def url(self) -> str: return f"{self.jms_api_url}/projects/{self.project_id}" @property - def fs_url(self) -> str: - """URL of the file storage gateway.""" - if self._fs_url is None: - self._fs_url = JmsApi(self.client).fs_url - return self._fs_url - - @property - def fs_bucket_url(self) -> str: - """URL of the project's bucket in the file storage gateway.""" - return f"{self.fs_url}/{self.project_id}" + def version(self) -> str: + """API version.""" + return self._jms_api.version ################################################################ # Project operations (copy, archive) @@ -140,6 +138,7 @@ def copy_project(self, wait: bool = True) -> str: else: return r + @version_required(min_version=JMS_VERSIONS[HpsRelease.v1_2_0]) def archive_project(self, path: str, include_job_files: bool = True): """Archive a project and save it to disk. @@ -166,6 +165,18 @@ def get_files(self, as_objects=True, content=False, **query_params) -> List[File If ``content=True``, each file's content is also downloaded and stored in memory as the :attr:`ansys.hps.client.jms.File.content` attribute. """ + + if content: + min_v = JMS_VERSIONS[HpsRelease.v1_2_0] + check_version_and_raise( + self.version, + min_version=min_v, + msg=( + f"ProjectApi.get_files with content=True requires" + f" JMS version {min_v} or later." + ), + ) + return get_files(self, as_objects=as_objects, content=content, **query_params) def create_files(self, files: List[File], as_objects=True) -> List[File]: @@ -180,6 +191,7 @@ def delete_files(self, files: List[File]): """Delete files.""" return self._delete_objects(files, File) + @version_required(min_version=JMS_VERSIONS[HpsRelease.v1_2_0]) def download_file( self, file: File, @@ -603,6 +615,8 @@ def delete_license_contexts(self): r = self.client.session.delete(url) ################################################################ + + @version_required(min_version=JMS_VERSIONS[HpsRelease.v1_2_0]) def copy_default_execution_script(self, filename: str) -> File: """Copy a default execution script to the current project. @@ -618,24 +632,23 @@ def copy_default_execution_script(self, filename: str) -> File: file = self.create_files([file])[0] # query location of default execution scripts from server - jms_api = JmsApi(self.client) - info = jms_api.get_api_info() + info = self._jms_api.get_api_info() execution_script_default_bucket = info["settings"]["execution_script_default_bucket"] # server side copy of the file to project bucket - self.client._start_dt_worker() + self.client.initialize_data_transfer_client() src = StoragePath(path=f"{execution_script_default_bucket}/{filename}") dst = StoragePath(path=f"{self.project_id}/{file.storage_id}") log.info(f"Copying default execution script {filename}") - op = self.client.dt_api.copy([SrcDst(src=src, dst=dst)]) - op = self.client.dt_api.wait_for(op.id)[0] + op = self.client.data_transfer_api.copy([SrcDst(src=src, dst=dst)]) + op = self.client.data_transfer_api.wait_for(op.id)[0] log.debug(f"Operation {op.state}") if op.state != OperationState.Succeeded: raise HPSError(f"Copying of default execution script {filename} failed") # get checksum of copied file - op = self.client.dt_api.get_metadata([dst]) - op = self.client.dt_api.wait_for(op.id)[0] + op = self.client.data_transfer_api.get_metadata([dst]) + op = self.client.data_transfer_api.wait_for(op.id)[0] log.debug(f"Operation {op.state}") if op.state != OperationState.Succeeded: raise HPSError( @@ -681,7 +694,7 @@ def _download_files(project_api: ProjectApi, files: List[File]): temp_dir = tempfile.TemporaryDirectory() - project_api.client._start_dt_worker() + project_api.client.initialize_data_transfer_client() out_path = os.path.join(temp_dir.name, "downloads") base_dir = project_api.project_id @@ -696,10 +709,10 @@ def _download_files(project_api: ProjectApi, files: List[File]): if len(srcs) > 0: log.info(f"Downloading files") - op = project_api.client.dt_api.copy( + op = project_api.client.data_transfer_api.copy( [SrcDst(src=src, dst=dst) for src, dst in zip(srcs, dsts)] ) - op = project_api.client.dt_api.wait_for([op.id]) + op = project_api.client.data_transfer_api.wait_for([op.id]) log.info(f"Operation {op[0].state}") if op[0].state == OperationState.Succeeded: for f in files: @@ -724,12 +737,16 @@ def get_files(project_api: ProjectApi, as_objects=True, content=False, **query_p def _upload_files(project_api: ProjectApi, files): - """ - Uploads files directly using data transfer worker. + """Uploads files directly using data transfer worker.""" - """ + min_v = JMS_VERSIONS[HpsRelease.v1_2_0] + check_version_and_raise( + project_api.version, + min_version=min_v, + msg=f"Uploading file content requires JMS version {min_v} or later.", + ) - project_api.client._start_dt_worker() + project_api.client.initialize_data_transfer_client() srcs = [] dsts = [] base_dir = project_api.project_id @@ -750,10 +767,10 @@ def _upload_files(project_api: ProjectApi, files): dsts.append(StoragePath(path=f"{base_dir}/{os.path.basename(f.storage_id)}")) if len(srcs) > 0: log.info(f"Uploading files") - op = project_api.client.dt_api.copy( + op = project_api.client.data_transfer_api.copy( [SrcDst(src=src, dst=dst) for src, dst in zip(srcs, dsts)] ) - op = project_api.client.dt_api.wait_for(op.id) + op = project_api.client.data_transfer_api.wait_for(op.id) log.info(f"Operation {op[0].state}") if op[0].state == OperationState.Succeeded: _fetch_file_metadata(project_api, files, dsts) @@ -769,8 +786,8 @@ def _fetch_file_metadata( project_api: ProjectApi, files: List[File], storagePaths: List[StoragePath] ): log.info(f"Getting upload file metadata") - op = project_api.client.dt_api.get_metadata(storagePaths) - op = project_api.client.dt_api.wait_for(op.id)[0] + op = project_api.client.data_transfer_api.get_metadata(storagePaths) + op = project_api.client.data_transfer_api.wait_for(op.id)[0] log.info(f"Operation {op.state}") if op.state == OperationState.Succeeded: base_dir = project_api.project_id @@ -830,7 +847,7 @@ def _download_file( ) -> str: """Download a file.""" - project_api.client._start_dt_worker() + project_api.client.initialize_data_transfer_client() if getattr(file, "hash", None) is None: log.warning(f"No hash found for file {file.name}.") @@ -845,8 +862,8 @@ def _download_file( if progress_handler is not None: progress_handler(0) - op = project_api.client.dt_api.copy([SrcDst(src=src, dst=dst)]) - op = project_api.client.dt_api.wait_for([op.id]) + op = project_api.client.data_transfer_api.copy([SrcDst(src=src, dst=dst)]) + op = project_api.client.data_transfer_api.wait_for([op.id]) log.info(f"Operation {op[0].state}") @@ -887,8 +904,7 @@ def archive_project(project_api: ProjectApi, target_path, include_job_files=True log.debug(f"Operation location: {operation_location}") operation_id = operation_location.rsplit("/", 1)[-1] - jms_api = JmsApi(project_api.client) - op = jms_api.monitor_operation(operation_id) + op = project_api._jms_api.monitor_operation(operation_id) if not op.succeeded: raise HPSError(f"Failed to archive project {project_api.project_id}.\n{op}") @@ -896,7 +912,6 @@ def archive_project(project_api: ProjectApi, target_path, include_job_files=True download_link = op.result["backend_path"] # Download archive - # download_link = download_link.replace("ansfs://", project_api.fs_url + "/") log.info(f"Project archive download link: {download_link}") if not os.path.isdir(target_path): @@ -912,12 +927,12 @@ def archive_project(project_api: ProjectApi, target_path, include_job_files=True def _download_archive(project_api: ProjectApi, download_link, target_path): - project_api.client._start_dt_worker() + project_api.client.initialize_data_transfer_client() src = StoragePath(path=f"{download_link}") dst = StoragePath(path=target_path, remote="local") - op = project_api.client.dt_api.copy([SrcDst(src=src, dst=dst)]) - op = project_api.client.dt_api.wait_for([op.id]) + op = project_api.client.data_transfer_api.copy([SrcDst(src=src, dst=dst)]) + op = project_api.client.data_transfer_api.wait_for([op.id]) log.info(f"Operation {op[0].state}") diff --git a/src/ansys/hps/client/rms/api/rms_api.py b/src/ansys/hps/client/rms/api/rms_api.py index f53561ed1..cdd870c02 100644 --- a/src/ansys/hps/client/rms/api/rms_api.py +++ b/src/ansys/hps/client/rms/api/rms_api.py @@ -20,6 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. """Module wrapping around RMS root endpoints.""" +from functools import cache import logging from typing import List @@ -52,13 +53,13 @@ class RmsApi(object): def __init__(self, client: Client): self.client = client - self._fs_url = None @property def url(self) -> str: """URL of the API.""" return f"{self.client.url}/rms/api/v1" + @cache def get_api_info(self): """Get information on the RMS API the client is connected to. @@ -67,6 +68,11 @@ def get_api_info(self): r = self.client.session.get(self.url) return r.json() + @property + def version(self) -> str: + """API version.""" + return self.get_api_info()["build"]["version"] + ################################################################ # Evaluators def get_evaluators_count(self, **query_params) -> int: diff --git a/tests/jms/test_jms_api.py b/tests/jms/test_jms_api.py index 080514c59..fbe86c490 100644 --- a/tests/jms/test_jms_api.py +++ b/tests/jms/test_jms_api.py @@ -60,6 +60,8 @@ def test_jms_api(client): log.debug("=== Projects ===") jms_api = JmsApi(client) + assert jms_api.version is not None + project = jms_api.get_project_by_name(name=proj_name) if project: @@ -76,6 +78,8 @@ def test_jms_api(client): log.debug("=== JobDefinitions ===") project_api = ProjectApi(client, project.id) + assert project_api.version == jms_api.version + job_definitions = project_api.get_job_definitions(active=True) job_def = job_definitions[0] log.debug(f"job_definition={job_def}") diff --git a/tests/rms/test_api.py b/tests/rms/test_api.py index fd32deecf..c9275a5fe 100644 --- a/tests/rms/test_api.py +++ b/tests/rms/test_api.py @@ -34,3 +34,5 @@ def test_rms_api_info(client): info = rms_api.get_api_info() assert "time" in info assert "build" in info + + assert rms_api.version is not None diff --git a/tests/test_check_versions.py b/tests/test_check_versions.py new file mode 100644 index 000000000..2dee9520b --- /dev/null +++ b/tests/test_check_versions.py @@ -0,0 +1,90 @@ +# Copyright (C) 2022 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE.get_projects + +import pytest + +from ansys.hps.client.check_version import check_version_and_raise, version_required +from ansys.hps.client.exceptions import VersionCompatibilityError + + +def test_check_version_and_raise(): + + with pytest.raises(VersionCompatibilityError) as excinfo: + check_version_and_raise(version="1.0.5", min_version="1.0.6", max_version="1.0.10") + assert "Minimum version required: 1.0.6" in str(excinfo.value) + + with pytest.raises(VersionCompatibilityError) as excinfo: + check_version_and_raise(version="1.0.11", min_version="1.0.6", max_version="1.0.10") + assert "Maximum version required: 1.0.10" in str(excinfo.value) + + check_version_and_raise(version="1.0.6", min_version="1.0.6", max_version="1.0.10") + check_version_and_raise(version="1.0.8", min_version="1.0.6", max_version="1.0.10") + check_version_and_raise(version="1.0.10", min_version="1.0.6", max_version="1.0.10") + + +def test_version_required(): + + class MockApi: + + def __init__(self, version): + self.version = version + + @version_required(min_version="1.0.6", max_version="2.1.4") + def fn1(self): + return True + + @version_required(max_version="2.1.4") + def fn2(self): + return True + + @version_required(min_version="0.1.8") + def fn3(self): + return True + + # test fn1 - bound to min and max version + with pytest.raises(VersionCompatibilityError) as excinfo: + MockApi("3.4.5").fn1() + assert "version <= 2.1.4" in str(excinfo.value) + + with pytest.raises(VersionCompatibilityError) as excinfo: + MockApi("0.1.2").fn1() + assert "version >= 1.0.6" in str(excinfo.value) + + assert MockApi("1.2.77").fn1() + assert MockApi("2.1.4").fn1() + assert MockApi("1.2.77-beta").fn1() + + # test fn2 - bound to max version + with pytest.raises(VersionCompatibilityError) as excinfo: + MockApi("3.4.5").fn1() + assert "version <= 2.1.4" in str(excinfo.value) + + assert MockApi("0.0.0").fn2() + assert MockApi("2.1.4").fn2() + + # test fn3 - bound to min version + with pytest.raises(VersionCompatibilityError) as excinfo: + MockApi("0.1.7").fn3() + assert "version >= 0.1.8" in str(excinfo.value) + + assert MockApi("0.1.8").fn3() + assert MockApi("1.2.3").fn3() diff --git a/tests/test_client.py b/tests/test_client.py index f906873ac..84942cf68 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -114,3 +114,17 @@ def test_authentication_username_exception(url, username, keycloak_client): client_id=rep_impersonation_client["clientId"], client_secret=rep_impersonation_client["secret"], ) + + +def test_dt_client(url, username, password): + client = Client(url, username, password) + assert client._dt_client is None + assert client._dt_api is None + + _ = client.data_transfer_api + + assert client._dt_client is not None + assert client._dt_api is not None + + assert client.data_transfer_client == client._dt_client + assert client.data_transfer_api == client._dt_api diff --git a/tests/test_services.py b/tests/test_services.py index c91be866e..63f0d4f25 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -48,7 +48,7 @@ def test_services(client: Client, build_info_path: str): assert "execution_script_default_bucket" in jms_info["settings"] # check dts api - r = client.session.get(f"{client.rep_url}/dt/api/v1") + r = client.session.get(f"{client.url}/dt/api/v1") dt_info = r.json() log.info(f"Dt api info\n{json.dumps(dt_info, indent=2)}") assert "build_info" in dt_info