From a357a964ba6813d1f603f9b3fddb4df13aff2c23 Mon Sep 17 00:00:00 2001 From: Dmitri Gavrilov Date: Wed, 17 Sep 2025 11:30:46 -0400 Subject: [PATCH 1/8] ENH: refactor of the 'upload' CLI --- src/save_and_restore_api/api.py | 99 ++++++++++ src/save_and_restore_api/tools/upload.py | 238 +++++++++++------------ 2 files changed, 214 insertions(+), 123 deletions(-) create mode 100644 src/save_and_restore_api/api.py diff --git a/src/save_and_restore_api/api.py b/src/save_and_restore_api/api.py new file mode 100644 index 0000000..87da7d0 --- /dev/null +++ b/src/save_and_restore_api/api.py @@ -0,0 +1,99 @@ +import getpass + +# import logging +import pprint + +import httpx + + +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=self._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) 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() From 7c5c90d7d9d081b0f3201dc3c9201df2fd957c65 Mon Sep 17 00:00:00 2001 From: Dmitri Gavrilov Date: Mon, 22 Sep 2025 22:44:31 -0400 Subject: [PATCH 2/8] ENH: add error processing for 'send_request' --- container/save-and-restore.yml | 9 -- container/start-save-and-restore.sh | 3 - src/save_and_restore_api/__init__.py | 3 +- src/save_and_restore_api/_api.py | 185 +++++++++++++++++++++++++++ src/save_and_restore_api/api.py | 99 -------------- tests/test_package.py | 4 +- 6 files changed, 189 insertions(+), 114 deletions(-) create mode 100644 src/save_and_restore_api/_api.py delete mode 100644 src/save_and_restore_api/api.py 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/src/save_and_restore_api/__init__.py b/src/save_and_restore_api/__init__.py index cf25015..c87156f 100644 --- a/src/save_and_restore_api/__init__.py +++ b/src/save_and_restore_api/__init__.py @@ -6,6 +6,7 @@ from __future__ import annotations +from ._api import SaveRestoreAPI from ._version import version as __version__ -__all__ = ["__version__"] +__all__ = ["__version__", "SaveRestoreAPI"] diff --git a/src/save_and_restore_api/_api.py b/src/save_and_restore_api/_api.py new file mode 100644 index 0000000..d30adf8 --- /dev/null +++ b/src/save_and_restore_api/_api.py @@ -0,0 +1,185 @@ +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: + 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._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=self._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 + + # # 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 send_request(self, method, url, *, params=None, url_params=None, headers=None, data=None, timeout=None): + try: + client_response = 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)}) + 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): + params = {"username": self._username, "password": self._password} + self.send_request("POST", "/login", params=params) + + 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) diff --git a/src/save_and_restore_api/api.py b/src/save_and_restore_api/api.py deleted file mode 100644 index 87da7d0..0000000 --- a/src/save_and_restore_api/api.py +++ /dev/null @@ -1,99 +0,0 @@ -import getpass - -# import logging -import pprint - -import httpx - - -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=self._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) diff --git a/tests/test_package.py b/tests/test_package.py index 217fcfe..b215ce1 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -3,7 +3,7 @@ import importlib.metadata import save_and_restore_api as m -from save_and_restore_api.tools.upload import SaveRestoreAPI +from save_and_restore_api import SaveRestoreAPI def test_version(): @@ -11,7 +11,7 @@ def test_version(): def test_import(): - from save_and_restore_api.tools.upload import SaveRestoreAPI # noqa: F401 + from save_and_restore_api import SaveRestoreAPI # noqa: F401 def test_comm(): From 316b20b6206322b6af726dadbc1a49e7cd3e8911 Mon Sep 17 00:00:00 2001 From: Dmitri Gavrilov Date: Mon, 22 Sep 2025 23:10:44 -0400 Subject: [PATCH 3/8] ENH: send auth with client.request --- src/save_and_restore_api/_api.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/save_and_restore_api/_api.py b/src/save_and_restore_api/_api.py index d30adf8..40b6162 100644 --- a/src/save_and_restore_api/_api.py +++ b/src/save_and_restore_api/_api.py @@ -47,19 +47,20 @@ def __init__(self, *, base_url, timeout, request_fail_exceptions=True): 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" + self._auth = None @property def ROOT_NODE_UID(self): return self._root_node_uid + 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 open(self): - auth = httpx.BasicAuth(username=self._username, password=self._password) - self._client = httpx.Client(base_url=self._base_url, timeout=self._timeout, auth=auth) + self._client = httpx.Client(base_url=self._base_url, timeout=self._timeout) def close(self): self._client.close() @@ -123,7 +124,9 @@ def _process_comm_exception(self, *, method, params, client_response): else: raise self.HTTPServerError(exc, **common_params) from exc - def send_request(self, method, url, *, params=None, url_params=None, headers=None, data=None, timeout=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 = {} @@ -137,6 +140,10 @@ def send_request(self, method, url, *, params=None, url_params=None, headers=Non 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}) client_response = self._client.request(method, url, **kwargs) response = self._process_response(client_response=client_response) except Exception: From 91792c7f32267325dd3799f40c7f00425bef8ab8 Mon Sep 17 00:00:00 2001 From: Dmitri Gavrilov Date: Tue, 23 Sep 2025 10:55:58 -0400 Subject: [PATCH 4/8] ENH: add imports for treaded and async versions --- src/save_and_restore_api/__init__.py | 8 +------- src/save_and_restore_api/_api_async.py | 4 ++++ src/save_and_restore_api/{_api.py => _api_base.py} | 2 +- src/save_and_restore_api/_api_threads.py | 4 ++++ src/save_and_restore_api/aio/__init__.py | 6 ++++++ 5 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 src/save_and_restore_api/_api_async.py rename src/save_and_restore_api/{_api.py => _api_base.py} (99%) create mode 100644 src/save_and_restore_api/_api_threads.py create mode 100644 src/save_and_restore_api/aio/__init__.py diff --git a/src/save_and_restore_api/__init__.py b/src/save_and_restore_api/__init__.py index c87156f..712a76e 100644 --- a/src/save_and_restore_api/__init__.py +++ b/src/save_and_restore_api/__init__.py @@ -1,12 +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 import SaveRestoreAPI +from ._api_threads import _SaveRestoreAPI_Threads as SaveRestoreAPI from ._version import version as __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..2e4a532 --- /dev/null +++ b/src/save_and_restore_api/_api_async.py @@ -0,0 +1,4 @@ +from ._api_base import _SaveRestoreAPI_Base + + +class _SaveRestoreAPI_Async(_SaveRestoreAPI_Base): ... diff --git a/src/save_and_restore_api/_api.py b/src/save_and_restore_api/_api_base.py similarity index 99% rename from src/save_and_restore_api/_api.py rename to src/save_and_restore_api/_api_base.py index 40b6162..4085406 100644 --- a/src/save_and_restore_api/_api.py +++ b/src/save_and_restore_api/_api_base.py @@ -34,7 +34,7 @@ def __init__(self, request, response): super().__init__(msg) -class SaveRestoreAPI: +class _SaveRestoreAPI_Base: RequestParameterError = RequestParameterError RequestTimeoutError = RequestTimeoutError RequestFailedError = RequestFailedError 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..2f645aa --- /dev/null +++ b/src/save_and_restore_api/_api_threads.py @@ -0,0 +1,4 @@ +from ._api_base import _SaveRestoreAPI_Base + + +class _SaveRestoreAPI_Threads(_SaveRestoreAPI_Base): ... 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"] From de31d4d4d1746cacecce3a81ac42a890ecd46c04 Mon Sep 17 00:00:00 2001 From: Dmitri Gavrilov Date: Tue, 23 Sep 2025 12:03:35 -0400 Subject: [PATCH 5/8] ENH: 'open' and 'close' functions for threaded and async classes --- src/save_and_restore_api/_api_async.py | 10 +++++++++- src/save_and_restore_api/_api_base.py | 7 ++----- src/save_and_restore_api/_api_threads.py | 10 +++++++++- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/save_and_restore_api/_api_async.py b/src/save_and_restore_api/_api_async.py index 2e4a532..315850a 100644 --- a/src/save_and_restore_api/_api_async.py +++ b/src/save_and_restore_api/_api_async.py @@ -1,4 +1,12 @@ +import httpx + from ._api_base import _SaveRestoreAPI_Base -class _SaveRestoreAPI_Async(_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 diff --git a/src/save_and_restore_api/_api_base.py b/src/save_and_restore_api/_api_base.py index 4085406..a6c5143 100644 --- a/src/save_and_restore_api/_api_base.py +++ b/src/save_and_restore_api/_api_base.py @@ -59,12 +59,9 @@ def gen_auth(username, password): def set_auth(self, *, username, password): self._auth = self.gen_auth(username=username, password=password) - def open(self): - self._client = httpx.Client(base_url=self._base_url, timeout=self._timeout) + def open(self): ... - def close(self): - self._client.close() - self._client = None + def close(self): ... def set_username_password(self, username=None, password=None): if not isinstance(username, str): diff --git a/src/save_and_restore_api/_api_threads.py b/src/save_and_restore_api/_api_threads.py index 2f645aa..7a5dd36 100644 --- a/src/save_and_restore_api/_api_threads.py +++ b/src/save_and_restore_api/_api_threads.py @@ -1,4 +1,12 @@ +import httpx + from ._api_base import _SaveRestoreAPI_Base -class _SaveRestoreAPI_Threads(_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 From 09384b562ba5d6e1de410024e9fee1cb9feb399c Mon Sep 17 00:00:00 2001 From: Dmitri Gavrilov Date: Tue, 23 Sep 2025 12:37:25 -0400 Subject: [PATCH 6/8] ENH: implementation of 'send_request' --- src/save_and_restore_api/_api_async.py | 21 +++++++++++ src/save_and_restore_api/_api_base.py | 47 +++++++++--------------- src/save_and_restore_api/_api_threads.py | 21 +++++++++++ 3 files changed, 60 insertions(+), 29 deletions(-) diff --git a/src/save_and_restore_api/_api_async.py b/src/save_and_restore_api/_api_async.py index 315850a..aad6cf5 100644 --- a/src/save_and_restore_api/_api_async.py +++ b/src/save_and_restore_api/_api_async.py @@ -10,3 +10,24 @@ def open(self): 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 diff --git a/src/save_and_restore_api/_api_base.py b/src/save_and_restore_api/_api_base.py index a6c5143..4f51e84 100644 --- a/src/save_and_restore_api/_api_base.py +++ b/src/save_and_restore_api/_api_base.py @@ -59,10 +59,6 @@ def gen_auth(username, password): def set_auth(self, *, username, password): self._auth = self.gen_auth(username=username, password=password) - def open(self): ... - - def close(self): ... - def set_username_password(self, username=None, password=None): if not isinstance(username, str): print("Username: ", end="") @@ -121,32 +117,25 @@ def _process_comm_exception(self, *, method, params, client_response): else: raise self.HTTPServerError(exc, **common_params) from exc - def send_request( - self, method, url, *, params=None, url_params=None, headers=None, data=None, timeout=None, auth=None + def _prepare_request( + self, *, method, params=None, url_params=None, headers=None, data=None, timeout=None, auth=None ): - try: - client_response = 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}) - 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 + 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 login(self, *, username=None, password=None): params = {"username": self._username, "password": self._password} diff --git a/src/save_and_restore_api/_api_threads.py b/src/save_and_restore_api/_api_threads.py index 7a5dd36..9a47974 100644 --- a/src/save_and_restore_api/_api_threads.py +++ b/src/save_and_restore_api/_api_threads.py @@ -10,3 +10,24 @@ def open(self): 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 From c0825fb5f58ab89594304fa3503d2043aa904242 Mon Sep 17 00:00:00 2001 From: Dmitri Gavrilov Date: Tue, 23 Sep 2025 13:11:15 -0400 Subject: [PATCH 7/8] ENH: implementation of 'login' function --- src/save_and_restore_api/_api_async.py | 4 ++++ src/save_and_restore_api/_api_base.py | 30 ++++++++++++++---------- src/save_and_restore_api/_api_threads.py | 4 ++++ tests/test_package.py | 4 ++-- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/save_and_restore_api/_api_async.py b/src/save_and_restore_api/_api_async.py index aad6cf5..a310f4f 100644 --- a/src/save_and_restore_api/_api_async.py +++ b/src/save_and_restore_api/_api_async.py @@ -31,3 +31,7 @@ async def send_request( 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 = await self._prepare_login(username=username, password=password) + self.send_request(method, url, params=params) diff --git a/src/save_and_restore_api/_api_base.py b/src/save_and_restore_api/_api_base.py index 4f51e84..9ce4c5e 100644 --- a/src/save_and_restore_api/_api_base.py +++ b/src/save_and_restore_api/_api_base.py @@ -1,9 +1,13 @@ -import getpass +# import getpass import pprint from collections.abc import Mapping import httpx +rest_api_method_map = { + "login": ("POST", "/login"), +} + class RequestParameterError(Exception): ... @@ -53,21 +57,22 @@ def __init__(self, *, base_url, timeout, request_fail_exceptions=True): 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() + # 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 + # self._username = username + # self._password = password # # TODO: rewrite the logic in this function # def _check_response(self, *, request, response): @@ -137,9 +142,10 @@ def _prepare_request( kwargs.update({"auth": auth}) return kwargs - def login(self, *, username=None, password=None): - params = {"username": self._username, "password": self._password} - self.send_request("POST", "/login", params=params) + def _prepare_login(self, *, username=None, password=None): + method, url = rest_api_method_map["login"] + params = {"username": username, "password": password} + return method, url, params def get_node(self, node_uid): return self.send_request("GET", f"/node/{node_uid}") diff --git a/src/save_and_restore_api/_api_threads.py b/src/save_and_restore_api/_api_threads.py index 9a47974..c31d6cc 100644 --- a/src/save_and_restore_api/_api_threads.py +++ b/src/save_and_restore_api/_api_threads.py @@ -31,3 +31,7 @@ def send_request( 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) diff --git a/tests/test_package.py b/tests/test_package.py index b215ce1..b89d79b 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -17,9 +17,9 @@ def test_import(): 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_auth(username="user", password="userPass") # SR.set_username_password(username="admin", password="adminPass") SR.open() - SR.login() + SR.login(username="user", password="userPass") SR.get_node(SR.ROOT_NODE_UID) SR.close() From 701c99980152e45e78916f7ba42eecc636e17eeb Mon Sep 17 00:00:00 2001 From: Dmitri Gavrilov Date: Tue, 23 Sep 2025 15:07:17 -0400 Subject: [PATCH 8/8] ENH: async tests --- pyproject.toml | 1 + src/save_and_restore_api/_api_async.py | 8 ++++-- src/save_and_restore_api/_api_base.py | 11 +++----- src/save_and_restore_api/_api_threads.py | 4 +++ tests/__init__.py | 0 tests/common.py | 0 tests/test_package.py | 33 +++++++++++++++++------- 7 files changed, 38 insertions(+), 19 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/common.py 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/_api_async.py b/src/save_and_restore_api/_api_async.py index a310f4f..80f4201 100644 --- a/src/save_and_restore_api/_api_async.py +++ b/src/save_and_restore_api/_api_async.py @@ -33,5 +33,9 @@ async def send_request( return response async def login(self, *, username=None, password=None): - method, url, params = await self._prepare_login(username=username, password=password) - self.send_request(method, url, params=params) + 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 index 9ce4c5e..3bd2a64 100644 --- a/src/save_and_restore_api/_api_base.py +++ b/src/save_and_restore_api/_api_base.py @@ -4,10 +4,6 @@ import httpx -rest_api_method_map = { - "login": ("POST", "/login"), -} - class RequestParameterError(Exception): ... @@ -143,12 +139,13 @@ def _prepare_request( return kwargs def _prepare_login(self, *, username=None, password=None): - method, url = rest_api_method_map["login"] + method, url = "POST", "/login" params = {"username": username, "password": password} return method, url, params - def get_node(self, node_uid): - return self.send_request("GET", f"/node/{node_uid}") + 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") diff --git a/src/save_and_restore_api/_api_threads.py b/src/save_and_restore_api/_api_threads.py index c31d6cc..20d40c7 100644 --- a/src/save_and_restore_api/_api_threads.py +++ b/src/save_and_restore_api/_api_threads.py @@ -35,3 +35,7 @@ def send_request( 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/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 b89d79b..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 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 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_auth(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(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()