diff --git a/container/save-and-restore.yml b/container/save-and-restore.yml index a923e3c..51a69e0 100644 --- a/container/save-and-restore.yml +++ b/container/save-and-restore.yml @@ -1,12 +1,3 @@ -# Running elasticsearch in docker -# sudo docker compose -f save-and-restore.yml up -d -# Test: -# curl -X GET "http://localhost:9200/" - -# .env file: -# -# HOST_EXTERNAL_IP_ADDRESS=192.168.50.49 - services: saveandrestore: image: ghcr.io/controlsystemstudio/phoebus/service-save-and-restore:master diff --git a/container/start-save-and-restore.sh b/container/start-save-and-restore.sh index 4f12407..a36c9b7 100755 --- a/container/start-save-and-restore.sh +++ b/container/start-save-and-restore.sh @@ -3,6 +3,3 @@ set -x python create_env_file.py sudo docker compose -f save-and-restore.yml up -d python wait_for_startup.py - -# Wait until the service is started. -#sleep 30 diff --git a/pyproject.toml b/pyproject.toml index d8ca403..05c1744 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ Homepage = "https://github.com/dmgav/save-and-restore-api" dev = [ "pytest >=6", "pytest-cov >=3", + "pytest-asyncio", "pre-commit", "ruff", ] diff --git a/src/save_and_restore_api/__init__.py b/src/save_and_restore_api/__init__.py index cf25015..712a76e 100644 --- a/src/save_and_restore_api/__init__.py +++ b/src/save_and_restore_api/__init__.py @@ -1,11 +1,6 @@ -""" -Copyright (c) 2025 My Name. All rights reserved. - -save-and-restore-api: Python package for communication with CS Studio save-and-restore service -""" - from __future__ import annotations +from ._api_threads import _SaveRestoreAPI_Threads as SaveRestoreAPI from ._version import version as __version__ -__all__ = ["__version__"] +__all__ = ["__version__", "SaveRestoreAPI"] diff --git a/src/save_and_restore_api/_api_async.py b/src/save_and_restore_api/_api_async.py new file mode 100644 index 0000000..80f4201 --- /dev/null +++ b/src/save_and_restore_api/_api_async.py @@ -0,0 +1,41 @@ +import httpx + +from ._api_base import _SaveRestoreAPI_Base + + +class _SaveRestoreAPI_Async(_SaveRestoreAPI_Base): + def open(self): + self._client = httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout) + + async def close(self): + await self._client.aclose() + self._client = None + + async def send_request( + self, method, url, *, params=None, url_params=None, headers=None, data=None, timeout=None, auth=None + ): + try: + client_response = None + kwargs = self._prepare_request( + method=method, + params=params, + url_params=url_params, + headers=headers, + data=data, + timeout=timeout, + auth=auth, + ) + client_response = await self._client.request(method, url, **kwargs) + response = self._process_response(client_response=client_response) + except Exception: + response = self._process_comm_exception(method=method, params=params, client_response=client_response) + + return response + + async def login(self, *, username=None, password=None): + method, url, params = self._prepare_login(username=username, password=password) + await self.send_request(method, url, params=params) + + async def get_node(self, node_uid): + method, url = self._prepare_get_node(node_uid=node_uid) + return await self.send_request(method, url) diff --git a/src/save_and_restore_api/_api_base.py b/src/save_and_restore_api/_api_base.py new file mode 100644 index 0000000..3bd2a64 --- /dev/null +++ b/src/save_and_restore_api/_api_base.py @@ -0,0 +1,181 @@ +# import getpass +import pprint +from collections.abc import Mapping + +import httpx + + +class RequestParameterError(Exception): ... + + +class HTTPRequestError(httpx.RequestError): ... + + +class HTTPClientError(httpx.HTTPStatusError): ... + + +class HTTPServerError(httpx.HTTPStatusError): ... + + +class RequestTimeoutError(TimeoutError): + def __init__(self, msg, request): + msg = f"Request timeout: {msg}" + self.request = request + super().__init__(msg) + + +class RequestFailedError(Exception): + def __init__(self, request, response): + msg = response.get("msg", "") if isinstance(response, Mapping) else str(response) + msg = msg or "(no error message)" + msg = f"Request failed: {msg}" + self.request = request + self.response = response + super().__init__(msg) + + +class _SaveRestoreAPI_Base: + RequestParameterError = RequestParameterError + RequestTimeoutError = RequestTimeoutError + RequestFailedError = RequestFailedError + HTTPRequestError = HTTPRequestError + HTTPClientError = HTTPClientError + HTTPServerError = HTTPServerError + + def __init__(self, *, base_url, timeout, request_fail_exceptions=True): + self._base_url = base_url + self._timeout = timeout + self._client = None + self._root_node_uid = "44bef5de-e8e6-4014-af37-b8f6c8a939a2" + self._auth = None + + @property + def ROOT_NODE_UID(self): + return self._root_node_uid + + @staticmethod + def gen_auth(username, password): + return httpx.BasicAuth(username=username, password=password) + + def set_auth(self, *, username, password): + self._auth = self.gen_auth(username=username, password=password) + + # def set_username_password(self, username=None, password=None): + # if not isinstance(username, str): + # print("Username: ", end="") + # username = input() + # if not isinstance(password, str): + # password = getpass.getpass() + + # self._username = username + # self._password = password + + # # TODO: rewrite the logic in this function + # def _check_response(self, *, request, response): + # """ + # Check if response is a dictionary and has ``"success": True``. Raise an exception + # if the request is considered failed and exceptions are allowed. If response is + # a dictionary and contains no ``"success"``, then it is considered successful. + # """ + # if self._request_fail_exceptions: + # # The response must be a list or a dictionary. If the response is a dictionary + # # and the key 'success': False, then consider the request failed. If there + # # is not 'success' key, then consider the request successful. + # is_iterable = isinstance(response, Iterable) and not isinstance(response, str) + # is_mapping = isinstance(response, Mapping) + # if not any([is_iterable, is_mapping]) or (is_mapping and not response.get("success", True)): + # raise self.RequestFailedError(request, response) + + def _process_response(self, *, client_response): + client_response.raise_for_status() + response = client_response.json() + return response + + def _process_comm_exception(self, *, method, params, client_response): + """ + The function must be called from ``except`` block and returns response with an error message + or raises an exception. + """ + try: + raise + + except httpx.TimeoutException as ex: + raise self.RequestTimeoutError(ex, {"method": method, "params": params}) from ex + + except httpx.RequestError as ex: + raise self.HTTPRequestError(f"HTTP request error: {ex}") from ex + + except httpx.HTTPStatusError as exc: + common_params = {"request": exc.request, "response": exc.response} + if client_response and (client_response.status_code < 500): + # Include more detail that httpx does by default. + message = ( + f"{exc.response.status_code}: " + f"{exc.response.json()['detail'] if client_response.content else ''} " + f"{exc.request.url}" + ) + raise self.HTTPClientError(message, **common_params) from exc + else: + raise self.HTTPServerError(exc, **common_params) from exc + + def _prepare_request( + self, *, method, params=None, url_params=None, headers=None, data=None, timeout=None, auth=None + ): + kwargs = {} + if params: + kwargs.update({"json": params}) + if url_params: + kwargs.update({"params": url_params}) + if headers: + kwargs.update({"headers": headers}) + if data: + kwargs.update({"data": data}) + if timeout is not None: + kwargs.update({"timeout": self._adjust_timeout(timeout)}) + if method != "GET": + auth = auth or self._auth + if auth is not None: + kwargs.update({"auth": auth}) + return kwargs + + def _prepare_login(self, *, username=None, password=None): + method, url = "POST", "/login" + params = {"username": username, "password": password} + return method, url, params + + def _prepare_get_node(self, *, node_uid): + method, url = "GET", f"/node/{node_uid}" + return method, url + + def get_children(self, node_uid): + return self.send_request("GET", f"/node/{node_uid}/children") + + def create_config(self, parent_node_uid, name, pv_list): + config_dict = { + "configurationNode": { + "name": name, + "nodeType": "CONFIGURATION", + "userName": self._username, + }, + "configurationData": { + "pvList": pv_list, + }, + } + print(f"config_dict=\n{pprint.pformat(config_dict)}") + return self.send_request("PUT", f"/config?parentNodeId={parent_node_uid}", json=config_dict) + + def update_config(self, node_uid, name, pv_list): + config_dict = { + "configurationNode": { + "name": name, + "nodeType": "CONFIGURATION", + "userName": self._username, + "uniqueId": node_uid, + }, + "configurationData": { + "pvList": pv_list, + }, + } + print(f"config_dict=\n{pprint.pformat(config_dict)}") + # return self.send_request("POST", f"/config/{node_uid}", json=config_dict) + return self.send_request("POST", "/config", json=config_dict) diff --git a/src/save_and_restore_api/_api_threads.py b/src/save_and_restore_api/_api_threads.py new file mode 100644 index 0000000..20d40c7 --- /dev/null +++ b/src/save_and_restore_api/_api_threads.py @@ -0,0 +1,41 @@ +import httpx + +from ._api_base import _SaveRestoreAPI_Base + + +class _SaveRestoreAPI_Threads(_SaveRestoreAPI_Base): + def open(self): + self._client = httpx.Client(base_url=self._base_url, timeout=self._timeout) + + def close(self): + self._client.close() + self._client = None + + def send_request( + self, method, url, *, params=None, url_params=None, headers=None, data=None, timeout=None, auth=None + ): + try: + client_response = None + kwargs = self._prepare_request( + method=method, + params=params, + url_params=url_params, + headers=headers, + data=data, + timeout=timeout, + auth=auth, + ) + client_response = self._client.request(method, url, **kwargs) + response = self._process_response(client_response=client_response) + except Exception: + response = self._process_comm_exception(method=method, params=params, client_response=client_response) + + return response + + def login(self, *, username=None, password=None): + method, url, params = self._prepare_login(username=username, password=password) + self.send_request(method, url, params=params) + + def get_node(self, node_uid): + method, url = self._prepare_get_node(node_uid=node_uid) + return self.send_request(method, url) diff --git a/src/save_and_restore_api/aio/__init__.py b/src/save_and_restore_api/aio/__init__.py new file mode 100644 index 0000000..bb89d61 --- /dev/null +++ b/src/save_and_restore_api/aio/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from .._api_async import _SaveRestoreAPI_Async as SaveRestoreAPI +from .._version import version as __version__ + +__all__ = ["__version__", "SaveRestoreAPI"] diff --git a/src/save_and_restore_api/tools/upload.py b/src/save_and_restore_api/tools/upload.py index a4244a0..69a90b1 100644 --- a/src/save_and_restore_api/tools/upload.py +++ b/src/save_and_restore_api/tools/upload.py @@ -1,8 +1,13 @@ -import getpass +import argparse + +# import getpass import logging -import pprint +import os + +# import pprint +import save_and_restore_api -import httpx +version = save_and_restore_api.__version__ logger = logging.getLogger(__name__) @@ -11,99 +16,6 @@ file_name = "auto_settings.sav" -class SaveRestoreAPI: - def __init__(self, *, base_url, timeout): - self._base_url = base_url - self._timeout = timeout - self._client = None - self._root_node_uid = "44bef5de-e8e6-4014-af37-b8f6c8a939a2" - - self._username = None - self._password = None - # self._username = "dgavrilov" - # self._password = "zelenyi.gena.krokodil" - - @property - def ROOT_NODE_UID(self): - return self._root_node_uid - - def open(self): - auth = httpx.BasicAuth(username=self._username, password=self._password) - self._client = httpx.Client(base_url=self._base_url, timeout=timeout, auth=auth) - - def close(self): - self._client.close() - self._client = None - - def set_username_password(self, username=None, password=None): - if not isinstance(username, str): - print("Username: ", end="") - username = input() - if not isinstance(password, str): - password = getpass.getpass() - - self._username = username - self._password = password - - def login(self, *, username=None, password=None): - params = {"username": self._username, "password": self._password} - self.send_request("POST", "/login", json=params) - - def send_request(self, method, url, **kwargs): - response = self._client.request(method, url, **kwargs) - - print(f"{response.request.url=}") - print(f"{response.headers.get('content-type')=}") - - if response.status_code != 200: - print(f"Request failed: status code {response.status_code}") - print(f"Error message: {response.text}") - raise Exception(f"Request failed with code {response.status_code}") - - if response.headers.get("content-type") == "application/json": - data = response.json() - else: - data = {} - - return data - - def get_node(self, node_uid): - return self.send_request("GET", f"/node/{node_uid}") - - def get_children(self, node_uid): - return self.send_request("GET", f"/node/{node_uid}/children") - - def create_config(self, parent_node_uid, name, pv_list): - config_dict = { - "configurationNode": { - "name": name, - "nodeType": "CONFIGURATION", - "userName": self._username, - }, - "configurationData": { - "pvList": pv_list, - }, - } - print(f"config_dict=\n{pprint.pformat(config_dict)}") - return self.send_request("PUT", f"/config?parentNodeId={parent_node_uid}", json=config_dict) - - def update_config(self, node_uid, name, pv_list): - config_dict = { - "configurationNode": { - "name": name, - "nodeType": "CONFIGURATION", - "userName": self._username, - "uniqueId": node_uid, - }, - "configurationData": { - "pvList": pv_list, - }, - } - print(f"config_dict=\n{pprint.pformat(config_dict)}") - # return self.send_request("POST", f"/config/{node_uid}", json=config_dict) - return self.send_request("POST", "/config", json=config_dict) - - def add_to_pv_list(pv_list, *, pv_name): pv_list.append({"pvName": pv_name}) @@ -121,34 +33,114 @@ def load_pvs_from_autosave_file(file_name): return pv_names +def split_config_name(config_name): + if not config_name.startswith("/"): + config_name = "/" + config_name + _ = config_name.split("/") + folders, name = _[1:-1], _[-1] + return folders, name + + def main(): logging.basicConfig(level=logging.WARNING) - # logging.getLogger("bluesky_queueserver").setLevel("INFO") + logging.getLogger("save-and-restore-api").setLevel("INFO") + + def formatter(prog): + # Set maximum width such that printed help mostly fits in the RTD theme code block (documentation). + return argparse.RawDescriptionHelpFormatter(prog, max_help_position=20, width=90) + + parser = argparse.ArgumentParser( + description="save-and-restore-upload: create configuration based on a batch of PVs.\n" + f"save-and-restore-api version {version}\n\n" + "Read a batch of PVs from a file and creates a configuration in Save and Restore service.\n", + formatter_class=formatter, + ) + + parser.add_argument( + "--file-name", + "-f", + dest="file_name", + type=str, + default=None, + help="File name with PV names.", + ) + + parser.add_argument( + "--config-name", + "-c", + dest="config_name", + type=str, + default=None, + help="Configuration name including folders, e.g. /detectors/imaging/eiger_config", + ) + + parser.add_argument( + "--create-folders", + dest="create_folders", + action="store_true", + help="Configuration name including folders, e.g. /detectors/imaging/eiger_config", + ) + + parser.add_argument( + "--update", + dest="config_update", + action="store_true", + help="Configuration name including folders, e.g. /detectors/imaging/eiger_config", + ) + + args = parser.parse_args() + file_name = args.file_name + config_name = args.config_name + create_folders = args.create_folders + config_update = args.config_update - SR = SaveRestoreAPI(base_url=BASE_URL, timeout=timeout) try: - pv_names = load_pvs_from_autosave_file(file_name) - - SR.set_username_password() - SR.open() - SR.login() - - data = SR.get_node(SR.ROOT_NODE_UID) - print(f"data=\n{pprint.pformat(data)}") - data = SR.get_children(data["uniqueId"]) - print(f"data=\n{pprint.pformat(data)}") - parent_node_uid = data[0]["uniqueId"] - name = "test5" - pv_list = [] - for pv_name in pv_names: - add_to_pv_list(pv_list, pv_name=pv_name) - add_to_pv_list(pv_list, pv_name="13SIM1:{SimDetector-Cam:1}cam1:BinX") - add_to_pv_list(pv_list, pv_name="13SIM1:{SimDetector-Cam:1}cam1:BinY") - data = SR.create_config(parent_node_uid, name, pv_list) - print(f"data=\n{pprint.pformat(data)}") - node_uid = data["configurationNode"]["uniqueId"] - data = SR.update_config(node_uid, name + "a", pv_list) - print(f"data=\n{pprint.pformat(data)}") - - finally: - SR.close() + if args.file_name is None: + raise ValueError("Required '--file-name' ('-f') parameter is not specified") + + if args.config_name is None: + raise ValueError("Required '--config-name' ('-c') parameter is not specified") + + file_name = os.path.abspath(os.path.expanduser(file_name)) + + print(f"file_name={file_name}") + print(f"config_name={config_name}") + print(f"create_folders={create_folders}") + print(f"update={config_update}") + + if not os.path.isfile(file_name): + raise ValueError(f"Input file '{file_name}' does not exist") + + folders, name = split_config_name(config_name) + print(f"folders={folders}, name={name}") + + except Exception as ex: + logger.error(f"Failed: {ex}") + + # SR = SaveRestoreAPI(base_url=BASE_URL, timeout=timeout) + # try: + # pv_names = load_pvs_from_autosave_file(file_name) + + # SR.set_username_password() + # SR.open() + # SR.login() + + # data = SR.get_node(SR.ROOT_NODE_UID) + # print(f"data=\n{pprint.pformat(data)}") + # data = SR.get_children(data["uniqueId"]) + # print(f"data=\n{pprint.pformat(data)}") + # parent_node_uid = data[0]["uniqueId"] + # name = "test5" + # pv_list = [] + # for pv_name in pv_names: + # add_to_pv_list(pv_list, pv_name=pv_name) + # add_to_pv_list(pv_list, pv_name="13SIM1:{SimDetector-Cam:1}cam1:BinX") + # add_to_pv_list(pv_list, pv_name="13SIM1:{SimDetector-Cam:1}cam1:BinY") + # data = SR.create_config(parent_node_uid, name, pv_list) + # print(f"data=\n{pprint.pformat(data)}") + # node_uid = data["configurationNode"]["uniqueId"] + # data = SR.update_config(node_uid, name + "a", pv_list) + # print(f"data=\n{pprint.pformat(data)}") + + # finally: + # SR.close() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/common.py b/tests/common.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_package.py b/tests/test_package.py index 217fcfe..d689df9 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -2,24 +2,37 @@ import importlib.metadata -import save_and_restore_api as m -from save_and_restore_api.tools.upload import SaveRestoreAPI +import pytest +import save_and_restore_api +from save_and_restore_api import SaveRestoreAPI as SaveRestoreAPI_Threads +from save_and_restore_api.aio import SaveRestoreAPI as SaveRestoreAPI_Async -def test_version(): - assert importlib.metadata.version("save_and_restore_api") == m.__version__ +admin_username, admin_password = "admin", "adminPass" +user_username, user_password = "user", "userPass" +read_username, read_password = "johndoe", "1234" + +base_url = "http://localhost:8080/save-restore" -def test_import(): - from save_and_restore_api.tools.upload import SaveRestoreAPI # noqa: F401 +def test_version(): + assert importlib.metadata.version("save_and_restore_api") == save_and_restore_api.__version__ def test_comm(): - SR = SaveRestoreAPI(base_url="http://localhost:8080/save-restore", timeout=2) - # SR.set_username_password(username="johndoe", password="1234") - SR.set_username_password(username="user", password="userPass") - # SR.set_username_password(username="admin", password="adminPass") + SR = SaveRestoreAPI_Threads(base_url=base_url, timeout=2) + SR.set_auth(username=user_username, password=user_password) SR.open() - SR.login() + SR.login(username="user", password="userPass") SR.get_node(SR.ROOT_NODE_UID) SR.close() + + +@pytest.mark.asyncio +async def test_comm_async(): + SR = SaveRestoreAPI_Async(base_url=base_url, timeout=2) + SR.set_auth(username="user", password="userPass") + SR.open() + await SR.login(username="user", password="userPass") + await SR.get_node(SR.ROOT_NODE_UID) + await SR.close()