diff --git a/.gitignore b/.gitignore index c16d3d4..4faa3d5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,14 +2,25 @@ *~ .*.sw[op] *.egg-info +*.dist-info +.eggs +.coverage +.coverage.* +coverage.xml dist build +logs _build -local distribute-* -.coverage +docs/env +local +*venv* +.idea +Pipfile* .tox +.DS_Store +pip-selfcheck.json +pyvenv.cfg .vscode -venv* -.idea +.envrc pip-wheel-metadata diff --git a/changes/143.feature.rst b/changes/143.feature.rst new file mode 100644 index 0000000..234ac89 --- /dev/null +++ b/changes/143.feature.rst @@ -0,0 +1 @@ +BaseStyle now supports |, |=, and 'in' operators. diff --git a/src/travertino/declaration.py b/src/travertino/declaration.py index cec23be..6b02965 100644 --- a/src/travertino/declaration.py +++ b/src/travertino/declaration.py @@ -1,4 +1,5 @@ from collections import defaultdict +from typing import Mapping from warnings import filterwarnings, warn from .colors import color @@ -142,6 +143,9 @@ def __delete__(self, obj): else: obj.apply(self.name, self.initial) + def is_set_on(self, obj): + return hasattr(obj, f"_{self.name}") + class directional_property: DIRECTIONS = [TOP, RIGHT, BOTTOM, LEFT] @@ -199,6 +203,11 @@ def __delete__(self, obj): for direction in self.DIRECTIONS: del obj[self.format(direction)] + def is_set_on(self, obj): + return any( + hasattr(obj, self.format(direction)) for direction in self.DIRECTIONS + ) + class BaseStyle: """A base class for style declarations. @@ -282,6 +291,13 @@ def __delitem__(self, name): else: raise KeyError(name) + def keys(self): + return { + name + for name in self._PROPERTIES[self.__class__] + if hasattr(self, f"_{name}") + } + def items(self): return [ (name, value) @@ -289,12 +305,43 @@ def items(self): if (value := getattr(self, f"_{name}", None)) is not None ] - def keys(self): - return { + def __len__(self): + return sum( + 1 for name in self._PROPERTIES[self.__class__] if hasattr(self, f"_{name}") + ) + + def __contains__(self, name): + return name in self._ALL_PROPERTIES[self.__class__] and ( + getattr(self.__class__, name).is_set_on(self) + ) + + def __iter__(self): + yield from ( name for name in self._PROPERTIES[self.__class__] if hasattr(self, f"_{name}") - } + ) + + def __or__(self, other): + if isinstance(other, BaseStyle): + if self.__class__ is not other.__class__: + return NotImplemented + elif not isinstance(other, Mapping): + return NotImplemented + + result = self.copy() + result.update(**other) + return result + + def __ior__(self, other): + if isinstance(other, BaseStyle): + if self.__class__ is not other.__class__: + return NotImplemented + elif not isinstance(other, Mapping): + return NotImplemented + + self.update(**other) + return self ###################################################################### # Get the rendered form of the style declaration diff --git a/tests/test_declaration.py b/tests/test_declaration.py index 7a1da8e..0d76ec6 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -73,6 +73,18 @@ class DeprecatedStyle(BaseStyle): DeprecatedStyle.directional_property("thing%s") +class StyleSubclass(Style): + pass + + +class DeprecatedStyleSubclass(DeprecatedStyle): + pass + + +class Sibling(BaseStyle): + pass + + def test_invalid_style(): with pytest.raises(ValueError): # Define an invalid initial value on a validated property @@ -504,7 +516,7 @@ def test_dict(StyleClass): thing=(30, 40, 50, 60), ) - assert style.keys() == { + expected_keys = { "explicit_const", "explicit_value", "thing_bottom", @@ -512,6 +524,9 @@ def test_dict(StyleClass): "thing_right", "thing_top", } + + assert style.keys() == expected_keys + assert sorted(style.items()) == sorted( [ ("explicit_const", "value2"), @@ -523,6 +538,20 @@ def test_dict(StyleClass): ] ) + # Properties that are set are in the keys. + for name in expected_keys: + assert name in style + + # Directional properties with one or more of the aliased properties set also count. + assert "thing" in style + + # Valid properties that haven't been set are not in the keys. + assert "implicit" not in style + assert "explicit_none" not in style + + # Neither are invalid properties. + assert "invalid_property" not in style + # A property can be set, retrieved and cleared using the attribute name style["thing-bottom"] = 10 assert style["thing-bottom"] == 10 @@ -550,6 +579,89 @@ def test_dict(StyleClass): del style["no-such-property"] +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +@pytest.mark.parametrize("instantiate", [True, False]) +def test_union_operators(StyleClass, instantiate): + """Styles support | and |= with dicts and with their own class.""" + left = StyleClass(explicit_value=VALUE1, implicit=VALUE2) + + style_dict = {"thing_top": 5, "implicit": VALUE3} + right = StyleClass(**style_dict) if instantiate else style_dict + + # Standard operator + result = left | right + + # Original objects unchanged + assert left["explicit_value"] == VALUE1 + assert left["implicit"] == VALUE2 + + assert right["thing_top"] == 5 + assert right["implicit"] == VALUE3 + + # Unshared properties assigned + assert result["explicit_const"] == VALUE1 + assert result["thing_top"] == 5 + + # Common property overridden by second operand + assert result["implicit"] == VALUE3 + + # In-place version + left |= right + + # Common property updated on lefthand + assert left["explicit_value"] == VALUE1 + assert left["implicit"] == VALUE3 + + # Righthand unchanged + assert right["thing_top"] == 5 + assert right["implicit"] == VALUE3 + + +@pytest.mark.parametrize( + "StyleClass, OtherClass", + [ + (Style, StyleSubclass), + (Style, Sibling), + (Style, int), + (Style, list), + (DeprecatedStyle, DeprecatedStyleSubclass), + (DeprecatedStyle, Sibling), + (DeprecatedStyle, int), + (DeprecatedStyle, list), + ], +) +def test_union_operators_invalid_type(StyleClass, OtherClass): + """Styles do not support | or |= with other style classes or with non-mappings.""" + + left = StyleClass() + right = OtherClass() + + with pytest.raises(TypeError, match=r"unsupported operand type"): + left | right + + with pytest.raises(TypeError, match=r"unsupported operand type"): + left |= right + + +@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) +@pytest.mark.parametrize( + "right, error", + [ + ({"implicit": "bogus_value"}, ValueError), + ({"bogus_key": 3.12}, NameError), + ], +) +def test_union_operators_invalid_key_value(StyleClass, right, error): + """Operators will accept any mapping, but invalid keys/values are still an error.""" + left = StyleClass() + + with pytest.raises(error): + left | right + + with pytest.raises(error): + left |= right + + def test_deprecated_class_methods(): class OldStyle(BaseStyle): pass