From 5bed4304b015630ce2b7004e54086ac8b88c729c Mon Sep 17 00:00:00 2001 From: Federico Negri Date: Fri, 21 Mar 2025 18:08:45 +0100 Subject: [PATCH 1/9] draft checking compatibility --- src/ansys/hps/client/check_version.py | 116 ++++++++++++++++++++ src/ansys/hps/client/client.py | 3 +- src/ansys/hps/client/exceptions.py | 7 ++ src/ansys/hps/client/jms/api/jms_api.py | 27 +++-- src/ansys/hps/client/jms/api/project_api.py | 50 +++++---- src/ansys/hps/client/rms/api/rms_api.py | 8 +- 6 files changed, 180 insertions(+), 31 deletions(-) create mode 100644 src/ansys/hps/client/check_version.py diff --git a/src/ansys/hps/client/check_version.py b/src/ansys/hps/client/check_version.py new file mode 100644 index 000000000..d4100b3cd --- /dev/null +++ b/src/ansys/hps/client/check_version.py @@ -0,0 +1,116 @@ +# 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 functools import wraps +from typing import Protocol + +from .exceptions import VersionCompatibilityError + + +class API(Protocol): + """Protocol for API classes.""" + + @property + def version(self) -> str: + pass + + +def check_min_version(version, min_version) -> bool: + """Check if a version string meets a minimum version. + + Parameters + ---------- + version : str + Version string to check. For example, ``"1.32.1"``. + min_version : str + Required version for comparison. For example, ``"1.32.2"``. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + from packaging.version import parse + + return parse(version) >= parse(min_version) + + +def check_max_version(version, max_version) -> bool: + """Check if a version string meets a maximum version. + + Parameters + ---------- + version : str + Version string to check. For example, ``"1.32.1"``. + max_version : str + Required version for comparison. For example, ``"1.32.2"``. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + from packaging.version import parse + + return parse(version) <= parse(max_version) + + +def check_version_and_raise(version, min_version=None, max_version=None, msg=None): + + if min_version is not None and 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) + + if max_version is not None and 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 version_required(min_version=None, max_version=None): + """Decorator for API methods to check version requirements.""" + + def decorator(func): + + @wraps(func) + def wrapper(self: API, *args, **kwargs): + if min_version is not None and not check_min_version(self.version, min_version): + raise VersionCompatibilityError( + f"{func.__name__} requires {type(self).__name__} version >= " + min_version + ) + + if max_version is not None and not check_max_version(self.version, max_version): + raise VersionCompatibilityError( + f"{func.__name__} requires {type(self).__name__} version <= " + min_version + ) + 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..a39431ff7 100644 --- a/src/ansys/hps/client/client.py +++ b/src/ansys/hps/client/client.py @@ -163,7 +163,8 @@ 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: DTClient | None = None + self.dt_api: DataTransferApi | None = None if self.verify is None: self.verify = False 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..66dc72ae1 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 version_required from ansys.hps.client.client import Client from ansys.hps.client.common import Object from ansys.hps.client.exceptions import HPSError @@ -41,8 +44,13 @@ log = logging.getLogger(__name__) +"""HPS to JMS version mapping.""" +_VERSIONS = { + "1.2.0": "1.1.4", +} + -class JmsApi(object): +class JmsApi: """Wraps around the JMS root endpoints. Parameters @@ -68,13 +76,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 +91,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 +134,7 @@ def delete_project(self, project): """Delete a project.""" return delete_project(self.client, self.url, project) + @version_required(min_version=_VERSIONS["1.2.0"]) def restore_project(self, path: str) -> Project: """Restore a project from an archive. @@ -451,10 +465,7 @@ def _restore_project(jms_api, archive_path): def _upload_archive(jms_api: JmsApi, archive_path, bucket): - """ - Uploads archive using data transfer worker. - - """ + """Uploads archive using data transfer worker.""" jms_api.client._start_dt_worker() src = StoragePath(path=archive_path, remote="local") diff --git a/src/ansys/hps/client/jms/api/project_api.py b/src/ansys/hps/client/jms/api/project_api.py index 4e8ef6078..766324a92 100644 --- a/src/ansys/hps/client/jms/api/project_api.py +++ b/src/ansys/hps/client/jms/api/project_api.py @@ -32,6 +32,7 @@ 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 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 @@ -55,7 +56,7 @@ from ansys.hps.client.rms.models import AnalyzeRequirements, AnalyzeResponse from .base import create_objects, delete_objects, get_objects, update_objects -from .jms_api import JmsApi, _copy_objects +from .jms_api import _VERSIONS, JmsApi, _copy_objects log = logging.getLogger(__name__) @@ -105,8 +106,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 +119,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 +133,7 @@ def copy_project(self, wait: bool = True) -> str: else: return r + @version_required(min_version=_VERSIONS["1.2.0"]) def archive_project(self, path: str, include_job_files: bool = True): """Archive a project and save it to disk. @@ -166,6 +160,17 @@ 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: + check_version_and_raise( + self.version, + min_version=_VERSIONS["1.2.0"], + msg=( + f"ProjectApi.get_files with content=True requires" + f" JMS version {_VERSIONS['1.2.0']} 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 +185,7 @@ def delete_files(self, files: List[File]): """Delete files.""" return self._delete_objects(files, File) + @version_required(min_version=_VERSIONS["1.2.0"]) def download_file( self, file: File, @@ -603,6 +609,8 @@ def delete_license_contexts(self): r = self.client.session.delete(url) ################################################################ + + @version_required(min_version=_VERSIONS["1.2.0"]) def copy_default_execution_script(self, filename: str) -> File: """Copy a default execution script to the current project. @@ -618,8 +626,7 @@ 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 @@ -724,10 +731,13 @@ 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.""" - """ + check_version_and_raise( + project_api.version, + min_version=_VERSIONS["1.2.0"], + msg=f"Uploading file content requires JMS version {_VERSIONS['1.2.0']} or later.", + ) project_api.client._start_dt_worker() srcs = [] @@ -887,8 +897,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 +905,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): 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: From 55273350d167c65b6622791504d79239b0eb38d2 Mon Sep 17 00:00:00 2001 From: Federico Negri Date: Mon, 24 Mar 2025 09:18:42 +0100 Subject: [PATCH 2/9] improve Client interface --- src/ansys/hps/client/client.py | 44 ++++++++++++++------- src/ansys/hps/client/jms/api/jms_api.py | 10 ++--- src/ansys/hps/client/jms/api/project_api.py | 38 +++++++++--------- tests/test_services.py | 2 +- 4 files changed, 55 insertions(+), 39 deletions(-) diff --git a/src/ansys/hps/client/client.py b/src/ansys/hps/client/client.py index a39431ff7..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,8 +163,9 @@ def __init__( self.client_secret = client_secret self.verify = verify self.data_transfer_url = url + f"/dt/api/v1" - self.dt_client: DTClient | None = None - self.dt_api: DataTransferApi | None = None + + self._dt_client: DataTransferClient | None = None + self._dt_api: DataTransferApi | None = None if self.verify is None: self.verify = False @@ -266,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) @@ -296,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.") @@ -378,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) @@ -414,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/jms/api/jms_api.py b/src/ansys/hps/client/jms/api/jms_api.py index 66dc72ae1..d9c5da87e 100644 --- a/src/ansys/hps/client/jms/api/jms_api.py +++ b/src/ansys/hps/client/jms/api/jms_api.py @@ -456,8 +456,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") @@ -466,13 +466,13 @@ 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() + 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 766324a92..2b7d7c8c0 100644 --- a/src/ansys/hps/client/jms/api/project_api.py +++ b/src/ansys/hps/client/jms/api/project_api.py @@ -630,19 +630,19 @@ def copy_default_execution_script(self, filename: str) -> File: 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( @@ -688,7 +688,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 @@ -703,10 +703,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: @@ -739,7 +739,7 @@ def _upload_files(project_api: ProjectApi, files): msg=f"Uploading file content requires JMS version {_VERSIONS['1.2.0']} or later.", ) - project_api.client._start_dt_worker() + project_api.client.initialize_data_transfer_client() srcs = [] dsts = [] base_dir = project_api.project_id @@ -760,10 +760,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) @@ -779,8 +779,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 @@ -840,7 +840,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}.") @@ -855,8 +855,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}") @@ -920,12 +920,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/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 From d8d6bdc46006c1e8c4a0c86b39f29aa467487614 Mon Sep 17 00:00:00 2001 From: Federico Negri Date: Mon, 24 Mar 2025 09:40:59 +0100 Subject: [PATCH 3/9] tests --- tests/jms/test_jms_api.py | 4 ++++ tests/rms/test_api.py | 2 ++ 2 files changed, 6 insertions(+) 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 From 3eac6bc0834b988db6a328740d73d6872d7eb991 Mon Sep 17 00:00:00 2001 From: Federico Negri Date: Mon, 24 Mar 2025 10:16:51 +0100 Subject: [PATCH 4/9] document compatibility --- doc/source/getting_started/index.rst | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/doc/source/getting_started/index.rst b/doc/source/getting_started/index.rst index 7ea6a30f3..4333f69c9 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 (old HPS features are still supported, new ones may not) +- :octicon:`x` Incompatible \ No newline at end of file From 3e7c04a11677d8c815537f531ebc047ade3c2a10 Mon Sep 17 00:00:00 2001 From: Federico Negri Date: Mon, 24 Mar 2025 10:50:50 +0100 Subject: [PATCH 5/9] rework versions --- src/ansys/hps/client/__version__.py | 2 +- src/ansys/hps/client/check_version.py | 25 +++++++++++++++++++++ src/ansys/hps/client/jms/api/jms_api.py | 9 ++------ src/ansys/hps/client/jms/api/project_api.py | 25 +++++++++++++-------- 4 files changed, 44 insertions(+), 17 deletions(-) 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 index d4100b3cd..7037b5a06 100644 --- a/src/ansys/hps/client/check_version.py +++ b/src/ansys/hps/client/check_version.py @@ -24,12 +24,37 @@ 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 API(Protocol): """Protocol for API classes.""" diff --git a/src/ansys/hps/client/jms/api/jms_api.py b/src/ansys/hps/client/jms/api/jms_api.py index d9c5da87e..900bbfa28 100644 --- a/src/ansys/hps/client/jms/api/jms_api.py +++ b/src/ansys/hps/client/jms/api/jms_api.py @@ -32,7 +32,7 @@ from ansys.hps.data_transfer.client.models.ops import OperationState import backoff -from ansys.hps.client.check_version import version_required +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 @@ -44,11 +44,6 @@ log = logging.getLogger(__name__) -"""HPS to JMS version mapping.""" -_VERSIONS = { - "1.2.0": "1.1.4", -} - class JmsApi: """Wraps around the JMS root endpoints. @@ -134,7 +129,7 @@ def delete_project(self, project): """Delete a project.""" return delete_project(self.client, self.url, project) - @version_required(min_version=_VERSIONS["1.2.0"]) + @version_required(min_version=JMS_VERSIONS[HpsRelease.v1_2_0]) def restore_project(self, path: str) -> Project: """Restore a project from an archive. diff --git a/src/ansys/hps/client/jms/api/project_api.py b/src/ansys/hps/client/jms/api/project_api.py index 2b7d7c8c0..10644a51c 100644 --- a/src/ansys/hps/client/jms/api/project_api.py +++ b/src/ansys/hps/client/jms/api/project_api.py @@ -32,7 +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 check_version_and_raise, version_required +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 @@ -56,7 +61,7 @@ from ansys.hps.client.rms.models import AnalyzeRequirements, AnalyzeResponse from .base import create_objects, delete_objects, get_objects, update_objects -from .jms_api import _VERSIONS, JmsApi, _copy_objects +from .jms_api import JmsApi, _copy_objects log = logging.getLogger(__name__) @@ -133,7 +138,7 @@ def copy_project(self, wait: bool = True) -> str: else: return r - @version_required(min_version=_VERSIONS["1.2.0"]) + @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. @@ -162,12 +167,13 @@ def get_files(self, as_objects=True, content=False, **query_params) -> List[File """ if content: + min_v = JMS_VERSIONS[HpsRelease.v1_2_0] check_version_and_raise( self.version, - min_version=_VERSIONS["1.2.0"], + min_version=min_v, msg=( f"ProjectApi.get_files with content=True requires" - f" JMS version {_VERSIONS['1.2.0']} or later." + f" JMS version {min_v} or later." ), ) @@ -185,7 +191,7 @@ def delete_files(self, files: List[File]): """Delete files.""" return self._delete_objects(files, File) - @version_required(min_version=_VERSIONS["1.2.0"]) + @version_required(min_version=JMS_VERSIONS[HpsRelease.v1_2_0]) def download_file( self, file: File, @@ -610,7 +616,7 @@ def delete_license_contexts(self): ################################################################ - @version_required(min_version=_VERSIONS["1.2.0"]) + @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. @@ -733,10 +739,11 @@ 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.""" + min_v = JMS_VERSIONS[HpsRelease.v1_2_0] check_version_and_raise( project_api.version, - min_version=_VERSIONS["1.2.0"], - msg=f"Uploading file content requires JMS version {_VERSIONS['1.2.0']} or later.", + min_version=min_v, + msg=f"Uploading file content requires JMS version {min_v} or later.", ) project_api.client.initialize_data_transfer_client() From 1b079c59f3380fcb438415d5de742bfcd8a78c11 Mon Sep 17 00:00:00 2001 From: Federico Negri Date: Mon, 24 Mar 2025 11:24:04 +0100 Subject: [PATCH 6/9] dt client from public url --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 53a30bf5e..17ccf9dca 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] From 09a4859e26b9d31c6fe34789dddb3a7a4571adec Mon Sep 17 00:00:00 2001 From: Federico Negri Date: Mon, 24 Mar 2025 11:46:55 +0100 Subject: [PATCH 7/9] add tests --- src/ansys/hps/client/check_version.py | 78 ++++++++++++--------------- tests/test_client.py | 14 +++++ 2 files changed, 48 insertions(+), 44 deletions(-) diff --git a/src/ansys/hps/client/check_version.py b/src/ansys/hps/client/check_version.py index 7037b5a06..8e0e4a9ae 100644 --- a/src/ansys/hps/client/check_version.py +++ b/src/ansys/hps/client/check_version.py @@ -55,7 +55,7 @@ class HpsRelease(Enum): } -class API(Protocol): +class ApiProtocol(Protocol): """Protocol for API classes.""" @property @@ -63,77 +63,67 @@ def version(self) -> str: pass -def check_min_version(version, min_version) -> bool: - """Check if a version string meets a minimum version. - - Parameters - ---------- - version : str - Version string to check. For example, ``"1.32.1"``. - min_version : str - Required version for comparison. For example, ``"1.32.2"``. - - Returns - ------- - bool - ``True`` when successful, ``False`` when failed. - """ +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, max_version) -> bool: - """Check if a version string meets a maximum version. - - Parameters - ---------- - version : str - Version string to check. For example, ``"1.32.1"``. - max_version : str - Required version for comparison. For example, ``"1.32.2"``. - - Returns - ------- - bool - ``True`` when successful, ``False`` when failed. - """ +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_version_and_raise(version, min_version=None, max_version=None, msg=None): +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 min_version is not None and not check_min_version(version, min_version): + 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) - if max_version is not None and not check_max_version(version, max_version): + +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: API, *args, **kwargs): - if min_version is not None and not check_min_version(self.version, min_version): - raise VersionCompatibilityError( - f"{func.__name__} requires {type(self).__name__} version >= " + min_version - ) - - if max_version is not None and not check_max_version(self.version, max_version): - raise VersionCompatibilityError( - f"{func.__name__} requires {type(self).__name__} version <= " + min_version - ) + 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 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 From 6cddcfab1a8ec20286739d5eb53237b531a532fc Mon Sep 17 00:00:00 2001 From: Federico Negri Date: Mon, 24 Mar 2025 11:48:22 +0100 Subject: [PATCH 8/9] add tests --- tests/test_check_versions.py | 90 ++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 tests/test_check_versions.py 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() From 113d3bf78f1533824f635171463a2f6c4c4dae16 Mon Sep 17 00:00:00 2001 From: Federico Negri Date: Mon, 24 Mar 2025 11:58:31 +0100 Subject: [PATCH 9/9] adjust wording --- doc/source/getting_started/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/getting_started/index.rst b/doc/source/getting_started/index.rst index 4333f69c9..f6688b221 100644 --- a/doc/source/getting_started/index.rst +++ b/doc/source/getting_started/index.rst @@ -81,5 +81,5 @@ The following table summarizes the compatibility between PyHPS versions and HPS Legend: - :octicon:`check-circle-fill` Compatible -- :octicon:`check-circle` Backward compatible (old HPS features are still supported, new ones may not) +- :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