diff --git a/backend/qualibrate_app/api/core/domain/bases/snapshot.py b/backend/qualibrate_app/api/core/domain/bases/snapshot.py index 99d392e0..cc812e13 100644 --- a/backend/qualibrate_app/api/core/domain/bases/snapshot.py +++ b/backend/qualibrate_app/api/core/domain/bases/snapshot.py @@ -120,6 +120,12 @@ def compare_by_id( def dump(self) -> SnapshotModel: return SnapshotModel(**self.content) + @abstractmethod + def extract_state_update_type( + self, path: str + ) -> Optional[Mapping[str, Any]]: + pass + @abstractmethod def update_entry(self, updates: Mapping[str, Any]) -> bool: pass diff --git a/backend/qualibrate_app/api/core/domain/local_storage/snapshot.py b/backend/qualibrate_app/api/core/domain/local_storage/snapshot.py index 5de34b8a..d3cdbcd4 100644 --- a/backend/qualibrate_app/api/core/domain/local_storage/snapshot.py +++ b/backend/qualibrate_app/api/core/domain/local_storage/snapshot.py @@ -16,6 +16,8 @@ import jsonpatch import jsonpointer +import requests +from requests import JSONDecodeError as RequestsJSONDecodeError from qualibrate_app.api.core.domain.bases.snapshot import ( SnapshotBase, @@ -36,6 +38,7 @@ from qualibrate_app.api.core.utils.find_utils import get_subpath_value from qualibrate_app.api.core.utils.path.node import NodePath from qualibrate_app.api.core.utils.snapshots_compare import jsonpatch_to_mapping +from qualibrate_app.api.core.utils.types_parsing import TYPE_TO_STR from qualibrate_app.api.exceptions.classes.storage import ( QFileNotFoundException, QPathException, @@ -376,6 +379,69 @@ def compare_by_id( this_data, jsonpatch.make_patch(dict(this_data), dict(other_data)) ) + @staticmethod + def _conversion_type_from_value(value: Any) -> Mapping[str, Any]: + if isinstance(value, list): + if len(value) == 0: + return {"type": "array"} + item_type = TYPE_TO_STR.get(type(value[0])) + if item_type is None: + return {"type": "array"} + return {"type": "array", "items": {"type": item_type}} + item_type = TYPE_TO_STR.get(type(value)) + if item_type is None: + return {"type": "null"} + return {"type": item_type} + + def _extract_state_updates_type_from_runner( + self, path: str + ) -> Optional[Mapping[str, Any]]: + try: + last_run_response = requests.get( + f"{self._settings.runner.address}/last_run" + ) + except requests.exceptions.ConnectionError: + return None + if last_run_response.status_code != 200: + return None + try: + data = last_run_response.json() + except RequestsJSONDecodeError: + return None + state_update = data.get("state_updates", {}).get(path) + if state_update is None: + return None + new_state = state_update.get("new", object) + if new_state is object: + return None + return self._conversion_type_from_value(new_state) + + def _extract_state_updates_from_quam_state( + self, path: str + ) -> Optional[Mapping[str, Any]]: + try: + quam_state_file: Path = self.node_path / "quam_state.json" + except QFileNotFoundException: + return None + if not quam_state_file.is_file(): + return None + try: + quam_state = json.loads(quam_state_file.read_text()) + except json.JSONDecodeError: + return None + quam_item = jsonpointer.resolve_pointer(quam_state, path[1:], object) + if quam_item is object: + return None + return self._conversion_type_from_value(quam_item) + + def extract_state_update_type( + self, path: str + ) -> Optional[Mapping[str, Any]]: + _type = self._extract_state_updates_type_from_runner(path) + if _type is not None: + return _type + return self._extract_state_updates_from_quam_state(path) + def update_entry(self, updates: Mapping[str, Any]) -> bool: if self.load_type < SnapshotLoadType.Data: self.load(SnapshotLoadType.Data) diff --git a/backend/qualibrate_app/api/core/domain/timeline_db/snapshot.py b/backend/qualibrate_app/api/core/domain/timeline_db/snapshot.py index 55b59782..6fd40724 100644 --- a/backend/qualibrate_app/api/core/domain/timeline_db/snapshot.py +++ b/backend/qualibrate_app/api/core/domain/timeline_db/snapshot.py @@ -141,3 +141,8 @@ def compare_by_id( def update_entry(self, updates: Mapping[str, Any]) -> bool: # TODO: update timeline db snapshot entry return False + + def extract_state_update_type( + self, path: str + ) -> Optional[Mapping[str, Any]]: + return None diff --git a/backend/qualibrate_app/api/core/utils/types_parsing.py b/backend/qualibrate_app/api/core/utils/types_parsing.py new file mode 100644 index 00000000..d1c7c3a8 --- /dev/null +++ b/backend/qualibrate_app/api/core/utils/types_parsing.py @@ -0,0 +1,165 @@ +# COPIED FROM qualibrate-runner/qualibrate_runner/core/types_parsing.py + +import sys + +if sys.version_info >= (3, 10): + from types import NoneType +else: + NoneType = type(None) +from typing import Any, Dict, List, Mapping, Optional, Type, Union + +NOT_NONE_BASIC_TYPES = Union[bool, int, float, str] +BASIC_TYPES = Union[NOT_NONE_BASIC_TYPES, NoneType] +LIST_TYPES = Union[List[bool], List[int], List[float], List[str]] +VALUE_TYPES_WITHOUT_REC = Union[BASIC_TYPES, LIST_TYPES] +INPUT_CONVERSION_TYPE = Union[ + VALUE_TYPES_WITHOUT_REC, Dict[str, "INPUT_CONVERSION_TYPE"] +] + + +def parse_bool(value: VALUE_TYPES_WITHOUT_REC) -> VALUE_TYPES_WITHOUT_REC: + if isinstance(value, bool): + return value + if isinstance(value, str): + if value.lower() == "true": + return True + if value.lower() == "false": + return False + return value + if isinstance(value, int): + return value != 0 + if isinstance(value, float): + return abs(value) > 1e-9 + return value + + +def parse_int(value: VALUE_TYPES_WITHOUT_REC) -> VALUE_TYPES_WITHOUT_REC: + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, str): + if value.isdigit() or ( + len(value) > 1 and value[0] == "-" and value[1:].isdigit() + ): + return int(value) + return value + if isinstance(value, float): + if value.is_integer(): + return int(value) + return value + return value + + +def parse_float(value: VALUE_TYPES_WITHOUT_REC) -> VALUE_TYPES_WITHOUT_REC: + if isinstance(value, float): + return value + if isinstance(value, int): + return float(value) + if isinstance(value, str): + try: + return float(value) + except ValueError: + return value + return value + + +def parse_str(value: VALUE_TYPES_WITHOUT_REC) -> VALUE_TYPES_WITHOUT_REC: + return value + + +def parse_none(value: VALUE_TYPES_WITHOUT_REC) -> VALUE_TYPES_WITHOUT_REC: + if value is None or (isinstance(value, str) and not value): + return None + return value + + +BASIC_PARSERS = { + int: parse_int, + float: parse_float, + bool: parse_bool, + str: parse_str, + NoneType: parse_none, +} + +STR_TO_TYPE: Mapping[str, Type[BASIC_TYPES]] = { + "integer": int, + "number": float, + "boolean": bool, + "string": str, + "null": NoneType, +} + +TYPE_TO_STR = {v: k for k, v in STR_TO_TYPE.items()} + + +def parse_typed_list( + value: List[Any], item_type: Type[BASIC_TYPES] +) -> LIST_TYPES: + if len(value) == 0: + return value + parser = BASIC_PARSERS.get(item_type) + if parser is None: + return value + try: + return list(map(parser, value)) # type: ignore + except ValueError: + return value + + +def _parse_list( + value: List[Any], + item_type: Optional[Type[BASIC_TYPES]], +) -> VALUE_TYPES_WITHOUT_REC: + if item_type is None: + return value + return parse_typed_list(value, item_type) + + +def parse_list( + value: VALUE_TYPES_WITHOUT_REC, + item_type: Optional[Type[BASIC_TYPES]], +) -> VALUE_TYPES_WITHOUT_REC: + if isinstance(value, List): + return _parse_list(value, item_type) + if isinstance(value, str): + stripped = value.strip() + if stripped.startswith("[") and stripped.endswith("]"): + stripped = stripped[1:-1] + splitted = list(map(str.strip, stripped.split(","))) + return _parse_list(splitted, item_type) + return value + + +def types_conversion(value: Any, expected_type: Mapping[str, Any]) -> Any: + if isinstance(value, Mapping): + # has sub items + new = {} + for k, v in value.items(): + if k in expected_type: + new[k] = types_conversion(v, expected_type[k]) + else: + new[k] = v + return new + if "anyOf" in expected_type: + # suppose that only `Type | None` is possible + none = parse_none(value) + if none is None: + return None + expected_type_ = dict(expected_type) + expected_type_["type"] = expected_type_.pop("anyOf")[0]["type"] + return types_conversion(value, expected_type_) + if "type" in expected_type: + if expected_type.get("type") == "array": + # array + item_type: Optional[Type[BASIC_TYPES]] = ( + STR_TO_TYPE.get(expected_type["items"]["type"]) + if "items" in expected_type + else None + ) + return parse_list(value, item_type) + if expected_type.get("type") in STR_TO_TYPE.keys(): + expected = STR_TO_TYPE[expected_type["type"]] + parser = BASIC_PARSERS[expected] + return parser(value) + return value diff --git a/backend/qualibrate_app/api/routes/snapshot.py b/backend/qualibrate_app/api/routes/snapshot.py index 4292b5d3..ea991b40 100644 --- a/backend/qualibrate_app/api/routes/snapshot.py +++ b/backend/qualibrate_app/api/routes/snapshot.py @@ -1,8 +1,7 @@ from typing import Annotated, Any, Mapping, Optional, Type, Union -from urllib.parse import urljoin import requests -from fastapi import APIRouter, Depends, Path, Query +from fastapi import APIRouter, Body, Depends, Path, Query from qualibrate_app.api.core.domain.bases.snapshot import ( SnapshotBase, @@ -20,6 +19,7 @@ ) from qualibrate_app.api.core.models.snapshot import Snapshot as SnapshotModel from qualibrate_app.api.core.types import DocumentSequenceType, IdType +from qualibrate_app.api.core.utils.types_parsing import types_conversion from qualibrate_app.api.dependencies.search import get_search_path from qualibrate_app.config import ( QualibrateAppSettings, @@ -99,30 +99,29 @@ def update_entity( snapshot: Annotated[SnapshotBase, Depends(_get_snapshot_instance)], data_path: Annotated[ str, - Query( + Body( ..., min_length=3, pattern="^#/.*", examples=["#/qubits/q0/frequency"], ), ], - value: Any, + value: Annotated[Any, Body()], settings: Annotated[QualibrateAppSettings, Depends(get_settings)], ) -> bool: - if isinstance(value, str): - if value.isdigit(): - value = int(value) - elif value.lower() in ["true", "false"]: - value = value.lower() == "true" - elif is_float(value): - value = float(value) + type_ = snapshot.extract_state_update_type(data_path) + if type_ is not None: + value = types_conversion(value, type_) updated = snapshot.update_entry({data_path: value}) if updated: - requests.post( - urljoin(str(settings.runner.address), "record_state_update"), - params={"key": data_path}, - timeout=settings.runner.timeout, - ) + try: + requests.post( + f"{settings.runner.address}/record_state_update", + params={"key": data_path}, + timeout=settings.runner.timeout, + ) + except requests.exceptions.ConnectionError: + pass return updated diff --git a/backend/tests/unit/test_api_core/test_domain/test_bases/test_snapshot.py b/backend/tests/unit/test_api_core/test_domain/test_bases/test_snapshot.py index 0650065d..1cf7673a 100644 --- a/backend/tests/unit/test_api_core/test_domain/test_bases/test_snapshot.py +++ b/backend/tests/unit/test_api_core/test_domain/test_bases/test_snapshot.py @@ -43,6 +43,11 @@ def compare_by_id( def update_entry(self, updates: Mapping[str, Any]) -> bool: raise NotImplementedError + def extract_state_update_type( + self, path: str + ) -> Optional[Mapping[str, Any]]: + raise NotImplementedError + def test__items_keys(): assert SnapshotBase._items_keys == ("data", "metadata") diff --git a/backend/tests/unit/test_api_core/test_utils/test_types_parsing.py b/backend/tests/unit/test_api_core/test_utils/test_types_parsing.py new file mode 100644 index 00000000..775de2e0 --- /dev/null +++ b/backend/tests/unit/test_api_core/test_utils/test_types_parsing.py @@ -0,0 +1,270 @@ +import pytest + +from qualibrate_app.api.core.utils import types_parsing + + +@pytest.mark.parametrize( + "data, expected", + ( + (True, True), + (False, False), + ("true", True), + ("false", False), + ("random", "random"), + (1, True), + (-2, True), + (0, False), + (-1e-8, True), + (1e-8, True), + (1e-10, False), + (-1e-10, False), + (0.0, False), + (b"random", b"random"), + ), +) +def test_parse_bool(data, expected): + if isinstance(expected, bool): + assert types_parsing.parse_bool(data) is expected + else: + assert types_parsing.parse_bool(data) == expected + + +@pytest.mark.parametrize( + "data, expected", + ( + (True, 1), + (False, 0), + (10, 10), + ("2", 2), + ("-3", -3), + ("random", "random"), + (1.0, 1), + (1.1, 1.1), + (b"random", b"random"), + ), +) +def test_parse_int(data, expected): + assert types_parsing.parse_int(data) == expected + + +@pytest.mark.parametrize( + "data, expected", + ( + (1.0, 1.0), + (1, 1.0), + ("2", 2.0), + ("-3", -3.0), + ("random", "random"), + (b"random", b"random"), + ), +) +def test_parse_float(data, expected): + assert types_parsing.parse_float(data) == expected + + +@pytest.mark.parametrize( + "data, expected", + ( + (1.0, 1.0), + (1, 1), + ("2", "2"), + ("-3", "-3"), + ("true", "true"), + ("random", "random"), + (b"random", b"random"), + ), +) +def test_parse_str(data, expected): + assert types_parsing.parse_str(data) == expected + + +@pytest.mark.parametrize( + "data, expected", + ( + (None, None), + ("", None), + (0, 0), + ("0", "0"), + ("none", "none"), + ("null", "null"), + ("random", "random"), + (b"random", b"random"), + ), +) +def test_parse_none(data, expected): + if expected is None: + assert types_parsing.parse_none(data) is None + else: + assert types_parsing.parse_none(data) == expected + + +def test_parse_typed_list_empty(mocker): + patched = mocker.patch("builtins.map") + assert types_parsing.parse_typed_list([], int) == [] + patched.assert_not_called() + + +@pytest.mark.parametrize( + "input_list, item_type", + ( + (["1", "2", "3"], int), + (["1.2", "2.2", "3.3"], float), + ([True, "false"], bool), + (["aaa"], str), + (["", None], types_parsing.NoneType), + ), +) +def test_parse_typed_list_non_empty(mocker, input_list, item_type): + mocked = mocker.MagicMock() + mocker.patch.dict(types_parsing.BASIC_PARSERS, {item_type: mocked}) + types_parsing.parse_typed_list(input_list, item_type) + assert mocked.mock_calls == [mocker.call(i) for i in input_list] + + +def test_parse_typed_list_raise_error(mocker): + mocked = mocker.MagicMock(side_effect=ValueError) + mocker.patch.dict(types_parsing.BASIC_PARSERS, {int: mocked}) + assert types_parsing.parse_typed_list([1, 2], int) == [1, 2] + + +def test__parse_list_no_item_type(): + lst = [1, 2, "a"] + assert types_parsing._parse_list(lst, None) == lst + + +def test__parse_list_with_item_type(mocker): + lst = ["1", "2", "a"] + patched = mocker.patch( + "qualibrate_app.api.core.utils.types_parsing.parse_typed_list", + return_value="value", + ) + assert types_parsing._parse_list(lst, int) == "value" + patched.assert_called_once_with(lst, int) + + +def test_parse_list_list(mocker): + lst = ["1", "2", "a"] + patched = mocker.patch( + "qualibrate_app.api.core.utils.types_parsing._parse_list", + return_value="value", + ) + assert types_parsing.parse_list(lst, int) == "value" + patched.assert_called_once_with(lst, int) + + +@pytest.mark.parametrize( + "brackets, join", + ( + (False, ","), + (False, ", "), + (True, ","), + (True, ", "), + ), +) +def test_parse_list_str(mocker, brackets, join): + lst = ["1", "2", "a"] + in_str = ( + f"{'[' if brackets else ''}{join.join(lst)}{']' if brackets else ''}" + ) + patched = mocker.patch( + "qualibrate_app.api.core.utils.types_parsing._parse_list", + return_value="value", + ) + assert types_parsing.parse_list(in_str, int) == "value" + patched.assert_called_once_with(lst, int) + + +@pytest.mark.parametrize("data", (1, False, 1.1, b"aaa")) +def test_parse_list_other_types(mocker, data): + patched = mocker.patch( + "qualibrate_app.api.core.utils.types_parsing._parse_list", + return_value="value", + ) + assert types_parsing.parse_list(data, int) == data + patched.assert_not_called() + + +@pytest.fixture +def types_schema(): + return { + "str_val": { + "anyOf": [{"type": "string"}, {"type": "null"}], + }, + "list_str": { + "items": {"type": "string"}, + "type": "array", + }, + "float_val": {"type": "number"}, + "list_float": { + "items": {"type": "number"}, + "type": "array", + }, + "int_val": {"type": "integer"}, + "list_int": { + "items": {"type": "integer"}, + "type": "array", + }, + "bool_val": {"type": "boolean"}, + "list_bool": { + "items": {"type": "boolean"}, + "type": "array", + }, + "none_val": {"type": "null"}, + } + + +@pytest.mark.parametrize( + "input_data, expected", + ( + ( + { + "str_val": "aaa", + "list_str": "[aa, bb]", + "float_val": "1.2", + "list_float": ["1.2", "1.3"], + "int_val": "-1111111", + "list_int": [1, 2, 3], + "bool_val": False, + "list_bool": "[true, false]", + "none_val": "", + }, + { + "str_val": "aaa", + "list_str": ["aa", "bb"], + "float_val": 1.2, + "list_float": [1.2, 1.3], + "int_val": -1111111, + "list_int": [1, 2, 3], + "bool_val": False, + "list_bool": [True, False], + "none_val": None, + }, + ), + ( + { + "str_val": "aaa", + "list_str": "aa, bb", + "float_val": "1e-2", + "list_float": "1.2,1.3", + "int_val": "0", + "list_int": "[1, 2, 3]", + "bool_val": "false", + "list_bool": "[true, false]", + "none_val": None, + }, + { + "str_val": "aaa", + "list_str": ["aa", "bb"], + "float_val": 0.01, + "list_float": [1.2, 1.3], + "int_val": 0, + "list_int": [1, 2, 3], + "bool_val": False, + "list_bool": [True, False], + "none_val": None, + }, + ), + ), +) +def test_types_conversion(types_schema, input_data, expected): + assert types_parsing.types_conversion(input_data, types_schema) == expected