Repository: qiskit-community/prototype-zne (language: python)

Issue:
Refactor `ZNEStrategy`
<!-- ⚠️ If you do not respect this template, your issue will be closed -->
<!-- ⚠️ Make sure to browse the opened and closed issues to confirm this idea does not exist. -->

### What is the expected enhancement?
<!-- Please, provide a clear and concise description of the expected behavior for this enhancement. -->
With the new interface in Qiskit Terra 0.22, `ZNEStrategy` no longer needs to be a frozen class. On top of that, all the internal functionality that it holds (now) makes it not very well suited to be a `dataclass` altogether; instead, something more tailored would be preferred.


Discussion / hints:

Fix patch:
diff --git a/zne/utils/unset.py b/zne/utils/unset.py
new file mode 100644
index 0000000..aa3466d
--- /dev/null
+++ b/zne/utils/unset.py
@@ -0,0 +1,43 @@
+# This code is part of Qiskit.
+#
+# (C) Copyright IBM 2022.
+#
+# This code is licensed under the Apache License, Version 2.0. You may
+# obtain a copy of this license in the LICENSE.txt file in the root directory
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+"""Module for singleton type to represent unset args."""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+class UnsetType:
+    """Singleton type to represent unset args."""
+
+    __slots__ = ()
+
+    def __new__(cls) -> UnsetType:
+        if not hasattr(cls, "instance"):
+            cls.instance = super().__new__(cls)
+        return cls.instance
+
+    def __bool__(self) -> bool:
+        return False
+
+    def __eq__(self, __o: Any) -> bool:
+        return self is __o
+
+    def __copy__(self) -> UnsetType:
+        return self
+
+    def __deepcopy__(self, memo: dict) -> UnsetType:
+        return self
+
+
+UNSET = UnsetType()
diff --git a/zne/utils/validation.py b/zne/utils/validation.py
index 62ebd26..28f6153 100644
--- a/zne/utils/validation.py
+++ b/zne/utils/validation.py
@@ -14,131 +14,150 @@
 
 from __future__ import annotations
 
-from collections.abc import Sequence, Set
-from typing import Any, Tuple, Union
+from collections.abc import Callable
+from copy import deepcopy
+from typing import Any
 
-IsInstanceTypes = Union[type, Tuple[Union[type, tuple], ...]]
+from .unset import UNSET, UnsetType
 
 
-################################################################################
-## VALIDATION
-################################################################################
-def validate_object_type(
-    obj: object,
-    class_or_tuple: IsInstanceTypes,
-    *,
-    default: Any = Ellipsis,
-) -> Any:
-    """Validate type with same interface as ``isinstance``.
+class quality:  # pylint: disable=invalid-name
+    """Quality attribute similar to property but geared towards custom validation.
 
     Args:
-        obj: The object to validate
-        class_or_tuple: A type, tuple of types, or Union to validate against.
-        default: If provided and ``obj`` is None return this instead (validated).
-
-    Returns:
-        The validated object or default.
-
-    Raises:
-        TypeError: If ``obj`` is not of a type specified in ``class_or_tuple``.
-        TypeError: If ``class_or_tuple`` is incompatible with ``isinstance``.
+        fval: function to be used for validating an attribute value on `__set__`
+        feff: function to be used for performing side-effects on `__set__` and
+            `__delete__` (e.g. erasing cache)
+        default: value to assume if set to `...`
+        null: value to assume if set to `None`
     """
-    if default is not ... and obj is None:  # Ellipsis avoids infinite recurssion
-        return validate_object_type(default, class_or_tuple)
-    try:
-        if not isinstance(obj, class_or_tuple):
-            raise TypeError(f"Expected {class_or_tuple} but got {type(obj)} instead.")
-    except TypeError as error:
-        raise TypeError("Invalid types provided: incompatible with ``isinstance``.") from error
-    else:
-        return obj
-
-
-def validate_sequence(  # pylint: disable=too-many-arguments
-    seq: Sequence,
-    element_types: IsInstanceTypes = object,
-    *,
-    standard_type: type | None = None,
-    cast_single: bool = False,
-    allow_set: bool = False,
-    default: Any = Ellipsis,
-) -> Sequence:
-    """Validate sequence and (optioanlly) the type of its elements.
 
-    Args:
-        seq: A sequence object ot validate.
-        element_types: A type, tuple of types, or Union for the elements in the sequence.
-        standard_type: A callable to cast the returned sequence to a standard type, or None.
-        cast_single: If ``True``, allows single element input (casted to tuple).
-        allow_set: If ``True``, ``Set`` objects will be allowed as sequences.
-        default: If provided and ``seq`` is ``None`` return this instead (validated).
-            Ellipsis (i.e. ``...``) disables the functinoality.
-
-    Returns:
-        The validated (casted) sequence or default.
-
-    Raises:
-        ValueError: If ``standard_type`` input fails to cast the return value's type.
-    """
-    ## DEFAULT
-    if default is not ... and seq is None:  # Ellipsis avoids infinite recurssion
-        return validate_sequence(
-            default,
-            element_types,
-            standard_type=standard_type,
-            cast_single=cast_single,
-            allow_set=allow_set,
+    __slots__ = "fval", "feff", "_default", "_null", "_name"
+
+    # TODO: update doc to fval's like property does for fget
+    def __init__(
+        self,
+        fval: Callable[[Any, Any], Any] | None = None,
+        feff: Callable[[Any], None] | None = None,
+        *,
+        default: Any = UNSET,
+        null: Any = UNSET,
+    ) -> None:
+        self.fval: Callable[[Any, Any], Any] | None = fval
+        self.feff: Callable[[Any], None] | None = feff
+        self._default: Any = default
+        self._null: Any = null
+        self._name: str | None = None
+
+    def __call__(
+        self,
+        fval: Callable[[Any, Any], Any] | None | UnsetType = UNSET,
+        feff: Callable[[Any], None] | None | UnsetType = UNSET,
+        *,
+        default: Any = UNSET,
+        null: Any = UNSET,
+    ) -> quality:
+        """Returns a shallow copy of the quality with updated attributes."""
+        copy = self.__class__(
+            fval=fval or self.fval,  # type: ignore
+            feff=feff or self.feff,  # type: ignore
+            default=default or self.default,
+            null=null or self.null,
         )
-
-    ## ALLOW SET
-    SequenceType: IsInstanceTypes = (Sequence, Set) if allow_set else Sequence
-
-    ## CAST SINGLE
-    element_types = _validate_isinstance_types(element_types)
-    if cast_single and isinstance(seq, element_types):
-        # TODO: handle arbitrarily deep recursive sequences gracefully (now up to depth two)
-        if not isinstance(seq, SequenceType) or any(not isinstance(e, element_types) for e in seq):
-            seq = (seq,)
-
-    ## VALIDATION
-    validate_object_type(seq, SequenceType)
-    for element in seq:
-        validate_object_type(element, element_types)
-
-    ## STANDARD TYPE
-    if standard_type is not None:
-        try:
-            seq = standard_type(seq)
-            return validate_sequence(seq, element_types, allow_set=allow_set)
-        except (TypeError, ValueError) as error:
-            raise ValueError(
-                "Incompatible `standard_type` input, type casting cannot be performed."
-            ) from error
-
-    ## RETURN
-    return seq
-
-
-################################################################################
-## AUXILIARY
-################################################################################
-def _validate_isinstance_types(types: IsInstanceTypes) -> tuple:
-    """Validate that types are a valid second argument for ``isinstance`` call.
-
-    Args:
-        types: To validate.
-
-    Returns:
-        The input types if validated in tuple form.
-
-    Raises:
-        TypeError: If types are invalid.
-    """
-    try:
-        isinstance(None, types)
-    except TypeError as error:
-        raise TypeError("Invalid types provided: incompatible with ``isinstance``.") from error
-    else:
-        if not isinstance(types, tuple):
-            types = (types,)
-        return types
+        copy._name = self.name
+        return copy
+
+    def __copy__(self) -> quality:
+        return self()
+
+    def __deepcopy__(self, memo: dict) -> quality:
+        fval = deepcopy(self.fval, memo)
+        feff = deepcopy(self.feff, memo)
+        default = deepcopy(self._default, memo)
+        null = deepcopy(self._null, memo)
+        return self(fval, feff, default=default, null=null)
+
+    ################################################################################
+    ## PROPERTIES
+    ################################################################################
+    @property
+    def default(self) -> Any:
+        """Default value for the managed attribute to be used for `...`.
+
+        Enforced immutable to avoid side-effects.
+        """
+        return deepcopy(self._default)
+
+    @property
+    def null(self) -> Any:
+        """Null value for the managed attribute to be used for `None`.
+
+        Enforced immutable to avoid side-effects.
+        """
+        return deepcopy(self._null)
+
+    @property
+    def name(self) -> str:
+        """Name for attr in managed instance as provided by `__set_name__`."""
+        return self._name
+
+    @property
+    def private_name(self) -> str:
+        """Private name to store attr value at in managed instance."""
+        if self.name is None:
+            return None
+        return "__quality_" + self.name
+
+    ################################################################################
+    ## DESCRIPTOR PROTOCOL
+    ################################################################################
+    def __set_name__(self, owner: type, name: str) -> None:
+        self._name = name
+
+    def __get__(self, obj: object, objtype: type = None) -> Any:
+        if obj is None:
+            return self
+        if hasattr(obj, self.private_name):
+            return getattr(obj, self.private_name)
+        setattr(obj, self.name, UNSET)
+        return getattr(obj, self.name)
+
+    def __set__(self, obj: object, value: Any) -> None:
+        value = self._apply_defaults(value)
+        if self.fval is not None:
+            value = self.fval(obj, value)
+        setattr(obj, self.private_name, value)
+        if self.feff is not None:
+            self.feff(obj)
+
+    def __delete__(self, obj: object) -> None:
+        if hasattr(obj, self.private_name):
+            delattr(obj, self.private_name)
+        if self.feff is not None:
+            self.feff(obj)
+
+    ################################################################################
+    ## DECORATORS
+    ################################################################################
+    def validator(self, fval: Callable[[Any, Any], Any] | None) -> quality:
+        """Returns a copy of the quality with a different validator."""
+        return self(fval=fval)
+
+    def side_effect(self, feff: Callable[[Any], None] | None) -> quality:
+        """Returns a copy of the quality with a different side effect."""
+        return self(feff=feff)
+
+    ################################################################################
+    ## AUXILIARY
+    ################################################################################
+    def _apply_defaults(self, value: Any) -> Any:
+        """Apply defaults to user input value."""
+        default = self.default or self.null
+        null = self.null or self.default
+        if value is UNSET:
+            value = default or None
+        elif value is Ellipsis and default is not UNSET:
+            value = default
+        elif value is None and null is not UNSET:
+            value = null
+        return value


Test patch:
diff --git a/test/utils/test_unset.py b/test/utils/test_unset.py
new file mode 100644
index 0000000..a6bac67
--- /dev/null
+++ b/test/utils/test_unset.py
@@ -0,0 +1,43 @@
+# This code is part of Qiskit.
+#
+# (C) Copyright IBM 2022.
+#
+# This code is licensed under the Apache License, Version 2.0. You may
+# obtain a copy of this license in the LICENSE.txt file in the root directory
+# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+#
+# Any modifications or derivative works of this code must retain this
+# copyright notice, and modified files need to carry a notice indicating
+# that they have been altered from the originals.
+
+from copy import copy, deepcopy
+
+from zne.utils.unset import UNSET, UnsetType
+
+
+class TestUnsetType:
+    """Test `UnsetType` class."""
+
+    def test_singleton(self):
+        """Test that class is a singleton."""
+        assert UnsetType() is UnsetType()
+        assert UNSET is UnsetType()
+
+    def test_bool(self):
+        """Test that instances evaluate to `False`."""
+        assert not UNSET
+        assert not UnsetType()
+
+    def test_eq(self):
+        """Test equality."""
+        assert UnsetType() == UnsetType()
+        assert UNSET == UnsetType()
+
+    def test_copy(self):
+        """Test copy."""
+        assert copy(UNSET) is UNSET
+
+    def test_deepcopy(self):
+        """Test deepcopy."""
+        assert deepcopy(UNSET, memo := {}) is UNSET
+        assert not memo
diff --git a/test/utils/test_validation.py b/test/utils/test_validation.py
index b35872c..1f129ba 100644
--- a/test/utils/test_validation.py
+++ b/test/utils/test_validation.py
@@ -10,83 +10,320 @@
 # copyright notice, and modified files need to carry a notice indicating
 # that they have been altered from the originals.
 
-from itertools import product
+from copy import copy, deepcopy
+from typing import Any
+from unittest.mock import Mock
 
 from pytest import mark, raises
 
-from zne.utils.validation import (
-    _validate_isinstance_types,
-    validate_object_type,
-    validate_sequence,
-)
-
-from .. import TYPES
+from zne.utils.unset import UNSET
+from zne.utils.validation import quality
 
 
 ################################################################################
-## VALIDATION
+## AUXILIARY
 ################################################################################
-@mark.parametrize("obj", [*(t for t in TYPES if t == t)])
-def test_validate_object_type(obj):
-    assert obj == validate_object_type(None, type(obj), default=obj)
-    assert obj == validate_object_type(obj, type(obj))
-    assert obj == validate_object_type(obj, (type(obj), *[type(t) for t in TYPES]))
-    with raises(TypeError):
-        validate_object_type(obj, [])
-    with raises(TypeError):
-        validate_object_type(obj, tuple(type(t) for t in TYPES if not isinstance(obj, type(t))))
-
-
-@mark.parametrize(
-    "seq, typecast",
-    cases := tuple(
-        product(
-            [
-                [0],
-                (0,),
-                [0, 1],
-                (0, 1),
-                [0, 1, 2],
-                (0, 1, 2),
-                [(), (0,), (0, 1)],
-                ((), (0,), (0, 1)),
-            ],
-            [list, tuple],
-        )
-    ),
-    ids=[f"{s}-{t.__name__}" for s, t in cases],
-)
-def test_validate_sequence(seq, typecast):
-    assert validate_sequence(seq) == seq
-    assert validate_sequence(None, default=seq) == seq
-    assert validate_sequence(seq, cast_single=True) == seq
-    assert validate_sequence(seq, standard_type=typecast) == typecast(seq)
-    assert validate_sequence(None, standard_type=typecast, default=seq) == typecast(seq)
-    assert validate_sequence(seq, standard_type=typecast, cast_single=True) == typecast(seq)
-    if len(seq) > 0:
-        el = seq[-1]
-        el_type = type(el)
-        assert validate_sequence(seq, el_type) == seq
-        assert validate_sequence(el, el_type, cast_single=True) == (el,)
-        assert validate_sequence(seq, el_type, standard_type=typecast) == typecast(seq)
-        assert validate_sequence(el, el_type, standard_type=typecast, cast_single=True) == typecast(
-            [el]
-        )
-    with raises(ValueError):
-        validate_sequence(seq, standard_type=type(None))
-    s = set(range(len(seq)))
-    assert validate_sequence(s, allow_set=True) == s
-    assert validate_sequence(tuple(s), allow_set=True, standard_type=set) == s
+class EqualCopies:
+    """Auxiliary wrapper class to check that deepcopies are equal but not the same object."""
+
+    def __init__(self, value: Any = None) -> None:
+        self.value = value
+
+    def __eq__(self, __o: object) -> bool:
+        if not isinstance(__o, self.__class__):
+            return False
+        return self.value == __o.value
 
 
 ################################################################################
-## AUXILIARY
+## TESTS
 ################################################################################
-@mark.parametrize("obj", [*(t for t in TYPES if t == t)])
-def test_validate_isinstance_types(obj):
-    tp = type(obj)
-    assert _validate_isinstance_types(tp) == (tp,)
-    assert _validate_isinstance_types((int, tp)) == (int, tp)
-    assert _validate_isinstance_types((tp, tp)) == (tp, tp)
-    with raises(TypeError):
-        _validate_isinstance_types(obj)
+class TestQualityInit:
+    """Test `quality` descriptor init."""
+
+    def test_defaults(self):
+        q = quality()
+        assert q.fval is None
+        assert q.feff is None
+        assert q.default is UNSET
+        assert q.null is UNSET
+
+    def test_custom(self):
+        fval = Mock()
+        feff = Mock()
+        default = EqualCopies("default")
+        null = EqualCopies("null")
+        q = quality(fval, feff, default=default, null=null)
+        assert q.fval is fval
+        assert q.feff is feff
+        assert q.default == default
+        assert q.null == null
+
+    def test_keyword_only(self):
+        with raises(TypeError):
+            quality(None, None, "default")
+
+    def test_uset_name(self):
+        q = quality()
+        assert q.name is None
+        assert q.private_name is None
+
+
+class TestQualityCall:
+    """Test `quality` descriptor call."""
+
+    @mark.parametrize(
+        "keys",
+        [
+            ("fval",),
+            ("feff",),
+            ("fval", "feff"),
+        ],
+    )
+    def test_mutable(self, keys):
+        q = quality()
+        kwargs = {k: Mock() for k in keys}
+        qq = q(**kwargs)
+        assert q is not qq
+        for key, value in kwargs.items():
+            assert getattr(qq, key) is value
+            assert getattr(q, key) is not value
+
+    @mark.parametrize(
+        "keys",
+        [
+            ("default",),
+            ("null",),
+            ("default", "null"),
+        ],
+    )
+    def test_immutable(self, keys):
+        q = quality()
+        kwargs = {k: EqualCopies(k) for k in keys}
+        qq = q(**kwargs)
+        assert q is not qq
+        for key, value in kwargs.items():
+            assert getattr(qq, key) == value
+            assert getattr(q, key) != value
+
+    @mark.parametrize("name", [None, "attr", "name", "foo", "bar"])
+    def test_name(self, name):
+        q = quality()
+        q._name = name
+        qq = q()
+        assert q is not qq
+        assert qq.name == q.name
+        assert qq.private_name == q.private_name
+
+
+@mark.parametrize("name", [None, "attr", "name", "foo", "bar"])
+class TestQualityCopy:
+    """Test `quality` descriptor copy logic."""
+
+    def test_copy(self, name):
+        fval = Mock()
+        feff = Mock()
+        default = EqualCopies("default")
+        null = EqualCopies("null")
+        descriptor = quality(fval, feff, default=default, null=null)
+        descriptor._name = name
+        duplicate = copy(descriptor)
+        assert descriptor is not duplicate
+        assert descriptor.fval is duplicate.fval
+        assert descriptor.feff is duplicate.feff
+        assert descriptor.default == duplicate.default
+        assert descriptor.null == duplicate.null
+        assert descriptor.name == duplicate.name
+        assert descriptor.private_name == duplicate.private_name
+
+    def test_deepcopy(self, name):
+        fval = EqualCopies("fval")
+        feff = EqualCopies("feff")
+        default = EqualCopies("default")
+        null = EqualCopies("null")
+        descriptor = quality(fval, feff, default=default, null=null)
+        descriptor._name = name
+        duplicate = deepcopy(descriptor, memo := {})
+
+        assert descriptor is not duplicate
+        assert descriptor.fval is not duplicate.fval
+        assert descriptor.fval == duplicate.fval
+        assert descriptor.feff is not duplicate.feff
+        assert descriptor.feff == duplicate.feff
+        assert descriptor.default is not duplicate.default
+        assert descriptor.default == duplicate.default
+        assert descriptor.null is not duplicate.null
+        assert descriptor.null == duplicate.null
+        assert descriptor.name == duplicate.name
+        assert descriptor.private_name == duplicate.private_name
+
+        memo_values = [fval, feff, default, null]
+        memo_values += [v.__dict__ for v in memo_values]
+        for value in memo.values():
+            if isinstance(value, quality):
+                assert value is duplicate
+            elif isinstance(value, list):
+                assert descriptor in value
+                for v in memo_values:
+                    assert v in value
+            else:
+                assert value in memo_values
+
+
+class TestQualityProperties:
+    """Test `quality` descriptor properties."""
+
+    @mark.parametrize("property", ["default", "null", "name", "private_name"])
+    def test_immutable(self, property):
+        descriptor = quality()
+        with raises(AttributeError):
+            setattr(descriptor, property, ...)
+
+    @mark.parametrize("property", ["default", "null"])
+    def test_copy(self, property):
+        p = EqualCopies(property)
+        descriptor = quality(**{property: p})
+        assert getattr(descriptor, property) is not p
+        assert getattr(descriptor, property) == p
+
+
+class TestQualityDescriptor:
+    """Test `quality` descriptor protocol."""
+
+    @mark.parametrize("attr", [None, "attr", "name", "foo", "bar"])
+    def test_set_name(self, attr):
+        """Test `quality` descriptor `__set_name__` logic."""
+        descriptor = quality()
+        type("Dummy", (), {attr: descriptor})
+        assert descriptor.name == attr
+        assert descriptor.private_name == attr if attr is None else "__quality_" + attr
+
+    def test_get(self):
+        """Test `quality` descriptor `__get__` logic."""
+        descriptor = quality()
+        Dummy = type("Dummy", (), {"q": descriptor})
+        assert Dummy.q is descriptor
+
+    @mark.parametrize(
+        "kwargs",
+        [
+            {"default": UNSET, "null": UNSET},
+            {"default": EqualCopies("default"), "null": UNSET},
+            {"default": UNSET, "null": EqualCopies("null")},
+            {"default": EqualCopies("default"), "null": EqualCopies("null")},
+        ],
+    )
+    class TestSet:
+        """Test `quality` descriptor `__set__` logic."""
+
+        def test_unset(self, kwargs):
+            default = kwargs.get("default")
+            null = kwargs.get("null")
+            descriptor = quality(**kwargs)
+            Dummy = type("Dummy", (), {"q": descriptor})
+            d = Dummy()
+            expected = None if default is UNSET and null is UNSET else default or null
+            assert d.q is not UNSET
+            assert d.q == expected
+            assert d.q == getattr(d, descriptor.private_name)
+
+        def test_none(self, kwargs):
+            default = kwargs.get("default")
+            null = kwargs.get("null")
+            descriptor = quality(**kwargs)
+            Dummy = type("Dummy", (), {"q": descriptor})
+            d = Dummy()
+            d.q = None
+            expected = None if default is UNSET and null is UNSET else null or default
+            assert d.q is not UNSET
+            assert d.q == expected
+            assert d.q == getattr(d, descriptor.private_name)
+
+        def test_ellipsis(self, kwargs):
+            default = kwargs.get("default")
+            null = kwargs.get("null")
+            descriptor = quality(**kwargs)
+            Dummy = type("Dummy", (), {"q": descriptor})
+            d = Dummy()
+            d.q = ...
+            expected = ... if default is UNSET and null is UNSET else default or null
+            assert d.q is not UNSET
+            assert d.q == expected
+            assert d.q == getattr(d, descriptor.private_name)
+
+        def test_set(self, kwargs):
+            descriptor = quality(**kwargs)
+            Dummy = type("Dummy", (), {"q": descriptor})
+            d = Dummy()
+            q = Mock()
+            d.q = q
+            assert d.q is q
+            assert d.q is getattr(d, descriptor.private_name)
+
+    def test_delete(self):
+        """Test `quality` descriptor `__delete__` logic."""
+        descriptor = quality()
+        Dummy = type("Dummy", (), {"q": descriptor})
+        d = Dummy()
+        assert not hasattr(d, descriptor.private_name)
+        del d.q  # Does not raise error
+        q = Mock()
+        d.q = q
+        assert getattr(d, descriptor.private_name) is q
+        del d.q
+        assert not hasattr(d, descriptor.private_name)
+
+    def test_fval(self):
+        """Test `quality` descriptor `fval` attribute."""
+        fval = Mock()
+        descriptor = quality(fval=fval)
+        Dummy = type("Dummy", (), {"q": descriptor})
+        d = Dummy()
+        q = Mock()
+        d.q = q
+        fval.assert_called_once_with(d, q)
+
+    def test_feff(self):
+        """Test `quality` descriptor `feff` attribute."""
+        feff = Mock()
+        descriptor = quality(feff=feff)
+        Dummy = type("Dummy", (), {"q": descriptor})
+        d = Dummy()
+        q = Mock()
+        d.q = q
+        feff.assert_called_once_with(d)
+        feff.reset_mock()
+        del d.q
+        feff.assert_called_once_with(d)
+
+
+class TestQualityDecorators:
+    """Test `quality` descriptor decorators."""
+
+    def test_validator(self):
+        """Test validator decorator."""
+        q = quality()
+        fval = Mock()
+        qq = q.validator(fval)
+        assert qq is not q
+        assert qq.fval is fval
+        assert qq.fval is not q.fval
+        assert qq.feff is q.feff
+        assert qq.default == q.default
+        assert qq.null == q.null
+        assert qq.name == q.name
+        assert qq.private_name == q.private_name
+
+    def test_side_effect(self):
+        """Test side effect decorator."""
+        q = quality()
+        feff = Mock()
+        qq = q.side_effect(feff)
+        assert qq is not q
+        assert qq.fval is q.fval
+        assert qq.feff is feff
+        assert qq.feff is not q.feff
+        assert qq.default == q.default
+        assert qq.null == q.null
+        assert qq.name == q.name
+        assert qq.private_name == q.private_name