diff --git a/docs/changes/newsfragments/7009.new b/docs/changes/newsfragments/7009.new new file mode 100644 index 000000000000..20dc32cbf429 --- /dev/null +++ b/docs/changes/newsfragments/7009.new @@ -0,0 +1 @@ +A new Validator ``LiteralValidator`` was added. This allows validating against the members of a ``typing.Literal``. diff --git a/src/qcodes/validators/__init__.py b/src/qcodes/validators/__init__.py index 9f5a32936bbf..dbab6fdfea96 100644 --- a/src/qcodes/validators/__init__.py +++ b/src/qcodes/validators/__init__.py @@ -8,6 +8,7 @@ Enum, Ints, Lists, + LiteralValidator, Multiples, MultiType, MultiTypeAnd, @@ -33,6 +34,7 @@ "Enum", "Ints", "Lists", + "LiteralValidator", "MultiType", "MultiTypeAnd", "MultiTypeOr", diff --git a/src/qcodes/validators/validators.py b/src/qcodes/validators/validators.py index 17bd0e40e8c1..f94be4335930 100644 --- a/src/qcodes/validators/validators.py +++ b/src/qcodes/validators/validators.py @@ -9,7 +9,7 @@ import typing from collections import abc from collections.abc import Hashable -from typing import Any, Generic, Literal, TypeVar, cast +from typing import Any, Generic, Literal, TypeVar, cast, get_args import numpy as np @@ -500,6 +500,56 @@ def values(self) -> set[Hashable]: return self._values.copy() +class LiteralValidator(Validator[T]): + """ + + A validator that allows users to check that values supplied are in set of members + of some typing.Literal. + + + .. code-block:: python + + from typing import Literal + + A123 = Literal[1,2,3] + A123Val = LiteralValidator[A123] + a123 = A123() + + a123().validate(1) # pass + + a123().validate(5) # fails + a123().validate("some_str") # fails + + """ + + def __init__(self) -> None: + self._orig_class = getattr(self, "__orig_class__", None) + + @property + def valid_values(self) -> tuple[Any, ...]: + # self__orig_class__ is available when init is executed so + # looking up the concrete type of T has to be postponed to here + orig_class = getattr(self, "__orig_class__", None) + + if orig_class is None: + raise TypeError( + "Cannot find valid literal members for Validator." + " Did you remember to instantiate as `LiteralValidator[SomeLiteralType]()" + ) + + valid_args = get_args(get_args(orig_class)[0]) + return valid_args + + def validate(self, value: T, context: str = "") -> None: + if value not in self.valid_values: + raise ValueError( + f"{value} is not a member of {self.valid_values}; {context}" + ) + + def __repr__(self) -> str: + return f"" + + class OnOff(Validator[str]): """ Requires either the string 'on' or 'off'. diff --git a/tests/validators/test_literal.py b/tests/validators/test_literal.py new file mode 100644 index 000000000000..c549e8003afd --- /dev/null +++ b/tests/validators/test_literal.py @@ -0,0 +1,50 @@ +from typing import Literal + +import pytest + +from qcodes.validators.validators import LiteralValidator + + +def test_literal_validator() -> None: + A123 = Literal[1, 2, 3] + + A123Val = LiteralValidator[A123] + + a123_val = A123Val() + + a123_val.validate(1) + + with pytest.raises(ValueError, match="5 is not a member of "): + a123_val.validate(5, context="Outside range") # pyright: ignore[reportArgumentType] + + with pytest.raises(ValueError, match="some_str is not a member of "): + a123_val.validate("some_str", context="Wrong type") # pyright: ignore[reportArgumentType] + + +def test_literal_validator_repr() -> None: + A123 = Literal[1, 2, 3] + + A123Val = LiteralValidator[A123] + + a123_val = A123Val() + + assert repr(a123_val) == "" + + +def test_valid_values() -> None: + A123 = Literal[1, 2, 3] + + A123Val = LiteralValidator[A123] + + a123_val = A123Val() + + assert a123_val.valid_values == (1, 2, 3) + + +def test_missing_generic_arg_raises_at_runtime(): + wrong_validator = LiteralValidator() + + with pytest.raises( + TypeError, match="Cannot find valid literal members for Validator" + ): + wrong_validator.validate(1)