diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce18512..62b59ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,13 +29,10 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12-dev" ] + python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ] include: - experimental: false - - python-version: "3.12-dev" - experimental: true - steps: - name: Checkout uses: actions/checkout@v4.1.1 diff --git a/changes/141.feature.rst b/changes/141.feature.rst new file mode 100644 index 0000000..8c88e55 --- /dev/null +++ b/changes/141.feature.rst @@ -0,0 +1 @@ +Validated properties of styles can now be defined as dataclass class attributes. diff --git a/src/travertino/declaration.py b/src/travertino/declaration.py index b731239..cec23be 100644 --- a/src/travertino/declaration.py +++ b/src/travertino/declaration.py @@ -1,6 +1,8 @@ +from collections import defaultdict from warnings import filterwarnings, warn from .colors import color +from .constants import BOTTOM, LEFT, RIGHT, TOP # Make sure deprecation warnings are shown by default filterwarnings("default", category=DeprecationWarning) @@ -74,19 +76,155 @@ def __str__(self): return ", ".join(self._options) +class validated_property: + def __init__(self, choices, initial=None): + """Define a simple validated property attribute. + + :param choices: The available choices. + :param initial: The initial value for the property. + """ + self.choices = choices + self.initial = None + + try: + # If an initial value has been provided, it must be consistent with + # the choices specified. + if initial is not None: + self.initial = choices.validate(initial) + except ValueError: + # Unfortunately, __set_name__ hasn't been called yet, so we don't know the + # property's name. + raise ValueError( + f"Invalid initial value {initial!r}. Available choices: {choices}" + ) + + def __set_name__(self, owner, name): + self.name = name + owner._PROPERTIES[owner].add(name) + owner._ALL_PROPERTIES[owner].add(name) + + def __get__(self, obj, objtype=None): + if obj is None: + return self + + value = getattr(obj, f"_{self.name}", None) + return self.initial if value is None else value + + def __set__(self, obj, value): + if value is self: + # This happens during autogenerated dataclass __init__ when no value is + # supplied. + return + + if value is None: + raise ValueError( + "Python `None` cannot be used as a style value; " + f"to reset a property, use del `style.{self.name}`" + ) + + try: + value = self.choices.validate(value) + except ValueError: + raise ValueError( + f"Invalid value {value!r} for property {self.name}; " + f"Valid values are: {self.choices}" + ) + + if value != getattr(obj, f"_{self.name}", None): + setattr(obj, f"_{self.name}", value) + obj.apply(self.name, value) + + def __delete__(self, obj): + try: + delattr(obj, f"_{self.name}") + except AttributeError: + pass + else: + obj.apply(self.name, self.initial) + + +class directional_property: + DIRECTIONS = [TOP, RIGHT, BOTTOM, LEFT] + ASSIGNMENT_SCHEMES = { + # T R B L + 1: [0, 0, 0, 0], + 2: [0, 1, 0, 1], + 3: [0, 1, 2, 1], + 4: [0, 1, 2, 3], + } + + def __init__(self, name_format, choices=None, initial=None): + """Define a property attribute that proxies for top/right/bottom/left alternatives. + + :param name_format: The format from which to generate subproperties. "{}" will + be replaced with "_top", etc. + :param choices: The available choices. + :param initial: The initial value for the property. + """ + self.name_format = name_format + self.choices = choices + self.initial = initial + + def __set_name__(self, owner, name): + self.name = name + owner._ALL_PROPERTIES[owner].add(self.name) + + def format(self, direction): + return self.name_format.format(f"_{direction}") + + def __get__(self, obj, objtype=None): + if obj is None: + return self + + return tuple(obj[self.format(direction)] for direction in self.DIRECTIONS) + + def __set__(self, obj, value): + if value is self: + # This happens during autogenerated dataclass __init__ when no value is + # supplied. + return + + if not isinstance(value, tuple): + value = (value,) + + if order := self.ASSIGNMENT_SCHEMES.get(len(value)): + for direction, index in zip(self.DIRECTIONS, order): + obj[self.format(direction)] = value[index] + else: + raise ValueError( + f"Invalid value for '{self.name}'; value must be a number, or a 1-4 tuple." + ) + + def __delete__(self, obj): + for direction in self.DIRECTIONS: + del obj[self.format(direction)] + + class BaseStyle: """A base class for style declarations. - Exposes a dict-like interface. + Exposes a dict-like interface. Designed for subclasses to be decorated + with @dataclass(kw_only=True), which most IDEs should be able to interpret and + provide autocompletion of argument names. On Python < 3.10, init=False can be used + to still get the keyword-only behavior from the included __init__. """ - _PROPERTIES = {} - _ALL_PROPERTIES = {} + _PROPERTIES = defaultdict(set) + _ALL_PROPERTIES = defaultdict(set) + # Fallback in case subclass isn't decorated as subclass (probably from using + # previous API) or for pre-3.10, before kw_only argument existed. def __init__(self, **style): - self._applicator = None self.update(**style) + @property + def _applicator(self): + return getattr(self, "_assigned_applicator", None) + + @_applicator.setter + def _applicator(self, value): + self._assigned_applicator = value + ###################################################################### # Interface that style declarations must define ###################################################################### @@ -101,15 +239,15 @@ def apply(self, property, value): ###################################################################### def reapply(self): - for style in self._PROPERTIES.get(self.__class__, set()): + for style in self._PROPERTIES[self.__class__]: self.apply(style, getattr(self, style)) def update(self, **styles): "Set multiple styles on the style definition." for name, value in styles.items(): name = name.replace("-", "_") - if name not in self._ALL_PROPERTIES.get(self.__class__, set()): - raise NameError("Unknown style '%s'" % name) + if name not in self._ALL_PROPERTIES[self.__class__]: + raise NameError(f"Unknown style {name}") setattr(self, name, value) @@ -117,164 +255,91 @@ def copy(self, applicator=None): "Create a duplicate of this style declaration." dup = self.__class__() dup._applicator = applicator - for style in self._PROPERTIES.get(self.__class__, set()): + for style in self._PROPERTIES[self.__class__]: try: - setattr(dup, style, getattr(self, "_%s" % style)) + setattr(dup, style, getattr(self, f"_{style}")) except AttributeError: pass return dup def __getitem__(self, name): name = name.replace("-", "_") - if name in self._PROPERTIES.get(self.__class__, set()): + if name in self._PROPERTIES[self.__class__]: return getattr(self, name) raise KeyError(name) def __setitem__(self, name, value): name = name.replace("-", "_") - if name in self._PROPERTIES.get(self.__class__, set()): + if name in self._PROPERTIES[self.__class__]: setattr(self, name, value) else: raise KeyError(name) def __delitem__(self, name): name = name.replace("-", "_") - if name in self._PROPERTIES.get(self.__class__, set()): + if name in self._PROPERTIES[self.__class__]: delattr(self, name) else: raise KeyError(name) def items(self): - result = [] - for name in self._PROPERTIES.get(self.__class__, set()): - try: - result.append((name, getattr(self, "_%s" % name))) - except AttributeError: - pass - return result + return [ + (name, value) + for name in self._PROPERTIES[self.__class__] + if (value := getattr(self, f"_{name}", None)) is not None + ] def keys(self): - result = set() - for name in self._PROPERTIES.get(self.__class__, set()): - if hasattr(self, "_%s" % name): - result.add(name) - return result + return { + name + for name in self._PROPERTIES[self.__class__] + if hasattr(self, f"_{name}") + } ###################################################################### # Get the rendered form of the style declaration ###################################################################### def __str__(self): non_default = [] - for name in self._PROPERTIES.get(self.__class__, set()): + for name in self._PROPERTIES[self.__class__]: try: - non_default.append( - (name.replace("_", "-"), getattr(self, "_%s" % name)) - ) + non_default.append((name.replace("_", "-"), getattr(self, f"_{name}"))) except AttributeError: pass return "; ".join(f"{name}: {value}" for name, value in sorted(non_default)) + ###################################################################### + # Backwards compatibility + ###################################################################### + @classmethod def validated_property(cls, name, choices, initial=None): - "Define a simple validated property attribute." - try: - # If an initial value has been provided, it must be consistent with - # the choices specified. - if initial is not None: - initial = choices.validate(initial) - except ValueError: - raise ValueError(f"Invalid initial value {initial!r} for property {name}") - - def getter(self): - return getattr(self, "_%s" % name, initial) - - def setter(self, value): - if value is None: - raise ValueError( - "Python `None` cannot be used as a style value; " - f"to reset a property, use del `style.{name}`" - ) - try: - value = choices.validate(value) - except ValueError: - raise ValueError( - f"Invalid value {value!r} for property {name}; " - f"Valid values are: {choices}" - ) - - if value != getattr(self, "_%s" % name, initial): - setattr(self, "_%s" % name, value) - self.apply(name, value) - - def deleter(self): - try: - value = getattr(self, "_%s" % name, initial) - delattr(self, "_%s" % name) - if value != initial: - self.apply(name, initial) - except AttributeError: - # Attribute doesn't exist - pass - - cls._PROPERTIES.setdefault(cls, set()).add(name) - cls._ALL_PROPERTIES.setdefault(cls, set()).add(name) - setattr(cls, name, property(getter, setter, deleter)) + warn( + "Defining style properties with class methods is deprecated; use class " + "attributes instead.", + DeprecationWarning, + stacklevel=2, + ) + prop = validated_property(choices, initial) + setattr(cls, name, prop) + prop.__set_name__(cls, name) @classmethod def directional_property(cls, name): - "Define a property attribute that proxies for top/right/bottom/left alternatives." - - def getter(self): - return ( - getattr(self, name % "_top"), - getattr(self, name % "_right"), - getattr(self, name % "_bottom"), - getattr(self, name % "_left"), - ) - - def setter(self, value): - if isinstance(value, tuple): - if len(value) == 4: - setattr(self, name % "_top", value[0]) - setattr(self, name % "_right", value[1]) - setattr(self, name % "_bottom", value[2]) - setattr(self, name % "_left", value[3]) - elif len(value) == 3: - setattr(self, name % "_top", value[0]) - setattr(self, name % "_right", value[1]) - setattr(self, name % "_bottom", value[2]) - setattr(self, name % "_left", value[1]) - elif len(value) == 2: - setattr(self, name % "_top", value[0]) - setattr(self, name % "_right", value[1]) - setattr(self, name % "_bottom", value[0]) - setattr(self, name % "_left", value[1]) - elif len(value) == 1: - setattr(self, name % "_top", value[0]) - setattr(self, name % "_right", value[0]) - setattr(self, name % "_bottom", value[0]) - setattr(self, name % "_left", value[0]) - else: - raise ValueError( - "Invalid value for '{}'; value must be an number, or a 1-4 tuple.".format( - name % "" - ) - ) - else: - setattr(self, name % "_top", value) - setattr(self, name % "_right", value) - setattr(self, name % "_bottom", value) - setattr(self, name % "_left", value) - - def deleter(self): - delattr(self, name % "_top") - delattr(self, name % "_right") - delattr(self, name % "_bottom") - delattr(self, name % "_left") - - cls._ALL_PROPERTIES.setdefault(cls, set()).add(name % "") - setattr(cls, name % "", property(getter, setter, deleter)) + warn( + "Defining style properties with class methods is deprecated; use class " + "attributes instead.", + DeprecationWarning, + stacklevel=2, + ) + name_format = name % "{}" + name = name_format.format("") + prop = directional_property(name_format) + setattr(cls, name, prop) + prop.__set_name__(cls, name) + + # Kept here for reference, for eventual implementation? # def list_property(name, choices, initial=None): # "Define a property attribute that accepts a list of independently validated values." diff --git a/tests/test_choices.py b/tests/test_choices.py index 4bb8f85..fc40fdf 100644 --- a/tests/test_choices.py +++ b/tests/test_choices.py @@ -1,402 +1,409 @@ -from unittest import TestCase +from __future__ import annotations + +import sys +from dataclasses import dataclass from unittest.mock import Mock +from warnings import catch_warnings, filterwarnings + +import pytest from travertino.colors import NAMED_COLOR, rgb from travertino.constants import GOLDENROD, NONE, REBECCAPURPLE, TOP -from travertino.declaration import BaseStyle, Choices +from travertino.declaration import BaseStyle, Choices, validated_property + +if sys.version_info < (3, 10): + _DATACLASS_KWARGS = {"init": False} +else: + _DATACLASS_KWARGS = {"kw_only": True} + + +def prep_style_class(cls): + """Decorator to apply dataclass and mock a style class's apply method.""" + return mock_apply(dataclass(**_DATACLASS_KWARGS)(cls)) + + +def mock_apply(cls): + """Only mock apply, without applying dataclass.""" + orig_init = cls.__init__ + + def __init__(self, *args, **kwargs): + self.apply = Mock() + orig_init(self, *args, **kwargs) + + cls.__init__ = __init__ + return cls + + +@prep_style_class +class Style(BaseStyle): + none: str = validated_property(choices=Choices(NONE, REBECCAPURPLE), initial=NONE) + allow_string: str = validated_property( + choices=Choices(string=True), initial="start" + ) + allow_integer: int = validated_property(choices=Choices(integer=True), initial=0) + allow_number: float = validated_property(choices=Choices(number=True), initial=0) + allow_color: str = validated_property( + choices=Choices(color=True), initial="goldenrod" + ) + values: str = validated_property(choices=Choices("a", "b", NONE), initial="a") + multiple_choices: str | float = validated_property( + choices=Choices("a", "b", NONE, number=True, color=True), + initial=None, + ) + string_symbol: str = validated_property(choices=Choices(TOP, NONE)) + + +with catch_warnings(): + filterwarnings("ignore", category=DeprecationWarning) + + @mock_apply + class DeprecatedStyle(BaseStyle): + pass + + DeprecatedStyle.validated_property( + "none", choices=Choices(NONE, REBECCAPURPLE), initial=NONE + ) + DeprecatedStyle.validated_property( + "allow_string", choices=Choices(string=True), initial="start" + ) + DeprecatedStyle.validated_property( + "allow_integer", choices=Choices(integer=True), initial=0 + ) + DeprecatedStyle.validated_property( + "allow_number", choices=Choices(number=True), initial=0 + ) + DeprecatedStyle.validated_property( + "allow_color", choices=Choices(color=True), initial="goldenrod" + ) + DeprecatedStyle.validated_property( + "values", choices=Choices("a", "b", NONE), initial="a" + ) + DeprecatedStyle.validated_property( + "multiple_choices", + choices=Choices("a", "b", NONE, number=True, color=True), + initial=None, + ) + DeprecatedStyle.validated_property("string_symbol", choices=Choices(TOP, NONE)) + + +def assert_property(obj, name, value): + assert getattr(obj, name) == value + + obj.apply.assert_called_once_with(name, value) + obj.apply.reset_mock() + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_none(StyleClass): + style = StyleClass() + assert style.none == NONE + + with pytest.raises(ValueError): + style.none = 10 + + with pytest.raises(ValueError): + style.none = 3.14159 + + with pytest.raises(ValueError): + style.none = "#112233" + + with pytest.raises(ValueError): + style.none = "a" + + with pytest.raises(ValueError): + style.none = "b" + # Set the property to a different explicit value + style.none = REBECCAPURPLE + assert_property(style, "none", REBECCAPURPLE) -class PropertyChoiceTests(TestCase): - def assert_property(self, obj, value, check_mock=True): - self.assertEqual(obj.prop, value) - if check_mock: - obj.apply.assert_called_once_with("prop", value) - obj.apply.reset_mock() + # A Travertino NONE is an explicit value + style.none = NONE + assert_property(style, "none", NONE) - def test_none(self): - class MyObject(BaseStyle): - def __init__(self): - self.apply = Mock() + # Set the property to a different explicit value + style.none = REBECCAPURPLE + assert_property(style, "none", REBECCAPURPLE) + + # A Python None is invalid + with pytest.raises(ValueError): + style.none = None + + # The property can be reset + del style.none + assert_property(style, "none", NONE) + + with pytest.raises( + ValueError, + match=r"Invalid value 'invalid' for property none; Valid values are: " + r"none, rebeccapurple", + ): + style.none = "invalid" + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_allow_string(StyleClass): + style = StyleClass() + assert style.allow_string == "start" - MyObject.validated_property( - "prop", choices=Choices(NONE, REBECCAPURPLE), initial=NONE - ) + with pytest.raises(ValueError): + style.allow_string = 10 - obj = MyObject() - self.assert_property(obj, NONE, check_mock=False) + with pytest.raises(ValueError): + style.allow_string = 3.14159 - with self.assertRaises(ValueError): - obj.prop = 10 + style.allow_string = REBECCAPURPLE + assert_property(style, "allow_string", "rebeccapurple") - with self.assertRaises(ValueError): - obj.prop = 3.14159 + style.allow_string = "#112233" + assert_property(style, "allow_string", "#112233") - with self.assertRaises(ValueError): - obj.prop = "#112233" + style.allow_string = "a" + assert_property(style, "allow_string", "a") - with self.assertRaises(ValueError): - obj.prop = "a" + style.allow_string = "b" + assert_property(style, "allow_string", "b") - with self.assertRaises(ValueError): - obj.prop = "b" + # A Travertino NONE is an explicit string value + style.allow_string = NONE + assert_property(style, "allow_string", NONE) - # Set the property to a different explicit value - obj.prop = REBECCAPURPLE - self.assert_property(obj, REBECCAPURPLE) + # A Python None is invalid + with pytest.raises(ValueError): + style.allow_string = None - # A Travertino NONE is an explicit value - obj.prop = NONE - self.assert_property(obj, NONE) + # The property can be reset + del style.allow_string + assert_property(style, "allow_string", "start") - # Set the property to a different explicit value - obj.prop = REBECCAPURPLE - self.assert_property(obj, REBECCAPURPLE) + with pytest.raises( + ValueError, + match=r"Invalid value 99 for property allow_string; Valid values are: ", + ): + style.allow_string = 99 - # A Python None is invalid - with self.assertRaises(ValueError): - obj.prop = None - # The property can be reset - del obj.prop - self.assert_property(obj, NONE) +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_allow_integer(StyleClass): + style = StyleClass() + assert style.allow_integer == 0 - # Check the error message - try: - obj.prop = "invalid" - self.fail("Should raise ValueError") - except ValueError as v: - self.assertEqual( - str(v), - "Invalid value 'invalid' for property prop; Valid values are: none, rebeccapurple", - ) + style.allow_integer = 10 + assert_property(style, "allow_integer", 10) - def test_allow_string(self): - class MyObject(BaseStyle): - def __init__(self): - self.apply = Mock() + # This is an odd case; Python happily rounds floats to integers. + # It's more trouble than it's worth to correct this. + style.allow_integer = 3.14159 + assert_property(style, "allow_integer", 3) - MyObject.validated_property( - "prop", choices=Choices(string=True), initial="start" - ) + with pytest.raises(ValueError): + style.allow_integer = REBECCAPURPLE - obj = MyObject() - self.assertEqual(obj.prop, "start") + with pytest.raises(ValueError): + style.allow_integer = "#112233" - with self.assertRaises(ValueError): - obj.prop = 10 + with pytest.raises(ValueError): + style.allow_integer = "a" - with self.assertRaises(ValueError): - obj.prop = 3.14159 + with pytest.raises(ValueError): + style.allow_integer = "b" - obj.prop = REBECCAPURPLE - self.assert_property(obj, "rebeccapurple") + # A Travertino NONE is an explicit string value + with pytest.raises(ValueError): + style.allow_integer = NONE - obj.prop = "#112233" - self.assert_property(obj, "#112233") + # A Python None is invalid + with pytest.raises(ValueError): + style.allow_integer = None - obj.prop = "a" - self.assert_property(obj, "a") + # The property can be reset + del style.allow_integer + assert_property(style, "allow_integer", 0) - obj.prop = "b" - self.assert_property(obj, "b") + # Check the error message + with pytest.raises( + ValueError, + match=r"Invalid value 'invalid' for property allow_integer; Valid values are: ", + ): + style.allow_integer = "invalid" - # A Travertino NONE is an explicit string value - obj.prop = NONE - self.assert_property(obj, NONE) - # A Python None is invalid - with self.assertRaises(ValueError): - obj.prop = None +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_allow_number(StyleClass): + style = StyleClass() + assert style.allow_number == 0 - # The property can be reset - del obj.prop - self.assert_property(obj, "start") + style.allow_number = 10 + assert_property(style, "allow_number", 10.0) - # Check the error message - try: - obj.prop = 99 - self.fail("Should raise ValueError") - except ValueError as v: - self.assertEqual( - str(v), "Invalid value 99 for property prop; Valid values are: " - ) + style.allow_number = 3.14159 + assert_property(style, "allow_number", 3.14159) - def test_allow_integer(self): - class MyObject(BaseStyle): - def __init__(self): - self.apply = Mock() + with pytest.raises(ValueError): + style.allow_number = REBECCAPURPLE - MyObject.validated_property("prop", choices=Choices(integer=True), initial=0) + with pytest.raises(ValueError): + style.allow_number = "#112233" - obj = MyObject() - self.assertEqual(obj.prop, 0) + with pytest.raises(ValueError): + style.allow_number = "a" - obj.prop = 10 - self.assert_property(obj, 10) + with pytest.raises(ValueError): + style.allow_number = "b" - # This is an odd case; Python happily rounds floats to integers. - # It's more trouble than it's worth to correct this. - obj.prop = 3.14159 - self.assert_property(obj, 3) + # A Travertino NONE is an explicit string value + with pytest.raises(ValueError): + style.allow_number = NONE - with self.assertRaises(ValueError): - obj.prop = REBECCAPURPLE + # A Python None is invalid + with pytest.raises(ValueError): + style.allow_number = None - with self.assertRaises(ValueError): - obj.prop = "#112233" + # The property can be reset + del style.allow_number + assert_property(style, "allow_number", 0) - with self.assertRaises(ValueError): - obj.prop = "a" + with pytest.raises( + ValueError, + match=r"Invalid value 'invalid' for property allow_number; Valid values are: ", + ): + style.allow_number = "invalid" - with self.assertRaises(ValueError): - obj.prop = "b" - # A Travertino NONE is an explicit string value - with self.assertRaises(ValueError): - obj.prop = NONE +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_allow_color(StyleClass): + style = StyleClass() + assert style.allow_color == NAMED_COLOR[GOLDENROD] - # A Python None is invalid - with self.assertRaises(ValueError): - obj.prop = None + with pytest.raises(ValueError): + style.allow_color = 10 - # The property can be reset - del obj.prop - self.assert_property(obj, 0) + with pytest.raises(ValueError): + style.allow_color = 3.14159 - # Check the error message - try: - obj.prop = "invalid" - self.fail("Should raise ValueError") - except ValueError as v: - self.assertEqual( - str(v), - "Invalid value 'invalid' for property prop; Valid values are: ", - ) + style.allow_color = REBECCAPURPLE + assert_property(style, "allow_color", NAMED_COLOR[REBECCAPURPLE]) - def test_allow_number(self): - class MyObject(BaseStyle): - def __init__(self): - self.apply = Mock() + style.allow_color = "#112233" + assert_property(style, "allow_color", rgb(0x11, 0x22, 0x33)) - MyObject.validated_property("prop", choices=Choices(number=True), initial=0) + with pytest.raises(ValueError): + style.allow_color = "a" - obj = MyObject() - self.assertEqual(obj.prop, 0) + with pytest.raises(ValueError): + style.allow_color = "b" - obj.prop = 10 - self.assert_property(obj, 10.0) + # A Travertino NONE is an explicit string value + with pytest.raises(ValueError): + style.allow_color = NONE - obj.prop = 3.14159 - self.assert_property(obj, 3.14159) + # A Python None is invalid + with pytest.raises(ValueError): + style.allow_color = None - with self.assertRaises(ValueError): - obj.prop = REBECCAPURPLE + # The property can be reset + del style.allow_color + assert_property(style, "allow_color", NAMED_COLOR["goldenrod"]) - with self.assertRaises(ValueError): - obj.prop = "#112233" + with pytest.raises( + ValueError, + match=r"Invalid value 'invalid' for property allow_color; Valid values are: ", + ): + style.allow_color = "invalid" - with self.assertRaises(ValueError): - obj.prop = "a" - with self.assertRaises(ValueError): - obj.prop = "b" +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_values(StyleClass): + style = StyleClass() + assert style.values == "a" - # A Travertino NONE is an explicit string value - with self.assertRaises(ValueError): - obj.prop = NONE + with pytest.raises(ValueError): + style.values = 10 - # A Python None is invalid - with self.assertRaises(ValueError): - obj.prop = None + with pytest.raises(ValueError): + style.values = 3.14159 - # The property can be reset - del obj.prop - self.assert_property(obj, 0) + with pytest.raises(ValueError): + style.values = REBECCAPURPLE - # Check the error message - try: - obj.prop = "invalid" - self.fail("Should raise ValueError") - except ValueError as v: - self.assertEqual( - str(v), - "Invalid value 'invalid' for property prop; Valid values are: ", - ) + with pytest.raises(ValueError): + style.values = "#112233" - def test_allow_color(self): - class MyObject(BaseStyle): - def __init__(self): - self.apply = Mock() + style.values = NONE + assert_property(style, "values", NONE) - MyObject.validated_property( - "prop", choices=Choices(color=True), initial="goldenrod" - ) - - obj = MyObject() - self.assertEqual(obj.prop, NAMED_COLOR[GOLDENROD]) - - with self.assertRaises(ValueError): - obj.prop = 10 + style.values = "b" + assert_property(style, "values", "b") - with self.assertRaises(ValueError): - obj.prop = 3.14159 + # A Python None is invalid + with pytest.raises(ValueError): + style.values = None - obj.prop = REBECCAPURPLE - self.assert_property(obj, NAMED_COLOR[REBECCAPURPLE]) + # The property can be reset + del style.values + assert_property(style, "values", "a") - obj.prop = "#112233" - self.assert_property(obj, rgb(0x11, 0x22, 0x33)) + with pytest.raises( + ValueError, + match=r"Invalid value 'invalid' for property values; Valid values are: a, b, none", + ): + style.values = "invalid" - with self.assertRaises(ValueError): - obj.prop = "a" - with self.assertRaises(ValueError): - obj.prop = "b" +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_multiple_choices(StyleClass): + style = StyleClass() - # A Travertino NONE is an explicit string value - with self.assertRaises(ValueError): - obj.prop = NONE + style.multiple_choices = 10 + assert_property(style, "multiple_choices", 10.0) - # A Python None is invalid - with self.assertRaises(ValueError): - obj.prop = None + style.multiple_choices = 3.14159 + assert_property(style, "multiple_choices", 3.14159) - # The property can be reset - del obj.prop - self.assert_property(obj, NAMED_COLOR["goldenrod"]) + style.multiple_choices = REBECCAPURPLE + assert_property(style, "multiple_choices", NAMED_COLOR[REBECCAPURPLE]) - # Check the error message - try: - obj.prop = "invalid" - self.fail("Should raise ValueError") - except ValueError as v: - self.assertEqual( - str(v), - "Invalid value 'invalid' for property prop; Valid values are: ", - ) + style.multiple_choices = "#112233" + assert_property(style, "multiple_choices", rgb(0x11, 0x22, 0x33)) - def test_values(self): - class MyObject(BaseStyle): - def __init__(self): - self.apply = Mock() + style.multiple_choices = "a" + assert_property(style, "multiple_choices", "a") - MyObject.validated_property( - "prop", choices=Choices("a", "b", NONE), initial="a" - ) + style.multiple_choices = NONE + assert_property(style, "multiple_choices", NONE) - obj = MyObject() - self.assertEqual(obj.prop, "a") + style.multiple_choices = "b" + assert_property(style, "multiple_choices", "b") - with self.assertRaises(ValueError): - obj.prop = 10 - - with self.assertRaises(ValueError): - obj.prop = 3.14159 - - with self.assertRaises(ValueError): - obj.prop = REBECCAPURPLE - - with self.assertRaises(ValueError): - obj.prop = "#112233" - - obj.prop = NONE - self.assert_property(obj, NONE) - - obj.prop = "b" - self.assert_property(obj, "b") - - # A Python None is invalid - with self.assertRaises(ValueError): - obj.prop = None - - # The property can be reset - del obj.prop - self.assert_property(obj, "a") - - # Check the error message - try: - obj.prop = "invalid" - self.fail("Should raise ValueError") - except ValueError as v: - self.assertEqual( - str(v), - "Invalid value 'invalid' for property prop; Valid values are: a, b, none", - ) + # A Python None is invalid + with pytest.raises(ValueError): + style.multiple_choices = None - def test_multiple_choices(self): - class MyObject(BaseStyle): - def __init__(self): - self.apply = Mock() + # The property can be reset + # There's no initial value, so the property is None + del style.multiple_choices + assert style.multiple_choices is None - MyObject.validated_property( - "prop", - choices=Choices("a", "b", NONE, number=True, color=True), - initial=None, - ) + # Check the error message + with pytest.raises( + ValueError, + match=r"Invalid value 'invalid' for property multiple_choices; Valid values are: " + r"a, b, none, , ", + ): + style.multiple_choices = "invalid" - obj = MyObject() - - obj.prop = 10 - self.assert_property(obj, 10.0) - - obj.prop = 3.14159 - self.assert_property(obj, 3.14159) - - obj.prop = REBECCAPURPLE - self.assert_property(obj, NAMED_COLOR[REBECCAPURPLE]) - - obj.prop = "#112233" - self.assert_property(obj, rgb(0x11, 0x22, 0x33)) - - obj.prop = "a" - self.assert_property(obj, "a") - - obj.prop = NONE - self.assert_property(obj, NONE) - - obj.prop = "b" - self.assert_property(obj, "b") - - # A Python None is invalid - with self.assertRaises(ValueError): - obj.prop = None - - # The property can be reset - # There's no initial value, so the property is None - del obj.prop - self.assertIsNone(obj.prop) - - # Check the error message - try: - obj.prop = "invalid" - self.fail("Should raise ValueError") - except ValueError as v: - self.assertEqual( - str(v), - "Invalid value 'invalid' for property prop; " - "Valid values are: a, b, none, , ", - ) - - def test_string_symbol(self): - class MyObject(BaseStyle): - def __init__(self): - self.apply = Mock() - MyObject.validated_property("prop", choices=Choices(TOP, NONE)) +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_string_symbol(StyleClass): + style = StyleClass() - obj = MyObject() + # Set a symbolic value using the string value of the symbol + # We can't just use the string directly, though - that would + # get optimized by the compiler. So we create a string and + # transform it into the value we want. + val = "TOP" + style.string_symbol = val.lower() - # Set a symbolic value using the string value of the symbol - # We can't just use the string directly, though - that would - # get optimized by the compiler. So we create a string and - # transform it into the value we want. - val = "TOP" - obj.prop = val.lower() - - # Both equality and instance checking should work. - self.assertEqual(obj.prop, TOP) - self.assertIs(obj.prop, TOP) - - def test_deprecated_default(self): - with self.assertWarns(DeprecationWarning): - Choices(default=True) + # Both equality and instance checking should work. + assert_property(style, "string_symbol", TOP) + assert style.string_symbol is TOP diff --git a/tests/test_declaration.py b/tests/test_declaration.py index debdf49..7a1da8e 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -1,7 +1,17 @@ -from unittest import TestCase -from unittest.mock import Mock, call +from __future__ import annotations -from travertino.declaration import BaseStyle, Choices +from unittest.mock import call +from warnings import catch_warnings, filterwarnings + +import pytest + +from tests.test_choices import mock_apply, prep_style_class +from travertino.declaration import ( + BaseStyle, + Choices, + directional_property, + validated_property, +) VALUE1 = "value1" VALUE2 = "value2" @@ -10,492 +20,542 @@ DEFAULT_VALUE_CHOICES = Choices(VALUE1, VALUE2, VALUE3, integer=True) +@prep_style_class class Style(BaseStyle): - def __init__(self, **kwargs): - self.apply = Mock() - super().__init__(**kwargs) - - -# Some properties with explicit initial values -Style.validated_property("explicit_const", choices=VALUE_CHOICES, initial=VALUE1) -Style.validated_property("explicit_value", choices=VALUE_CHOICES, initial=0) -Style.validated_property("explicit_none", choices=VALUE_CHOICES, initial=None) - -# A property with an implicit default value. -# This usually means the default is platform specific. -Style.validated_property("implicit", choices=DEFAULT_VALUE_CHOICES) - -# A set of directional properties -Style.validated_property("thing_top", choices=VALUE_CHOICES, initial=0) -Style.validated_property("thing_right", choices=VALUE_CHOICES, initial=0) -Style.validated_property("thing_bottom", choices=VALUE_CHOICES, initial=0) -Style.validated_property("thing_left", choices=VALUE_CHOICES, initial=0) -Style.directional_property("thing%s") - - -class ExampleNode: - def __init__(self, style=None): - if style is None: - self.style = Style() - else: - self.style = style.copy(self) - - -class DeclarationTests(TestCase): - def test_invalid_style(self): - with self.assertRaises(ValueError): - # Define a style that has an invalid initial value on a validated property - class BadStyle(BaseStyle): - pass - - BadStyle.validated_property( - "value", choices=VALUE_CHOICES, initial="something" - ) - - def test_create_and_copy(self): - style = Style(explicit_const=VALUE2, implicit=VALUE3) - - dup = style.copy() - self.assertEqual(dup.explicit_const, VALUE2) - self.assertEqual(dup.explicit_value, 0) - self.assertEqual(dup.implicit, VALUE3) - - def test_reapply(self): - node = ExampleNode(style=Style(explicit_const=VALUE2, implicit=VALUE3)) - - node.style.reapply() - node.style.apply.assert_has_calls( - [ - call("explicit_const", VALUE2), - call("explicit_value", 0), - call("explicit_none", None), - call("implicit", VALUE3), - call("thing_left", 0), - call("thing_top", 0), - call("thing_right", 0), - call("thing_bottom", 0), - ], - any_order=True, - ) - - def test_property_with_explicit_const(self): - node = ExampleNode() - - # Default value is VALUE1 - self.assertIs(node.style.explicit_const, VALUE1) - node.style.apply.assert_not_called() - - # Modify the value - node.style.explicit_const = 10 - - self.assertEqual(node.style.explicit_const, 10) - node.style.apply.assert_called_once_with("explicit_const", 10) - - # Clear the applicator mock - node.style.apply.reset_mock() - - # Set the value to the same value. - # No dirty notification is sent - node.style.explicit_const = 10 - self.assertEqual(node.style.explicit_const, 10) - node.style.apply.assert_not_called() - - # Set the value to something new - # A dirty notification is set. - node.style.explicit_const = 20 - self.assertEqual(node.style.explicit_const, 20) - node.style.apply.assert_called_once_with("explicit_const", 20) - - # Clear the applicator mock - node.style.apply.reset_mock() - - # Clear the property - del node.style.explicit_const - self.assertIs(node.style.explicit_const, VALUE1) - node.style.apply.assert_called_once_with("explicit_const", VALUE1) - - # Clear the applicator mock - node.style.apply.reset_mock() - - # Clear the property again. - # The underlying attribute won't exist, so this - # should be a no-op. - del node.style.explicit_const - self.assertIs(node.style.explicit_const, VALUE1) - node.style.apply.assert_not_called() - - def test_property_with_explicit_value(self): - node = ExampleNode() - - # Default value is 0 - self.assertEqual(node.style.explicit_value, 0) - node.style.apply.assert_not_called() - - # Modify the value - node.style.explicit_value = 10 - - self.assertEqual(node.style.explicit_value, 10) - node.style.apply.assert_called_once_with("explicit_value", 10) - - # Clear the applicator mock - node.style.apply.reset_mock() - - # Set the value to the same value. - # No dirty notification is sent - node.style.explicit_value = 10 - self.assertEqual(node.style.explicit_value, 10) - node.style.apply.assert_not_called() - - # Set the value to something new - # A dirty notification is set. - node.style.explicit_value = 20 - self.assertEqual(node.style.explicit_value, 20) - node.style.apply.assert_called_once_with("explicit_value", 20) - - # Clear the applicator mock - node.style.apply.reset_mock() - - # Clear the property - del node.style.explicit_value - self.assertEqual(node.style.explicit_value, 0) - node.style.apply.assert_called_once_with("explicit_value", 0) - - def test_property_with_explicit_none(self): - node = ExampleNode() - - # Default value is None - self.assertIsNone(node.style.explicit_none) - node.style.apply.assert_not_called() - - # Modify the value - node.style.explicit_none = 10 - - self.assertEqual(node.style.explicit_none, 10) - node.style.apply.assert_called_once_with("explicit_none", 10) - - # Clear the applicator mock - node.style.apply.reset_mock() - - # Set the property to the same value. - # No dirty notification is sent - node.style.explicit_none = 10 - self.assertEqual(node.style.explicit_none, 10) - node.style.apply.assert_not_called() - - # Set the property to something new - # A dirty notification is set. - node.style.explicit_none = 20 - self.assertEqual(node.style.explicit_none, 20) - node.style.apply.assert_called_once_with("explicit_none", 20) - - # Clear the applicator mock - node.style.apply.reset_mock() - - # Clear the property - del node.style.explicit_none - self.assertIsNone(node.style.explicit_none) - node.style.apply.assert_called_once_with("explicit_none", None) - - def test_property_with_implicit_default(self): - node = ExampleNode() - - # Default value is None - self.assertIsNone(node.style.implicit) - node.style.apply.assert_not_called() - - # Modify the value - node.style.implicit = 10 - - self.assertEqual(node.style.implicit, 10) - node.style.apply.assert_called_once_with("implicit", 10) - - # Clear the applicator mock - node.style.apply.reset_mock() - - # Set the value to the same value. - # No dirty notification is sent - node.style.implicit = 10 - self.assertEqual(node.style.implicit, 10) - node.style.apply.assert_not_called() - - # Set the value to something new - # A dirty notification is set. - node.style.implicit = 20 - self.assertEqual(node.style.implicit, 20) - node.style.apply.assert_called_once_with("implicit", 20) - - # Clear the applicator mock - node.style.apply.reset_mock() - - # Clear the property - del node.style.implicit - self.assertIsNone(node.style.implicit) - node.style.apply.assert_called_once_with("implicit", None) - - def test_directional_property(self): - node = ExampleNode() - - # Default value is 0 - self.assertEqual(node.style.thing, (0, 0, 0, 0)) - self.assertEqual(node.style.thing_top, 0) - self.assertEqual(node.style.thing_right, 0) - self.assertEqual(node.style.thing_bottom, 0) - self.assertEqual(node.style.thing_left, 0) - node.style.apply.assert_not_called() - - # Set a value in one axis - node.style.thing_top = 10 - - self.assertEqual(node.style.thing, (10, 0, 0, 0)) - self.assertEqual(node.style.thing_top, 10) - self.assertEqual(node.style.thing_right, 0) - self.assertEqual(node.style.thing_bottom, 0) - self.assertEqual(node.style.thing_left, 0) - node.style.apply.assert_called_once_with("thing_top", 10) - - # Clear the applicator mock - node.style.apply.reset_mock() - - # Set a value directly with a single item - node.style.thing = (10,) - - self.assertEqual(node.style.thing, (10, 10, 10, 10)) - self.assertEqual(node.style.thing_top, 10) - self.assertEqual(node.style.thing_right, 10) - self.assertEqual(node.style.thing_bottom, 10) - self.assertEqual(node.style.thing_left, 10) - node.style.apply.assert_has_calls( - [ - call("thing_right", 10), - call("thing_bottom", 10), - call("thing_left", 10), - ] - ) - - # Clear the applicator mock - node.style.apply.reset_mock() - - # Set a value directly with a single item - node.style.thing = 30 - - self.assertEqual(node.style.thing, (30, 30, 30, 30)) - self.assertEqual(node.style.thing_top, 30) - self.assertEqual(node.style.thing_right, 30) - self.assertEqual(node.style.thing_bottom, 30) - self.assertEqual(node.style.thing_left, 30) - node.style.apply.assert_has_calls( - [ - call("thing_top", 30), - call("thing_right", 30), - call("thing_bottom", 30), - call("thing_left", 30), - ] - ) - - # Clear the applicator mock - node.style.apply.reset_mock() - - # Set a value directly with a 2 values - node.style.thing = (10, 20) - - self.assertEqual(node.style.thing, (10, 20, 10, 20)) - self.assertEqual(node.style.thing_top, 10) - self.assertEqual(node.style.thing_right, 20) - self.assertEqual(node.style.thing_bottom, 10) - self.assertEqual(node.style.thing_left, 20) - node.style.apply.assert_has_calls( - [ - call("thing_top", 10), - call("thing_right", 20), - call("thing_bottom", 10), - call("thing_left", 20), - ] - ) - - # Clear the applicator mock - node.style.apply.reset_mock() - - # Set a value directly with a 3 values - node.style.thing = (10, 20, 30) - - self.assertEqual(node.style.thing, (10, 20, 30, 20)) - self.assertEqual(node.style.thing_top, 10) - self.assertEqual(node.style.thing_right, 20) - self.assertEqual(node.style.thing_bottom, 30) - self.assertEqual(node.style.thing_left, 20) - node.style.apply.assert_called_once_with("thing_bottom", 30) - - # Clear the applicator mock - node.style.apply.reset_mock() - - # Set a value directly with a 4 values - node.style.thing = (10, 20, 30, 40) - - self.assertEqual(node.style.thing, (10, 20, 30, 40)) - self.assertEqual(node.style.thing_top, 10) - self.assertEqual(node.style.thing_right, 20) - self.assertEqual(node.style.thing_bottom, 30) - self.assertEqual(node.style.thing_left, 40) - node.style.apply.assert_called_once_with("thing_left", 40) - - # Set a value directly with an invalid number of values - with self.assertRaises(ValueError): - node.style.thing = () - - with self.assertRaises(ValueError): - node.style.thing = (10, 20, 30, 40, 50) - - # Clear the applicator mock - node.style.apply.reset_mock() - - # Clear a value on one axis - del node.style.thing_top - - self.assertEqual(node.style.thing, (0, 20, 30, 40)) - self.assertEqual(node.style.thing_top, 0) - self.assertEqual(node.style.thing_right, 20) - self.assertEqual(node.style.thing_bottom, 30) - self.assertEqual(node.style.thing_left, 40) - node.style.apply.assert_called_once_with("thing_top", 0) - - # Restore the top thing - node.style.thing_top = 10 - - # Clear the applicator mock - node.style.apply.reset_mock() - - # Clear a value directly - del node.style.thing - - self.assertEqual(node.style.thing, (0, 0, 0, 0)) - self.assertEqual(node.style.thing_top, 0) - self.assertEqual(node.style.thing_right, 0) - self.assertEqual(node.style.thing_bottom, 0) - self.assertEqual(node.style.thing_left, 0) - node.style.apply.assert_has_calls( - [ - call("thing_right", 0), - call("thing_bottom", 0), - call("thing_left", 0), - ] - ) - - def test_set_multiple_properties(self): - node = ExampleNode() - - # Set a pair of properties - node.style.update(explicit_value=20, explicit_none=10) - - self.assertIs(node.style.explicit_const, VALUE1) - self.assertEqual(node.style.explicit_none, 10) - self.assertEqual(node.style.explicit_value, 20) - node.style.apply.assert_has_calls( - [ - call("explicit_value", 20), - call("explicit_none", 10), - ], - any_order=True, - ) - - # Set a different pair of properties - node.style.update(explicit_const=VALUE2, explicit_value=30) - - self.assertIs(node.style.explicit_const, VALUE2) - self.assertEqual(node.style.explicit_value, 30) - self.assertEqual(node.style.explicit_none, 10) - node.style.apply.assert_has_calls( - [ - call("explicit_const", VALUE2), - call("explicit_value", 30), - ], - any_order=True, - ) - - # Clear the applicator mock - node.style.apply.reset_mock() - - # Setting a non-property - with self.assertRaises(NameError): - node.style.update(not_a_property=10) - - node.style.apply.assert_not_called() - - def test_str(self): - node = ExampleNode() - - node.style.update( - explicit_const=VALUE2, - explicit_value=20, - thing=(30, 40, 50, 60), - ) - - self.assertEqual( - str(node.style), - "explicit-const: value2; " - "explicit-value: 20; " - "thing-bottom: 50; " - "thing-left: 60; " - "thing-right: 40; " - "thing-top: 30", - ) - - def test_dict(self): - "Style declarations expose a dict-like interface" - node = ExampleNode() - - node.style.update( - explicit_const=VALUE2, - explicit_value=20, - thing=(30, 40, 50, 60), - ) - - self.assertEqual( - node.style.keys(), - { - "explicit_const", - "explicit_value", - "thing_bottom", - "thing_left", - "thing_right", - "thing_top", - }, - ) - self.assertEqual( - sorted(node.style.items()), - sorted( - [ - ("explicit_const", "value2"), - ("explicit_value", 20), - ("thing_bottom", 50), - ("thing_left", 60), - ("thing_right", 40), - ("thing_top", 30), - ] - ), - ) - - # A property can be set, retrieved and cleared using the attribute name - node.style["thing-bottom"] = 10 - self.assertEqual(node.style["thing-bottom"], 10) - del node.style["thing-bottom"] - self.assertEqual(node.style["thing-bottom"], 0) - - # A property can be set, retrieved and cleared using the Python attribute name - node.style["thing_bottom"] = 10 - self.assertEqual(node.style["thing_bottom"], 10) - del node.style["thing_bottom"] - self.assertEqual(node.style["thing_bottom"], 0) - - # Clearing a valid property isn't an error - del node.style["thing_bottom"] - self.assertEqual(node.style["thing_bottom"], 0) - - # Non-existent properties raise KeyError - with self.assertRaises(KeyError): - node.style["no-such-property"] = "no-such-value" - - with self.assertRaises(KeyError): - node.style["no-such-property"] - - with self.assertRaises(KeyError): - del node.style["no-such-property"] + # Some properties with explicit initial values + explicit_const: str | int = validated_property( + choices=VALUE_CHOICES, initial=VALUE1 + ) + explicit_value: str | int = validated_property(choices=VALUE_CHOICES, initial=0) + explicit_none: str | int | None = validated_property( + choices=VALUE_CHOICES, initial=None + ) + + # A property with an implicit default value. + # This usually means the default is platform specific. + implicit: str | int | None = validated_property(choices=DEFAULT_VALUE_CHOICES) + + # A set of directional properties + thing: tuple[str | int] | str | int = directional_property("thing{}") + thing_top: str | int = validated_property(choices=VALUE_CHOICES, initial=0) + thing_right: str | int = validated_property(choices=VALUE_CHOICES, initial=0) + thing_bottom: str | int = validated_property(choices=VALUE_CHOICES, initial=0) + thing_left: str | int = validated_property(choices=VALUE_CHOICES, initial=0) + + +with catch_warnings(): + filterwarnings("ignore", category=DeprecationWarning) + + @mock_apply + class DeprecatedStyle(BaseStyle): + pass + + # Some properties with explicit initial values + DeprecatedStyle.validated_property( + "explicit_const", choices=VALUE_CHOICES, initial=VALUE1 + ) + DeprecatedStyle.validated_property( + "explicit_value", choices=VALUE_CHOICES, initial=0 + ) + DeprecatedStyle.validated_property( + "explicit_none", choices=VALUE_CHOICES, initial=None + ) + + # A property with an implicit default value. + # This usually means the default is platform specific. + DeprecatedStyle.validated_property("implicit", choices=DEFAULT_VALUE_CHOICES) + + # A set of directional properties + DeprecatedStyle.validated_property("thing_top", choices=VALUE_CHOICES, initial=0) + DeprecatedStyle.validated_property("thing_right", choices=VALUE_CHOICES, initial=0) + DeprecatedStyle.validated_property("thing_bottom", choices=VALUE_CHOICES, initial=0) + DeprecatedStyle.validated_property("thing_left", choices=VALUE_CHOICES, initial=0) + DeprecatedStyle.directional_property("thing%s") + + +def test_invalid_style(): + with pytest.raises(ValueError): + # Define an invalid initial value on a validated property + validated_property(choices=VALUE_CHOICES, initial="something") + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_positional_argument(StyleClass): + # Could be the subclass or inherited __init__, depending on Python version / API + # used. + with pytest.raises( + TypeError, match=r"__init__\(\) takes 1 positional argument but 2 were given" + ): + StyleClass(5) + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_create_and_copy(StyleClass): + style = StyleClass(explicit_const=VALUE2, implicit=VALUE3) + + dup = style.copy() + assert dup.explicit_const == VALUE2 + assert dup.explicit_value == 0 + assert dup.implicit == VALUE3 + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_reapply(StyleClass): + style = StyleClass(explicit_const=VALUE2, implicit=VALUE3) + + style.reapply() + style.apply.assert_has_calls( + [ + call("explicit_const", VALUE2), + call("explicit_value", 0), + call("explicit_none", None), + call("implicit", VALUE3), + call("thing_left", 0), + call("thing_top", 0), + call("thing_right", 0), + call("thing_bottom", 0), + ], + any_order=True, + ) + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_property_with_explicit_const(StyleClass): + style = StyleClass() + + # Default value is VALUE1 + assert style.explicit_const is VALUE1 + style.apply.assert_not_called() + + # Modify the value + style.explicit_const = 10 + + assert style.explicit_const == 10 + style.apply.assert_called_once_with("explicit_const", 10) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set the value to the same value. + # No dirty notification is sent + style.explicit_const = 10 + assert style.explicit_const == 10 + style.apply.assert_not_called() + + # Set the value to something new + # A dirty notification is set. + style.explicit_const = 20 + assert style.explicit_const == 20 + style.apply.assert_called_once_with("explicit_const", 20) + + # Clear the applicator mock + style.apply.reset_mock() + + # Clear the property + del style.explicit_const + assert style.explicit_const is VALUE1 + style.apply.assert_called_once_with("explicit_const", VALUE1) + + # Clear the applicator mock + style.apply.reset_mock() + + # Clear the property again. + # The underlying attribute won't exist, so this + # should be a no-op. + del style.explicit_const + assert style.explicit_const is VALUE1 + style.apply.assert_not_called() + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_property_with_explicit_value(StyleClass): + style = StyleClass() + + # Default value is 0 + assert style.explicit_value == 0 + style.apply.assert_not_called() + + # Modify the value + style.explicit_value = 10 + + assert style.explicit_value == 10 + style.apply.assert_called_once_with("explicit_value", 10) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set the value to the same value. + # No dirty notification is sent + style.explicit_value = 10 + assert style.explicit_value == 10 + style.apply.assert_not_called() + + # Set the value to something new + # A dirty notification is set. + style.explicit_value = 20 + assert style.explicit_value == 20 + style.apply.assert_called_once_with("explicit_value", 20) + + # Clear the applicator mock + style.apply.reset_mock() + + # Clear the property + del style.explicit_value + assert style.explicit_value == 0 + style.apply.assert_called_once_with("explicit_value", 0) + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_property_with_explicit_none(StyleClass): + style = StyleClass() + + # Default value is None + assert style.explicit_none is None + style.apply.assert_not_called() + + # Modify the value + style.explicit_none = 10 + + assert style.explicit_none == 10 + style.apply.assert_called_once_with("explicit_none", 10) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set the property to the same value. + # No dirty notification is sent + style.explicit_none = 10 + assert style.explicit_none == 10 + style.apply.assert_not_called() + + # Set the property to something new + # A dirty notification is set. + style.explicit_none = 20 + assert style.explicit_none == 20 + style.apply.assert_called_once_with("explicit_none", 20) + + # Clear the applicator mock + style.apply.reset_mock() + + # Clear the property + del style.explicit_none + assert style.explicit_none is None + style.apply.assert_called_once_with("explicit_none", None) + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_property_with_implicit_default(StyleClass): + style = StyleClass() + + # Default value is None + assert style.implicit is None + style.apply.assert_not_called() + + # Modify the value + style.implicit = 10 + + assert style.implicit == 10 + style.apply.assert_called_once_with("implicit", 10) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set the value to the same value. + # No dirty notification is sent + style.implicit = 10 + assert style.implicit == 10 + style.apply.assert_not_called() + + # Set the value to something new + # A dirty notification is set. + style.implicit = 20 + assert style.implicit == 20 + style.apply.assert_called_once_with("implicit", 20) + + # Clear the applicator mock + style.apply.reset_mock() + + # Clear the property + del style.implicit + assert style.implicit is None + style.apply.assert_called_once_with("implicit", None) + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_directional_property(StyleClass): + style = StyleClass() + + # Default value is 0 + assert style.thing == (0, 0, 0, 0) + assert style.thing_top == 0 + assert style.thing_right == 0 + assert style.thing_bottom == 0 + assert style.thing_left == 0 + style.apply.assert_not_called() + + # Set a value in one axis + style.thing_top = 10 + + assert style.thing == (10, 0, 0, 0) + assert style.thing_top == 10 + assert style.thing_right == 0 + assert style.thing_bottom == 0 + assert style.thing_left == 0 + style.apply.assert_called_once_with("thing_top", 10) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set a value directly with a single item + style.thing = (10,) + + assert style.thing == (10, 10, 10, 10) + assert style.thing_top == 10 + assert style.thing_right == 10 + assert style.thing_bottom == 10 + assert style.thing_left == 10 + style.apply.assert_has_calls( + [ + call("thing_right", 10), + call("thing_bottom", 10), + call("thing_left", 10), + ] + ) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set a value directly with a single item + style.thing = 30 + + assert style.thing == (30, 30, 30, 30) + assert style.thing_top == 30 + assert style.thing_right == 30 + assert style.thing_bottom == 30 + assert style.thing_left == 30 + style.apply.assert_has_calls( + [ + call("thing_top", 30), + call("thing_right", 30), + call("thing_bottom", 30), + call("thing_left", 30), + ] + ) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set a value directly with a 2 values + style.thing = (10, 20) + + assert style.thing == (10, 20, 10, 20) + assert style.thing_top == 10 + assert style.thing_right == 20 + assert style.thing_bottom == 10 + assert style.thing_left == 20 + style.apply.assert_has_calls( + [ + call("thing_top", 10), + call("thing_right", 20), + call("thing_bottom", 10), + call("thing_left", 20), + ] + ) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set a value directly with a 3 values + style.thing = (10, 20, 30) + + assert style.thing == (10, 20, 30, 20) + assert style.thing_top == 10 + assert style.thing_right == 20 + assert style.thing_bottom == 30 + assert style.thing_left == 20 + style.apply.assert_called_once_with("thing_bottom", 30) + + # Clear the applicator mock + style.apply.reset_mock() + + # Set a value directly with a 4 values + style.thing = (10, 20, 30, 40) + + assert style.thing == (10, 20, 30, 40) + assert style.thing_top == 10 + assert style.thing_right == 20 + assert style.thing_bottom == 30 + assert style.thing_left == 40 + style.apply.assert_called_once_with("thing_left", 40) + + # Set a value directly with an invalid number of values + with pytest.raises(ValueError): + style.thing = () + + with pytest.raises(ValueError): + style.thing = (10, 20, 30, 40, 50) + + # Clear the applicator mock + style.apply.reset_mock() + + # Clear a value on one axis + del style.thing_top + + assert style.thing == (0, 20, 30, 40) + assert style.thing_top == 0 + assert style.thing_right == 20 + assert style.thing_bottom == 30 + assert style.thing_left == 40 + style.apply.assert_called_once_with("thing_top", 0) + + # Restore the top thing + style.thing_top = 10 + + # Clear the applicator mock + style.apply.reset_mock() + + # Clear a value directly + del style.thing + + assert style.thing == (0, 0, 0, 0) + assert style.thing_top == 0 + assert style.thing_right == 0 + assert style.thing_bottom == 0 + assert style.thing_left == 0 + style.apply.assert_has_calls( + [ + call("thing_right", 0), + call("thing_bottom", 0), + call("thing_left", 0), + ] + ) + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_set_multiple_properties(StyleClass): + style = StyleClass() + + # Set a pair of properties + style.update(explicit_value=20, explicit_none=10) + + assert style.explicit_const is VALUE1 + assert style.explicit_none == 10 + assert style.explicit_value == 20 + style.apply.assert_has_calls( + [ + call("explicit_value", 20), + call("explicit_none", 10), + ], + any_order=True, + ) + + # Set a different pair of properties + style.update(explicit_const=VALUE2, explicit_value=30) + + assert style.explicit_const is VALUE2 + assert style.explicit_value == 30 + assert style.explicit_none == 10 + style.apply.assert_has_calls( + [ + call("explicit_const", VALUE2), + call("explicit_value", 30), + ], + any_order=True, + ) + + # Clear the applicator mock + style.apply.reset_mock() + + # Setting a non-property + with pytest.raises(NameError): + style.update(not_a_property=10) + + style.apply.assert_not_called() + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_str(StyleClass): + style = StyleClass() + + style.update( + explicit_const=VALUE2, + explicit_value=20, + thing=(30, 40, 50, 60), + ) + + assert ( + str(style) == "explicit-const: value2; " + "explicit-value: 20; " + "thing-bottom: 50; " + "thing-left: 60; " + "thing-right: 40; " + "thing-top: 30" + ) + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +def test_dict(StyleClass): + "Style declarations expose a dict-like interface" + style = StyleClass() + + style.update( + explicit_const=VALUE2, + explicit_value=20, + thing=(30, 40, 50, 60), + ) + + assert style.keys() == { + "explicit_const", + "explicit_value", + "thing_bottom", + "thing_left", + "thing_right", + "thing_top", + } + assert sorted(style.items()) == sorted( + [ + ("explicit_const", "value2"), + ("explicit_value", 20), + ("thing_bottom", 50), + ("thing_left", 60), + ("thing_right", 40), + ("thing_top", 30), + ] + ) + + # A property can be set, retrieved and cleared using the attribute name + style["thing-bottom"] = 10 + assert style["thing-bottom"] == 10 + del style["thing-bottom"] + assert style["thing-bottom"] == 0 + + # A property can be set, retrieved and cleared using the Python attribute name + style["thing_bottom"] = 10 + assert style["thing_bottom"] == 10 + del style["thing_bottom"] + assert style["thing_bottom"] == 0 + + # Clearing a valid property isn't an error + del style["thing_bottom"] + assert style["thing_bottom"] == 0 + + # Non-existent properties raise KeyError + with pytest.raises(KeyError): + style["no-such-property"] = "no-such-value" + + with pytest.raises(KeyError): + style["no-such-property"] + + with pytest.raises(KeyError): + del style["no-such-property"] + + +def test_deprecated_class_methods(): + class OldStyle(BaseStyle): + pass + + with pytest.warns(DeprecationWarning): + OldStyle.validated_property("implicit", choices=DEFAULT_VALUE_CHOICES) + + with pytest.warns(DeprecationWarning): + OldStyle.directional_property("thing%s")