diff --git a/pyproject.toml b/pyproject.toml index a9745ddacc65..b73e5b9c73a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dependencies = [ "pyvisa>=1.11.0, <1.17.0", "ruamel.yaml>=0.16.0,!=0.16.6", "tabulate>=0.9.0", - "typing_extensions>=4.6.0", + "typing_extensions>=4.13.0", "tqdm>=4.59.0", "uncertainties>=3.2.0", "versioningit>=2.2.1", diff --git a/tests/dataset/test_export_config.py b/tests/dataset/test_export_config.py new file mode 100644 index 000000000000..1032e612b594 --- /dev/null +++ b/tests/dataset/test_export_config.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from qcodes.dataset.export_config import ( + DataExportType, + get_data_export_name_elements, + get_data_export_prefix, + get_data_export_type, + set_data_export_prefix, + set_data_export_type, +) + + +def test_data_export_type_enum_members() -> None: + assert DataExportType.NETCDF.value == "nc" + assert DataExportType.CSV.value == "csv" + assert len(DataExportType) == 2 + + +def test_get_data_export_type_with_string_netcdf() -> None: + result = get_data_export_type("NETCDF") + assert result is DataExportType.NETCDF + + +def test_get_data_export_type_with_string_csv() -> None: + result = get_data_export_type("CSV") + assert result is DataExportType.CSV + + +def test_get_data_export_type_case_insensitive() -> None: + assert get_data_export_type("netcdf") is DataExportType.NETCDF + assert get_data_export_type("csv") is DataExportType.CSV + assert get_data_export_type("Csv") is DataExportType.CSV + + +def test_get_data_export_type_with_enum_input() -> None: + result = get_data_export_type(DataExportType.NETCDF) + assert result is DataExportType.NETCDF + + result = get_data_export_type(DataExportType.CSV) + assert result is DataExportType.CSV + + +def test_get_data_export_type_with_none_returns_none() -> None: + # When config export_type is also None/empty, should return None + set_data_export_type(None) # type: ignore[arg-type] + result = get_data_export_type(None) + assert result is None + + +def test_get_data_export_type_with_invalid_string_returns_none() -> None: + result = get_data_export_type("nonexistent_format") + assert result is None + + +def test_set_and_get_data_export_prefix_roundtrip() -> None: + set_data_export_prefix("my_prefix_") + assert get_data_export_prefix() == "my_prefix_" + + set_data_export_prefix("") + assert get_data_export_prefix() == "" + + +def test_get_data_export_name_elements_returns_list() -> None: + result = get_data_export_name_elements() + assert isinstance(result, list) + + +def test_set_data_export_type_valid() -> None: + set_data_export_type("netcdf") + result = get_data_export_type() + assert result is DataExportType.NETCDF + + set_data_export_type("csv") + result = get_data_export_type() + assert result is DataExportType.CSV + + +def test_set_data_export_type_invalid_does_not_change_config() -> None: + set_data_export_type("netcdf") + set_data_export_type("invalid_type") + # Config should still have the previous valid value + result = get_data_export_type() + assert result is DataExportType.NETCDF diff --git a/tests/dataset/test_json_exporter.py b/tests/dataset/test_json_exporter.py new file mode 100644 index 000000000000..f2e6229d82e0 --- /dev/null +++ b/tests/dataset/test_json_exporter.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import copy +import json +from pathlib import Path + +import numpy as np + +from qcodes.dataset.json_exporter import ( + export_data_as_json_heatmap, + export_data_as_json_linear, + json_template_heatmap, + json_template_linear, +) + + +def test_json_template_linear_structure() -> None: + assert json_template_linear["type"] == "linear" + assert "x" in json_template_linear + assert "y" in json_template_linear + assert isinstance(json_template_linear["x"], dict) + assert isinstance(json_template_linear["y"], dict) + assert "data" in json_template_linear["x"] + assert "data" in json_template_linear["y"] + assert json_template_linear["x"]["is_setpoint"] is True + assert json_template_linear["y"]["is_setpoint"] is False + + +def test_json_template_heatmap_structure() -> None: + assert json_template_heatmap["type"] == "heatmap" + assert "x" in json_template_heatmap + assert "y" in json_template_heatmap + assert "z" in json_template_heatmap + assert isinstance(json_template_heatmap["x"], dict) + assert isinstance(json_template_heatmap["y"], dict) + assert isinstance(json_template_heatmap["z"], dict) + assert json_template_heatmap["x"]["is_setpoint"] is True + assert json_template_heatmap["y"]["is_setpoint"] is True + assert json_template_heatmap["z"]["is_setpoint"] is False + + +def test_export_linear_writes_correct_json(tmp_path: Path) -> None: + location = str(tmp_path / "linear.json") + state: dict = {"json": copy.deepcopy(json_template_linear)} + data = [[1.0, 10.0], [2.0, 20.0], [3.0, 30.0]] + + export_data_as_json_linear(data, len(data), state, location) + + with open(location) as f: + result = json.load(f) + + assert result["type"] == "linear" + assert result["x"]["data"] == [1.0, 2.0, 3.0] + assert result["y"]["data"] == [10.0, 20.0, 30.0] + + +def test_export_linear_accumulates_data(tmp_path: Path) -> None: + location = str(tmp_path / "linear.json") + state: dict = {"json": copy.deepcopy(json_template_linear)} + + export_data_as_json_linear([[1.0, 10.0]], 1, state, location) + export_data_as_json_linear([[2.0, 20.0]], 2, state, location) + + with open(location) as f: + result = json.load(f) + + assert result["x"]["data"] == [1.0, 2.0] + assert result["y"]["data"] == [10.0, 20.0] + + +def test_export_linear_does_nothing_for_empty_data(tmp_path: Path) -> None: + location = str(tmp_path / "linear.json") + state: dict = {"json": copy.deepcopy(json_template_linear)} + + export_data_as_json_linear([], 0, state, location) + + assert not Path(location).exists() + + +def test_export_heatmap_writes_correct_json(tmp_path: Path) -> None: + location = str(tmp_path / "heatmap.json") + xlen = 2 + ylen = 3 + total = xlen * ylen + + state: dict = { + "json": copy.deepcopy(json_template_heatmap), + "data": { + "x": np.zeros(total), + "y": np.zeros(total), + "z": np.zeros(total), + "location": 0, + "xlen": xlen, + "ylen": ylen, + }, + } + + # 2x3 grid: x varies slowly, y varies fast + data = [ + [0.0, 0.0, 1.0], + [0.0, 1.0, 2.0], + [0.0, 2.0, 3.0], + [1.0, 0.0, 4.0], + [1.0, 1.0, 5.0], + [1.0, 2.0, 6.0], + ] + + export_data_as_json_heatmap(data, total, state, location) + + with open(location) as f: + result = json.load(f) + + assert result["type"] == "heatmap" + assert result["x"]["data"] == [0.0, 1.0] + assert result["y"]["data"] == [0.0, 1.0, 2.0] + assert result["z"]["data"] == [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]] + + +def test_export_heatmap_does_nothing_for_empty_data(tmp_path: Path) -> None: + location = str(tmp_path / "heatmap.json") + state: dict = { + "json": copy.deepcopy(json_template_heatmap), + "data": { + "x": np.zeros(4), + "y": np.zeros(4), + "z": np.zeros(4), + "location": 0, + "xlen": 2, + "ylen": 2, + }, + } + + export_data_as_json_heatmap([], 0, state, location) + + assert not Path(location).exists() diff --git a/tests/dataset/test_rundescribertypes.py b/tests/dataset/test_rundescribertypes.py new file mode 100644 index 000000000000..1bccd33e1dae --- /dev/null +++ b/tests/dataset/test_rundescribertypes.py @@ -0,0 +1,169 @@ +""" +Tests for qcodes.dataset.descriptions.versioning.rundescribertypes. + +Verifies the TypedDict classes, inheritance relationships, type aliases, +and the RunDescriberDicts union. +""" + +from __future__ import annotations + +import typing + +from typing_extensions import get_annotations, get_original_bases + +from qcodes.dataset.descriptions.versioning.rundescribertypes import ( + InterDependencies_Dict, + InterDependenciesDict, + RunDescriberDicts, + RunDescriberV0Dict, + RunDescriberV1Dict, + RunDescriberV2Dict, + RunDescriberV3Dict, + Shapes, +) + +# --------------- Shapes type alias --------------- + + +def test_shapes_type_alias() -> None: + sample: Shapes = {"param": (1, 2, 3)} + assert sample["param"] == (1, 2, 3) + + +# --------------- InterDependenciesDict --------------- + + +def test_interdependencies_dict_instantiation() -> None: + d: InterDependenciesDict = {"paramspecs": ()} + assert d["paramspecs"] == () + + +# --------------- InterDependencies_Dict --------------- + + +def test_interdependencies_underscore_dict_instantiation() -> None: + d: InterDependencies_Dict = { + "parameters": {}, + "dependencies": {}, + "inferences": {}, + "standalones": [], + } + assert d["parameters"] == {} + assert d["standalones"] == [] + + +# --------------- RunDescriberV0Dict --------------- + + +def test_v0_dict_instantiation() -> None: + d: RunDescriberV0Dict = { + "version": 0, + "interdependencies": {"paramspecs": ()}, + } + assert d["version"] == 0 + + +# --------------- RunDescriberV1Dict --------------- + + +def test_v1_dict_instantiation() -> None: + d: RunDescriberV1Dict = { + "version": 1, + "interdependencies": { + "parameters": {}, + "dependencies": {}, + "inferences": {}, + "standalones": [], + }, + } + assert d["version"] == 1 + + +# --------------- RunDescriberV2Dict inherits from V0 --------------- + + +def test_v2_dict_inherits_from_v0() -> None: + # typing_extensions TypedDict flattens __bases__ to (dict,) at runtime; + # verify structural inheritance via __orig_bases__ and annotations. + assert RunDescriberV0Dict in get_original_bases(RunDescriberV2Dict) + # V2 should contain all V0 keys plus its own + v0_keys = set(get_annotations(RunDescriberV0Dict)) + v2_keys = set(get_annotations(RunDescriberV2Dict)) + assert v0_keys.issubset(v2_keys) + + +def test_v2_dict_instantiation() -> None: + d: RunDescriberV2Dict = { + "version": 2, + "interdependencies": {"paramspecs": ()}, + "interdependencies_": { + "parameters": {}, + "dependencies": {}, + "inferences": {}, + "standalones": [], + }, + } + assert d["version"] == 2 + assert "interdependencies_" in d + + +# --------------- RunDescriberV3Dict inherits from V2 --------------- + + +def test_v3_dict_inherits_from_v2() -> None: + assert RunDescriberV2Dict in get_original_bases(RunDescriberV3Dict) + v2_keys = set(get_annotations(RunDescriberV2Dict)) + v3_keys = set(get_annotations(RunDescriberV3Dict)) + assert v2_keys.issubset(v3_keys) + + +def test_v3_dict_inherits_from_v0_transitively() -> None: + # V3 inherits from V2 which inherits from V0 — all V0 keys present + v0_keys = set(get_annotations(RunDescriberV0Dict)) + v3_keys = set(get_annotations(RunDescriberV3Dict)) + assert v0_keys.issubset(v3_keys) + + +def test_v3_dict_instantiation() -> None: + d: RunDescriberV3Dict = { + "version": 3, + "interdependencies": {"paramspecs": ()}, + "interdependencies_": { + "parameters": {}, + "dependencies": {}, + "inferences": {}, + "standalones": [], + }, + "shapes": {"x": (10,)}, + } + assert d["version"] == 3 + assert d["shapes"] == {"x": (10,)} + + +def test_v3_dict_shapes_none() -> None: + d: RunDescriberV3Dict = { + "version": 3, + "interdependencies": {"paramspecs": ()}, + "interdependencies_": { + "parameters": {}, + "dependencies": {}, + "inferences": {}, + "standalones": [], + }, + "shapes": None, + } + assert d["shapes"] is None + + +# --------------- RunDescriberDicts union --------------- + + +def test_rundescriber_dicts_includes_all_versions() -> None: + args = typing.get_args(RunDescriberDicts) + expected = { + RunDescriberV0Dict, + RunDescriberV1Dict, + RunDescriberV2Dict, + RunDescriberV3Dict, + } + assert set(args) == expected diff --git a/tests/dataset/test_snapshot_utils.py b/tests/dataset/test_snapshot_utils.py new file mode 100644 index 000000000000..5f4a8853806a --- /dev/null +++ b/tests/dataset/test_snapshot_utils.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from qcodes.dataset.snapshot_utils import diff_param_snapshots +from qcodes.utils import ParameterDiff + + +def _make_mock_dataset(run_id: int, snapshot: dict | None) -> MagicMock: + ds = MagicMock() + ds.run_id = run_id + ds.snapshot = snapshot + return ds + + +def test_diff_param_snapshots_both_have_snapshots() -> None: + left_snapshot = { + "station": { + "parameters": { + "p1": {"value": 1.0}, + "p2": {"value": 2.0}, + } + } + } + right_snapshot = { + "station": { + "parameters": { + "p1": {"value": 1.0}, + "p3": {"value": 3.0}, + } + } + } + left = _make_mock_dataset(1, left_snapshot) + right = _make_mock_dataset(2, right_snapshot) + + result = diff_param_snapshots(left, right) + + assert isinstance(result, ParameterDiff) + assert result.left_only == {"p2": 2.0} + assert result.right_only == {"p3": 3.0} + assert result.changed == {} + + +def test_diff_param_snapshots_identical_snapshots() -> None: + snapshot = { + "station": { + "parameters": { + "p1": {"value": 1.0}, + } + } + } + left = _make_mock_dataset(1, snapshot) + right = _make_mock_dataset(2, snapshot) + + result = diff_param_snapshots(left, right) + + assert result.left_only == {} + assert result.right_only == {} + assert result.changed == {} + + +def test_diff_param_snapshots_changed_values() -> None: + left_snapshot = { + "station": { + "parameters": { + "p1": {"value": 1.0}, + } + } + } + right_snapshot = { + "station": { + "parameters": { + "p1": {"value": 99.0}, + } + } + } + left = _make_mock_dataset(1, left_snapshot) + right = _make_mock_dataset(2, right_snapshot) + + result = diff_param_snapshots(left, right) + + assert result.changed == {"p1": (1.0, 99.0)} + + +def test_diff_param_snapshots_raises_when_left_snapshot_is_none() -> None: + left = _make_mock_dataset(run_id=5, snapshot=None) + right = _make_mock_dataset( + run_id=6, + snapshot={"station": {"parameters": {"p1": {"value": 1.0}}}}, + ) + + with pytest.raises(RuntimeError, match="5"): + diff_param_snapshots(left, right) + + +def test_diff_param_snapshots_raises_when_right_snapshot_is_none() -> None: + left = _make_mock_dataset( + run_id=7, + snapshot={"station": {"parameters": {"p1": {"value": 1.0}}}}, + ) + right = _make_mock_dataset(run_id=8, snapshot=None) + + with pytest.raises(RuntimeError, match="8"): + diff_param_snapshots(left, right) + + +def test_diff_param_snapshots_raises_when_both_snapshots_are_none() -> None: + left = _make_mock_dataset(run_id=10, snapshot=None) + right = _make_mock_dataset(run_id=11, snapshot=None) + + # When both are None, the left dataset is identified as the empty one + with pytest.raises(RuntimeError, match="10"): + diff_param_snapshots(left, right) + + +def test_diff_param_snapshots_error_message_includes_run_id() -> None: + left = _make_mock_dataset(run_id=42, snapshot=None) + right = _make_mock_dataset( + run_id=99, + snapshot={"station": {"parameters": {}}}, + ) + + with pytest.raises(RuntimeError) as exc_info: + diff_param_snapshots(left, right) + + assert "42" in str(exc_info.value) + assert "snapshot" in str(exc_info.value).lower() diff --git a/tests/dataset/test_sqlite_settings_extended.py b/tests/dataset/test_sqlite_settings_extended.py new file mode 100644 index 000000000000..ff10aed658ec --- /dev/null +++ b/tests/dataset/test_sqlite_settings_extended.py @@ -0,0 +1,66 @@ +""" +Extended tests for qcodes.dataset.sqlite.settings beyond the minimal +checks in test_sqlitesettings.py. +""" + +from __future__ import annotations + +from qcodes.dataset.sqlite.settings import SQLiteSettings, _read_settings + +# --------------- _read_settings returns a 2-tuple of dicts --------------- + + +def test_read_settings_returns_two_dicts() -> None: + result = _read_settings() + assert isinstance(result, tuple) + assert len(result) == 2 + limits, settings = result + assert isinstance(limits, dict) + assert isinstance(settings, dict) + + +# --------------- settings dict --------------- + + +def test_settings_contains_version_key() -> None: + assert "VERSION" in SQLiteSettings.settings + + +def test_settings_version_is_string() -> None: + assert isinstance(SQLiteSettings.settings["VERSION"], str) + + +def test_settings_dict_is_non_empty() -> None: + assert len(SQLiteSettings.settings) >= 1 + + +# --------------- limits dict --------------- + + +EXPECTED_LIMIT_KEYS = { + "MAX_ATTACHED", + "MAX_COLUMN", + "MAX_COMPOUND_SELECT", + "MAX_EXPR_DEPTH", + "MAX_FUNCTION_ARG", + "MAX_LENGTH", + "MAX_LIKE_PATTERN_LENGTH", + "MAX_PAGE_COUNT", + "MAX_SQL_LENGTH", + "MAX_VARIABLE_NUMBER", +} + + +def test_limits_contains_expected_keys() -> None: + assert EXPECTED_LIMIT_KEYS.issubset(SQLiteSettings.limits.keys()) + + +def test_each_limit_value_is_int_or_str() -> None: + for key, value in SQLiteSettings.limits.items(): + assert isinstance(value, (int, str)), ( + f"Limit {key!r} should be int or str, got {type(value)}" + ) + + +def test_limits_has_ten_entries() -> None: + assert len(SQLiteSettings.limits) == 10 diff --git a/tests/test_metadatable_base.py b/tests/test_metadatable_base.py new file mode 100644 index 000000000000..304aa5c8a841 --- /dev/null +++ b/tests/test_metadatable_base.py @@ -0,0 +1,145 @@ +""" +Tests for qcodes.metadatable.metadatable_base covering branches +not exercised by test_metadata.py. +""" + +from __future__ import annotations + +from typing import Any + +from qcodes.metadatable import Metadatable +from qcodes.metadatable.metadatable_base import MetadatableWithName, Snapshot + +# --------------- helpers --------------- + + +class ConcreteMetadatableWithName(MetadatableWithName): + """Minimal concrete implementation for testing.""" + + def __init__( + self, + name: str, + full: str | None = None, + metadata: dict[str, Any] | None = None, + ): + self._short_name = name + self._full_name = full or name + super().__init__(metadata=metadata) + + @property + def short_name(self) -> str: + return self._short_name + + @property + def full_name(self) -> str: + return self._full_name + + +# --------------- Snapshot type alias --------------- + + +def test_snapshot_type_alias_is_dict_str_any() -> None: + assert Snapshot == dict[str, Any] + + +# --------------- Metadatable.__init__ --------------- + + +def test_init_with_none_metadata() -> None: + m = Metadatable(metadata=None) + assert m.metadata == {} + + +def test_init_with_no_arguments() -> None: + m = Metadatable() + assert m.metadata == {} + + +def test_init_with_metadata() -> None: + m = Metadatable(metadata={"key": "value"}) + assert m.metadata == {"key": "value"} + + +# --------------- snapshot without / with metadata --------------- + + +def test_snapshot_without_metadata_returns_base_only() -> None: + m = Metadatable() + snap = m.snapshot() + assert snap == {} + assert "metadata" not in snap + + +def test_snapshot_with_metadata_includes_metadata_key() -> None: + m = Metadatable(metadata={"x": 1}) + snap = m.snapshot() + assert "metadata" in snap + assert snap["metadata"] == {"x": 1} + + +def test_snapshot_metadata_removed_after_clear() -> None: + m = Metadatable(metadata={"a": 1}) + assert "metadata" in m.snapshot() + m.metadata.clear() + assert "metadata" not in m.snapshot() + + +# --------------- snapshot_base default --------------- + + +def test_snapshot_base_default_returns_empty_dict() -> None: + m = Metadatable() + assert m.snapshot_base() == {} + assert m.snapshot_base(update=True) == {} + assert m.snapshot_base(params_to_skip_update=["p1"]) == {} + + +# --------------- load_metadata deep_update --------------- + + +def test_load_metadata_deep_updates_nested_dicts() -> None: + m = Metadatable(metadata={"outer": {"a": 1, "b": 2}}) + m.load_metadata({"outer": {"b": 99, "c": 3}}) + assert m.metadata == {"outer": {"a": 1, "b": 99, "c": 3}} + + +def test_load_metadata_adds_new_top_level_keys() -> None: + m = Metadatable(metadata={"first": 1}) + m.load_metadata({"second": 2}) + assert m.metadata == {"first": 1, "second": 2} + + +# --------------- MetadatableWithName --------------- + + +def test_metadatable_with_name_has_abstract_methods() -> None: + # MetadatableWithName uses @abstractmethod for static analysis; + # verify the property descriptors are marked abstract. + for attr_name in ("short_name", "full_name"): + descriptor = getattr(MetadatableWithName, attr_name) + assert isinstance(descriptor, property) + assert getattr(descriptor.fget, "__isabstractmethod__", False) + + +def test_concrete_metadatable_with_name_short_name() -> None: + obj = ConcreteMetadatableWithName("sensor") + assert obj.short_name == "sensor" + + +def test_concrete_metadatable_with_name_full_name() -> None: + obj = ConcreteMetadatableWithName("sensor", full="instrument_sensor") + assert obj.full_name == "instrument_sensor" + + +def test_concrete_metadatable_with_name_inherits_metadata() -> None: + obj = ConcreteMetadatableWithName("s", metadata={"cal": True}) + assert obj.metadata == {"cal": True} + snap = obj.snapshot() + assert snap["metadata"] == {"cal": True} + + +def test_concrete_metadatable_with_name_snapshot_no_metadata() -> None: + obj = ConcreteMetadatableWithName("s") + snap = obj.snapshot() + assert snap == {} + assert "metadata" not in snap diff --git a/tests/utils/test_abstractmethod.py b/tests/utils/test_abstractmethod.py new file mode 100644 index 000000000000..8d6954c75eda --- /dev/null +++ b/tests/utils/test_abstractmethod.py @@ -0,0 +1,77 @@ +""" +Tests for qcodes.utils.abstractmethod - custom abstract method decorator. +""" + +from qcodes.utils.abstractmethod import qcodes_abstractmethod + + +def test_decorator_sets_attribute() -> None: + """Test that the decorator sets __qcodes_is_abstract_method__ to True.""" + + @qcodes_abstractmethod + def my_func() -> None: + pass + + assert hasattr(my_func, "__qcodes_is_abstract_method__") + assert my_func.__qcodes_is_abstract_method__ is True # type: ignore[attr-defined] + + +def test_decorator_returns_same_function() -> None: + """Test that the decorator returns the original function object.""" + + def my_func() -> None: + pass + + result = qcodes_abstractmethod(my_func) + assert result is my_func + + +def test_decorated_function_is_still_callable() -> None: + """Test that the decorated function can still be called.""" + + @qcodes_abstractmethod + def my_func(x: int) -> int: + return x * 2 + + assert my_func(5) == 10 + + +def test_class_with_qcodes_abstractmethod_can_be_instantiated() -> None: + """Test that unlike abc.abstractmethod, classes can still be instantiated.""" + + class MyClass: + @qcodes_abstractmethod + def my_method(self) -> str: + return "base" + + instance = MyClass() + assert instance.my_method() == "base" + + +def test_undecorated_function_lacks_attribute() -> None: + """Test that undecorated functions don't have the attribute.""" + + def regular_func() -> None: + pass + + assert not hasattr(regular_func, "__qcodes_is_abstract_method__") + + +def test_multiple_methods_decorated() -> None: + """Test that multiple methods in a class can be decorated independently.""" + + class MyClass: + @qcodes_abstractmethod + def method_a(self) -> None: + pass + + @qcodes_abstractmethod + def method_b(self) -> None: + pass + + def method_c(self) -> None: + pass + + assert hasattr(MyClass.method_a, "__qcodes_is_abstract_method__") + assert hasattr(MyClass.method_b, "__qcodes_is_abstract_method__") + assert not hasattr(MyClass.method_c, "__qcodes_is_abstract_method__") diff --git a/tests/utils/test_deep_update_utils.py b/tests/utils/test_deep_update_utils.py new file mode 100644 index 000000000000..e8d1f76f40a0 --- /dev/null +++ b/tests/utils/test_deep_update_utils.py @@ -0,0 +1,115 @@ +""" +Tests for qcodes.utils.deep_update_utils - recursive dict merging. +""" + +from qcodes.utils.deep_update_utils import deep_update + + +def test_simple_key_value_update() -> None: + """Test updating simple key-value pairs.""" + dest = {"a": 1, "b": 2} + update = {"b": 3} + result = deep_update(dest, update) + assert result["a"] == 1 + assert result["b"] == 3 + + +def test_nested_dict_merging() -> None: + """Test that nested dicts are merged recursively.""" + dest = {"a": {"x": 1, "y": 2}} + update = {"a": {"y": 3, "z": 4}} + result = deep_update(dest, update) + assert result["a"] == {"x": 1, "y": 3, "z": 4} + + +def test_deeply_nested_dict_merging() -> None: + """Test recursive merging multiple levels deep.""" + dest = {"a": {"b": {"c": 1, "d": 2}}} + update = {"a": {"b": {"d": 3, "e": 4}}} + result = deep_update(dest, update) + assert result["a"]["b"] == {"c": 1, "d": 3, "e": 4} + + +def test_lists_replaced_entirely() -> None: + """Test that lists are replaced completely, not merged.""" + dest = {"a": [1, 2, 3]} + update = {"a": [4, 5]} + result = deep_update(dest, update) + assert result["a"] == [4, 5] + + +def test_new_keys_added() -> None: + """Test that new keys from update are added to dest.""" + dest = {"a": 1} + update = {"b": 2, "c": 3} + result = deep_update(dest, update) + assert result == {"a": 1, "b": 2, "c": 3} + + +def test_non_dict_replaces_dict() -> None: + """Test that a non-dict value replaces a dict value.""" + dest = {"a": {"x": 1}} + update = {"a": "string_value"} + result = deep_update(dest, update) + assert result["a"] == "string_value" + + +def test_dict_replaces_non_dict() -> None: + """Test that a dict value replaces a non-dict value.""" + dest = {"a": "string_value"} + update = {"a": {"x": 1}} + result = deep_update(dest, update) + assert result["a"] == {"x": 1} + + +def test_returns_dest_dict() -> None: + """Test that deep_update returns the dest dict (mutated in place).""" + dest = {"a": 1} + update = {"b": 2} + result = deep_update(dest, update) + assert result is dest + + +def test_deep_copy_of_update_values() -> None: + """Test that mutations to the update dict don't affect dest.""" + inner_list = [1, 2, 3] + dest: dict = {} + update = {"a": inner_list} + deep_update(dest, update) + + inner_list.append(4) + assert dest["a"] == [1, 2, 3] + + +def test_deep_copy_of_nested_update_values() -> None: + """Test that deep copies are made for nested structures.""" + inner_dict = {"x": [1, 2]} + dest: dict = {"a": 1} + update = {"b": inner_dict} + deep_update(dest, update) + + inner_dict["x"].append(3) + assert dest["b"]["x"] == [1, 2] + + +def test_empty_update() -> None: + """Test that an empty update leaves dest unchanged.""" + dest = {"a": 1, "b": 2} + result = deep_update(dest, {}) + assert result == {"a": 1, "b": 2} + + +def test_empty_dest() -> None: + """Test updating an empty dest with values.""" + dest: dict = {} + update = {"a": 1, "b": {"c": 2}} + result = deep_update(dest, update) + assert result == {"a": 1, "b": {"c": 2}} + + +def test_none_values() -> None: + """Test that None values are handled correctly.""" + dest = {"a": 1} + update = {"a": None} + result = deep_update(dest, update) + assert result["a"] is None diff --git a/tests/utils/test_deprecate.py b/tests/utils/test_deprecate.py new file mode 100644 index 000000000000..8f12e796a106 --- /dev/null +++ b/tests/utils/test_deprecate.py @@ -0,0 +1,62 @@ +""" +Tests for qcodes.utils.deprecate - QCoDeSDeprecationWarning. +""" + +import warnings + +import pytest + +from qcodes.utils.deprecate import QCoDeSDeprecationWarning + + +def test_is_subclass_of_runtime_warning() -> None: + """Test that QCoDeSDeprecationWarning is a subclass of RuntimeWarning.""" + assert issubclass(QCoDeSDeprecationWarning, RuntimeWarning) + + +def test_is_not_subclass_of_deprecation_warning() -> None: + """Test that it is not a subclass of the standard DeprecationWarning.""" + assert not issubclass(QCoDeSDeprecationWarning, DeprecationWarning) + + +def test_can_be_raised_and_caught() -> None: + """Test that QCoDeSDeprecationWarning can be raised and caught.""" + with pytest.raises(QCoDeSDeprecationWarning, match="test message"): + raise QCoDeSDeprecationWarning("test message") + + +def test_can_be_caught_as_runtime_warning() -> None: + """Test that it can be caught as a RuntimeWarning.""" + with pytest.raises(RuntimeWarning): + raise QCoDeSDeprecationWarning("test message") + + +def test_can_be_used_with_warnings_warn() -> None: + """Test that it can be used with warnings.warn.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + warnings.warn("deprecation message", QCoDeSDeprecationWarning, stacklevel=1) + + assert len(caught) == 1 + assert issubclass(caught[0].category, QCoDeSDeprecationWarning) + assert "deprecation message" in str(caught[0].message) + + +def test_instance_attributes() -> None: + """Test that the warning carries its message.""" + warning = QCoDeSDeprecationWarning("my message") + assert str(warning) == "my message" + assert isinstance(warning, RuntimeWarning) + + +def test_not_suppressed_by_default_warning_filters() -> None: + """Test that QCoDeSDeprecationWarning is visible with default filters. + + Standard DeprecationWarning is suppressed by default, but since this + inherits from RuntimeWarning it should not be. + """ + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("default") + warnings.warn("should be visible", QCoDeSDeprecationWarning, stacklevel=1) + + assert len(caught) == 1 diff --git a/tests/utils/test_numpy_utils.py b/tests/utils/test_numpy_utils.py new file mode 100644 index 000000000000..367460d55659 --- /dev/null +++ b/tests/utils/test_numpy_utils.py @@ -0,0 +1,85 @@ +""" +Tests for qcodes.utils.numpy_utils - numpy array conversion utilities. +""" + +import numpy as np + +from qcodes.utils.numpy_utils import list_of_data_to_maybe_ragged_nd_array + + +def test_regular_list_converts_to_array() -> None: + """Test that a simple list converts to a 1D numpy array.""" + data = [1, 2, 3] + result = list_of_data_to_maybe_ragged_nd_array(data) + np.testing.assert_array_equal(result, np.array([1, 2, 3])) + assert result.ndim == 1 + + +def test_nested_lists_same_length_create_2d_array() -> None: + """Test that nested lists of equal length create a 2D array.""" + data = [[1, 2], [3, 4], [5, 6]] + result = list_of_data_to_maybe_ragged_nd_array(data) + expected = np.array([[1, 2], [3, 4], [5, 6]]) + np.testing.assert_array_equal(result, expected) + assert result.shape == (3, 2) + + +def test_ragged_nested_lists_return_object_array() -> None: + """Test that ragged nested lists produce an object-dtype array.""" + data = [[1, 2], [3, 4, 5], [6]] + result = list_of_data_to_maybe_ragged_nd_array(data) + assert result.dtype == object + assert len(result) == 3 + + +def test_dtype_parameter_is_respected() -> None: + """Test that the dtype parameter is used for the output array.""" + data = [1, 2, 3] + result = list_of_data_to_maybe_ragged_nd_array(data, dtype=float) + assert result.dtype == np.float64 + np.testing.assert_array_equal(result, np.array([1.0, 2.0, 3.0])) + + +def test_empty_list() -> None: + """Test that an empty list converts to an empty array.""" + data: list = [] + result = list_of_data_to_maybe_ragged_nd_array(data) + assert len(result) == 0 + + +def test_single_element_list() -> None: + """Test that a single element list converts correctly.""" + data = [42] + result = list_of_data_to_maybe_ragged_nd_array(data) + np.testing.assert_array_equal(result, np.array([42])) + assert result.shape == (1,) + + +def test_list_of_floats() -> None: + """Test that a list of floats converts correctly.""" + data = [1.1, 2.2, 3.3] + result = list_of_data_to_maybe_ragged_nd_array(data) + np.testing.assert_array_almost_equal(result, np.array([1.1, 2.2, 3.3])) + + +def test_list_of_strings() -> None: + """Test that a list of strings converts to a string array.""" + data = ["a", "b", "c"] + result = list_of_data_to_maybe_ragged_nd_array(data) + np.testing.assert_array_equal(result, np.array(["a", "b", "c"])) + + +def test_3d_uniform_data() -> None: + """Test that uniformly nested 3D data creates a 3D array.""" + data = [[[1, 2], [3, 4]], [[5, 6], [7, 8]]] + result = list_of_data_to_maybe_ragged_nd_array(data) + assert result.shape == (2, 2, 2) + + +def test_ragged_array_preserves_inner_lists() -> None: + """Test that ragged array elements are preserved correctly.""" + data = [[1, 2, 3], [4, 5]] + result = list_of_data_to_maybe_ragged_nd_array(data) + assert result.dtype == object + assert list(result[0]) == [1, 2, 3] + assert list(result[1]) == [4, 5] diff --git a/tests/utils/test_path_helpers.py b/tests/utils/test_path_helpers.py new file mode 100644 index 000000000000..5254ffeef8e0 --- /dev/null +++ b/tests/utils/test_path_helpers.py @@ -0,0 +1,92 @@ +""" +Tests for qcodes.utils.path_helpers - path utility functions. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pytest + +from qcodes.utils.path_helpers import ( + QCODES_USER_PATH_ENV, + get_qcodes_path, + get_qcodes_user_path, +) + + +def test_get_qcodes_path_returns_string() -> None: + """Test that get_qcodes_path returns a string.""" + result = get_qcodes_path() + assert isinstance(result, str) + + +def test_get_qcodes_path_ends_with_separator() -> None: + """Test that get_qcodes_path returns a path ending with os.sep.""" + result = get_qcodes_path() + assert result.endswith(os.sep) + + +def test_get_qcodes_path_contains_qcodes() -> None: + """Test that the returned path contains 'qcodes'.""" + result = get_qcodes_path() + assert "qcodes" in result.lower() + + +def test_get_qcodes_path_with_subfolder() -> None: + """Test that get_qcodes_path appends a subfolder.""" + result = get_qcodes_path("subdir") + assert result.endswith("subdir" + os.sep) + + +def test_get_qcodes_path_with_nested_subfolders() -> None: + """Test that get_qcodes_path appends multiple subfolder parts.""" + result = get_qcodes_path("subdir", "nested") + assert "subdir" in result + assert result.endswith("nested" + os.sep) + + +def test_get_qcodes_user_path_default(monkeypatch: pytest.MonkeyPatch) -> None: + """Test that get_qcodes_user_path returns ~/.qcodes by default.""" + monkeypatch.delenv(QCODES_USER_PATH_ENV, raising=False) + result = get_qcodes_user_path() + expected = os.path.join(str(Path.home()), ".qcodes") + assert result == expected + + +def test_get_qcodes_user_path_respects_env_var( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Test that get_qcodes_user_path uses QCODES_USER_PATH env var.""" + custom_path = str(tmp_path / "custom_qcodes") + monkeypatch.setenv(QCODES_USER_PATH_ENV, custom_path) + result = get_qcodes_user_path() + assert result == custom_path + + +def test_get_qcodes_user_path_appends_file_parts( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Test that get_qcodes_user_path appends file parts.""" + custom_path = str(tmp_path / "custom_qcodes") + monkeypatch.setenv(QCODES_USER_PATH_ENV, custom_path) + result = get_qcodes_user_path("config.json") + assert result == os.path.join(custom_path, "config.json") + + +def test_get_qcodes_user_path_appends_nested_parts( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Test that get_qcodes_user_path appends multiple nested parts.""" + custom_path = str(tmp_path / "custom_qcodes") + monkeypatch.setenv(QCODES_USER_PATH_ENV, custom_path) + result = get_qcodes_user_path("subdir", "file.txt") + assert result == os.path.join(custom_path, "subdir", "file.txt") + + +def test_qcodes_user_path_env_constant() -> None: + """Test that the env variable constant has the expected value.""" + assert QCODES_USER_PATH_ENV == "QCODES_USER_PATH" diff --git a/tests/utils/test_types.py b/tests/utils/test_types.py new file mode 100644 index 000000000000..e4f6b6044d39 --- /dev/null +++ b/tests/utils/test_types.py @@ -0,0 +1,197 @@ +""" +Tests for qcodes.utils.types - numpy type tuples and aliases. +""" + +import numpy as np + +from qcodes.utils.types import ( + complex_types, + concrete_complex_types, + numpy_c_complex, + numpy_c_floats, + numpy_c_ints, + numpy_complex, + numpy_concrete_complex, + numpy_concrete_floats, + numpy_concrete_ints, + numpy_floats, + numpy_ints, + numpy_non_concrete_ints_instantiable, +) + + +def test_numpy_concrete_ints_contents() -> None: + """Test that numpy_concrete_ints contains the expected fixed-size int types.""" + expected = ( + np.int8, + np.int16, + np.int32, + np.int64, + np.uint8, + np.uint16, + np.uint32, + np.uint64, + ) + assert numpy_concrete_ints == expected + + +def test_numpy_concrete_ints_length() -> None: + """Test that numpy_concrete_ints has 8 types.""" + assert len(numpy_concrete_ints) == 8 + + +def test_numpy_c_ints_contents() -> None: + """Test that numpy_c_ints contains the expected C-compatible int types.""" + expected = ( + np.uintp, + np.uintc, + np.intp, + np.intc, + np.short, + np.byte, + np.ushort, + np.ubyte, + np.longlong, + np.ulonglong, + ) + assert numpy_c_ints == expected + + +def test_numpy_c_ints_length() -> None: + """Test that numpy_c_ints has 10 types.""" + assert len(numpy_c_ints) == 10 + + +def test_numpy_non_concrete_ints_instantiable_contents() -> None: + """Test that numpy_non_concrete_ints_instantiable contains default int types.""" + expected = (np.int_, np.uint) + assert numpy_non_concrete_ints_instantiable == expected + + +def test_numpy_ints_is_combination() -> None: + """Test that numpy_ints is the concatenation of all int sub-tuples.""" + expected = numpy_concrete_ints + numpy_c_ints + numpy_non_concrete_ints_instantiable + assert numpy_ints == expected + + +def test_numpy_ints_length() -> None: + """Test that numpy_ints has the combined length of all int sub-tuples.""" + expected_len = ( + len(numpy_concrete_ints) + + len(numpy_c_ints) + + len(numpy_non_concrete_ints_instantiable) + ) + assert len(numpy_ints) == expected_len + + +def test_numpy_concrete_floats_contents() -> None: + """Test that numpy_concrete_floats contains fixed-size float types.""" + expected = (np.float16, np.float32, np.float64) + assert numpy_concrete_floats == expected + + +def test_numpy_c_floats_contents() -> None: + """Test that numpy_c_floats contains C-compatible float types.""" + expected = (np.half, np.single, np.double) + assert numpy_c_floats == expected + + +def test_numpy_floats_is_combination() -> None: + """Test that numpy_floats is the concatenation of float sub-tuples.""" + assert numpy_floats == numpy_concrete_floats + numpy_c_floats + + +def test_numpy_floats_length() -> None: + """Test that numpy_floats has the combined length of float sub-tuples.""" + assert len(numpy_floats) == len(numpy_concrete_floats) + len(numpy_c_floats) + + +def test_numpy_concrete_complex_contents() -> None: + """Test that numpy_concrete_complex contains fixed-size complex types.""" + expected = (np.complex64, np.complex128) + assert numpy_concrete_complex == expected + + +def test_numpy_c_complex_contents() -> None: + """Test that numpy_c_complex contains C-compatible complex types.""" + expected = (np.csingle, np.cdouble) + assert numpy_c_complex == expected + + +def test_numpy_complex_is_combination() -> None: + """Test that numpy_complex is the concatenation of complex sub-tuples.""" + assert numpy_complex == numpy_concrete_complex + numpy_c_complex + + +def test_concrete_complex_types_includes_python_complex() -> None: + """Test that concrete_complex_types includes numpy and Python complex.""" + assert complex in concrete_complex_types + for t in numpy_concrete_complex: + assert t in concrete_complex_types + + +def test_complex_types_includes_python_complex() -> None: + """Test that complex_types includes numpy and Python complex.""" + assert complex in complex_types + for t in numpy_concrete_complex: + assert t in complex_types + + +def test_all_int_types_are_numpy_integer_subclass() -> None: + """Test that all types in numpy_ints are subclasses of np.integer.""" + for t in numpy_ints: + assert issubclass(t, np.integer), f"{t} is not a subclass of np.integer" + + +def test_all_float_types_are_numpy_floating_subclass() -> None: + """Test that all types in numpy_floats are subclasses of np.floating.""" + for t in numpy_floats: + assert issubclass(t, np.floating), f"{t} is not a subclass of np.floating" + + +def test_all_complex_types_are_numpy_complexfloating_subclass() -> None: + """Test that all types in numpy_complex are subclasses of np.complexfloating.""" + for t in numpy_complex: + assert issubclass(t, np.complexfloating), ( + f"{t} is not a subclass of np.complexfloating" + ) + + +def test_concrete_int_instances() -> None: + """Test that instances of concrete int types can be created.""" + for t in numpy_concrete_ints: + val = t(42) + assert isinstance(val, np.integer) + + +def test_concrete_float_instances() -> None: + """Test that instances of concrete float types can be created.""" + for t in numpy_concrete_floats: + val = t(3.14) + assert isinstance(val, np.floating) + + +def test_concrete_complex_instances() -> None: + """Test that instances of concrete complex types can be created.""" + for t in numpy_concrete_complex: + val = t(1 + 2j) + assert isinstance(val, np.complexfloating) + + +def test_all_tuples_contain_types() -> None: + """Test that every element in every tuple is a type (class).""" + all_tuples = [ + numpy_concrete_ints, + numpy_c_ints, + numpy_non_concrete_ints_instantiable, + numpy_ints, + numpy_concrete_floats, + numpy_c_floats, + numpy_floats, + numpy_concrete_complex, + numpy_c_complex, + numpy_complex, + ] + for tup in all_tuples: + for t in tup: + assert isinstance(t, type), f"{t} is not a type"