From eb9967aca445b1ef7ca434247700f0f36c22ac77 Mon Sep 17 00:00:00 2001 From: Charles Whittington Date: Tue, 15 Oct 2024 06:45:17 -0400 Subject: [PATCH 1/7] Added composite property (and some base style / ImmutableString cleanups) --- src/travertino/declaration.py | 158 ++++++++++++++++++++++++++-------- tests/test_declaration.py | 89 ++++++++++++++++++- tox.ini | 2 + 3 files changed, 214 insertions(+), 35 deletions(-) diff --git a/src/travertino/declaration.py b/src/travertino/declaration.py index c5b4642..96b5f3b 100644 --- a/src/travertino/declaration.py +++ b/src/travertino/declaration.py @@ -1,5 +1,5 @@ from collections import defaultdict -from typing import Mapping, Sequence +from collections.abc import Mapping, Sequence from warnings import filterwarnings, warn from .colors import color @@ -31,6 +31,18 @@ def __str__(self): def __repr__(self): return repr(self._data) + def __reversed__(self): + return reversed(self._data) + + def index(self, value): + return self._data.index(value) + + def count(self, value): + return self._data.count(value) + + +Sequence.register(ImmutableList) + class Choices: "A class to define allowable data types for a property" @@ -108,19 +120,7 @@ def __init__(self, choices, initial=None): :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 = self.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}" - ) + self.initial = None if initial is None else self.validate(initial) def __set_name__(self, owner, name): self.name = name @@ -209,7 +209,22 @@ def validate(self, value): return ImmutableList(result) -class directional_property: +class property_alias: + """A base class for list / composite properties. Not designed to be instnatiated.""" + + def __set_name__(self, owner, name): + self.name = name + owner._BASE_ALL_PROPERTIES[owner].add(self.name) + + def __delete__(self, obj): + for name in self.properties: + del obj[name] + + def is_set_on(self, obj): + return any(hasattr(obj, name) for name in self.properties) + + +class directional_property(property_alias): DIRECTIONS = [TOP, RIGHT, BOTTOM, LEFT] ASSIGNMENT_SCHEMES = { # T R B L @@ -226,10 +241,7 @@ def __init__(self, name_format): be replaced with "_top", etc. """ self.name_format = name_format - - def __set_name__(self, owner, name): - self.name = name - owner._BASE_ALL_PROPERTIES[owner].add(self.name) + self.properties = [self.format(direction) for direction in self.DIRECTIONS] def format(self, direction): return self.name_format.format(f"_{direction}") @@ -238,7 +250,7 @@ def __get__(self, obj, objtype=None): if obj is None: return self - return tuple(obj[self.format(direction)] for direction in self.DIRECTIONS) + return tuple(obj[name] for name in self.properties) def __set__(self, obj, value): if value is self: @@ -250,22 +262,98 @@ def __set__(self, obj, value): value = (value,) if order := self.ASSIGNMENT_SCHEMES.get(len(value)): - for direction, index in zip(self.DIRECTIONS, order): - obj[self.format(direction)] = value[index] + for name, index in zip(self.properties, order): + obj[name] = 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)] - def is_set_on(self, obj): - return any( - hasattr(obj, self.format(direction)) for direction in self.DIRECTIONS +NOT_PROVIDED = object() + + +class composite_property(property_alias): + def __init__(self, optional, required, reset_value=NOT_PROVIDED): + """Define a property attribute that proxies for an arbitrary set of properties. + + :param optional: The names of aliased properties that are optional in + assignment. Assigning `reset_value` unsets such a property. Order is + irrelevant, unless the same (non-resetting) value is valid for more than one + property, in which case it's assigned to the first one available. + :param required: Which properties, if any, are required when setting this + property. In assignment, these must be specified last and in order. + :param reset_value: Value to provide to reset optional values to their defaults. + """ + self.optional = optional + self.required = required + self.properties = self.optional + self.required + self.reset_value = reset_value + self.min_num = len(self.required) + self.max_num = len(self.required) + len(self.optional) + + def __get__(self, obj, objtype=None): + if obj is None: + return self + + return tuple(obj[name] for name in self.optional if name in obj) + tuple( + obj[name] for name in self.required ) + def __set__(self, obj, value): + if value is self: + # This happens during autogenerated dataclass __init__ when no value is + # supplied. + return + + if not self.min_num <= len(value) <= self.max_num: + raise TypeError( + f"Composite property {self.name} must be set with at least " + f"{self.min_num} and no more than {self.max_num} values." + ) + + # Don't clear and set values until we're sure everything validates. + staged = {} + + # Handle the required values first. They have to be there, and in order, or the + # whole assignment is invalid. + required_vals = value[-len(self.required) :] + for name, val in zip(self.required, required_vals): + # Let error propagate if it raises. + staged[name] = getattr(obj.__class__, name).validate(val) + + # Next, look through the optional values. For each that isn't NORMAL, assign it + # to the first property that a) hasn't already had a value staged, and b) + # validates this value. (No need to handle NORMAL, since everything not + # specified will be unset anyway.) + optional_vals = value[: -len(self.required)] + for val in optional_vals: + if val == self.reset_value: + continue + + for name in self.optional: + if name in staged: + continue + + try: + staged[name] = getattr(obj.__class__, name).validate(val) + break + except ValueError: + pass + # no break + else: + # We got to the end and nothing (that wasn't already set) validated this + # item. + raise ValueError( + f"Invalid assignment for composite property {self.name}: {value}" + ) + + # Update to staged properties, and delete unstaged ones. + for name in self.optional: + if name not in staged: + del obj[name] + obj |= staged + class BaseStyle: """A base class for style declarations. @@ -276,7 +364,9 @@ class BaseStyle: to still get the keyword-only behavior from the included __init__. """ + # Contains only "actual" properties _BASE_PROPERTIES = defaultdict(set) + # Also includes property aliases _BASE_ALL_PROPERTIES = defaultdict(set) def __init_subclass__(cls): @@ -284,7 +374,7 @@ def __init_subclass__(cls): cls._PROPERTIES = cls._BASE_PROPERTIES[cls] cls._ALL_PROPERTIES = cls._BASE_ALL_PROPERTIES[cls] - # Fallback in case subclass isn't decorated as subclass (probably from using + # Fallback in case subclass isn't decorated as dataclass (probably from using # previous API) or for pre-3.10, before kw_only argument existed. def __init__(self, **style): self.update(**style) @@ -315,7 +405,7 @@ def reapply(self): self.apply(name, self[name]) def update(self, **styles): - "Set multiple styles on the style definition." + """Set multiple styles on the style definition.""" for name, value in styles.items(): name = name.replace("-", "_") if name not in self._ALL_PROPERTIES: @@ -324,7 +414,7 @@ def update(self, **styles): self[name] = value def copy(self, applicator=None): - "Create a duplicate of this style declaration." + """Create a duplicate of this style declaration.""" dup = self.__class__() dup._applicator = applicator dup.update(**self) @@ -351,13 +441,13 @@ def __delitem__(self, name): raise KeyError(name) def keys(self): - return {name for name in self._PROPERTIES if name in self} + return {name for name in self} def items(self): - return [(name, self[name]) for name in self._PROPERTIES if name in self] + return [(name, self[name]) for name in self] def __len__(self): - return sum(1 for name in self._PROPERTIES if name in self) + return sum(1 for _ in self) def __contains__(self, name): return name in self._ALL_PROPERTIES and ( diff --git a/tests/test_declaration.py b/tests/test_declaration.py index 62f4254..30a4eca 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -1,15 +1,18 @@ from __future__ import annotations +from collections.abc import Sequence 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.constants import NORMAL from travertino.declaration import ( BaseStyle, Choices, ImmutableList, + composite_property, directional_property, list_property, validated_property, @@ -18,8 +21,11 @@ VALUE1 = "value1" VALUE2 = "value2" VALUE3 = "value3" +VALUE4 = "value4" +VALUE5 = "value5" VALUE_CHOICES = Choices(VALUE1, VALUE2, VALUE3, None, integer=True) DEFAULT_VALUE_CHOICES = Choices(VALUE1, VALUE2, VALUE3, integer=True) +OTHER_CHOICES = Choices(VALUE4, VALUE5) @prep_style_class @@ -45,7 +51,14 @@ class Style(BaseStyle): thing_left: str | int = validated_property(choices=VALUE_CHOICES, initial=0) # Doesn't need to be tested in deprecated API: - list_prop: list[str] = list_property(choices=VALUE_CHOICES, initial=(VALUE2,)) + list_prop: list[str] = list_property(choices=VALUE_CHOICES, initial=[VALUE2]) + # Same for composite property. + optional_prop = validated_property(choices=OTHER_CHOICES) + composite_prop: list = composite_property( + optional=["implicit", "optional_prop"], + required=["explicit_const", "list_prop"], + reset_value=NORMAL, + ) with catch_warnings(): @@ -573,6 +586,80 @@ def test_list_property_list_like(): count += 1 assert count == 4 + assert [*reversed(prop)] == [VALUE2, 3, 2, 1] + + assert prop.index(3) == 2 + + assert prop.count(VALUE2) == 1 + + assert isinstance(prop, Sequence) + + +def test_composite_property(): + style = Style() + + # Initial values + assert style.composite_prop == (VALUE1, [VALUE2]) + assert "implicit" not in style + assert "optional_prop" not in style + assert style.explicit_const == VALUE1 + assert style.list_prop == [VALUE2] + + # Set all the properties. + style.composite_prop = (VALUE1, VALUE4, VALUE2, [VALUE1, VALUE3]) + + assert style.composite_prop == (VALUE1, VALUE4, VALUE2, [VALUE1, VALUE3]) + assert style.implicit == VALUE1 + assert style.optional_prop == VALUE4 + assert style.explicit_const == VALUE2 + assert style.list_prop == [VALUE1, VALUE3] + + # Set just the required properties. Should unset optionals. + style.composite_prop = (VALUE3, [VALUE2]) + + assert style.composite_prop == (VALUE3, [VALUE2]) + assert "implicit" not in style + assert "optional_prop" not in style + assert style.explicit_const == VALUE3 + assert style.list_prop == [VALUE2] + + # Set all properties, with optionals out of order. + style.composite_prop = (VALUE4, VALUE1, VALUE2, [VALUE1, VALUE3]) + + assert style.composite_prop == (VALUE1, VALUE4, VALUE2, [VALUE1, VALUE3]) + assert style.implicit == VALUE1 + assert style.optional_prop == VALUE4 + assert style.explicit_const == VALUE2 + assert style.list_prop == [VALUE1, VALUE3] + + # Verify that a string passed to the list property is put into a list. + style.composite_prop = (VALUE2, VALUE1) + + assert style.composite_prop == (VALUE2, [VALUE1]) + assert style.list_prop == [VALUE1] + + +@pytest.mark.parametrize( + "values, error", + [ + # Too few values + ([], TypeError), + ([VALUE3], TypeError), + # Too many values + ([VALUE4, VALUE1, VALUE1, VALUE2, [VALUE1]], TypeError), + # Value not valid for any optional property + (["bogus", VALUE2, [VALUE3]], ValueError), + # Repeated value (VALUE4) that's only valid for one optional property + ([VALUE4, VALUE4, VALUE2, [VALUE3]], ValueError), + # Invalid property for a required property + ([VALUE4, [VALUE3]], ValueError), + ], +) +def test_composite_property_invalid(values, error): + style = Style() + with pytest.raises(error): + style.composite_prop = values + @pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) def test_set_multiple_properties(StyleClass): diff --git a/tox.ini b/tox.ini index 0c9d9c8..7dbbfa7 100644 --- a/tox.ini +++ b/tox.ini @@ -12,6 +12,8 @@ max-line-length = 119 ignore = # line break occurred before a binary operator W503, + # whitespace before ':' (not PEP 8 compliant, recommended disabled by Black) + E203, [tox] envlist = towncrier-check,pre-commit,py{38,39,310,311,312,313} From 08665d4beb2f54f9dde6b2033548ae3d29bb2548 Mon Sep 17 00:00:00 2001 From: Charles Whittington Date: Tue, 15 Oct 2024 07:24:57 -0400 Subject: [PATCH 2/7] Added changenote --- changes/222.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/222.feature.rst diff --git a/changes/222.feature.rst b/changes/222.feature.rst new file mode 100644 index 0000000..3fac1a9 --- /dev/null +++ b/changes/222.feature.rst @@ -0,0 +1 @@ +Added a ``composite_property`` for aliasing an arbitrary set of other properties. From a083e923eb9c79cfc9eeae2f667bbacad3132e36 Mon Sep 17 00:00:00 2001 From: Charles Whittington Date: Tue, 15 Oct 2024 07:27:56 -0400 Subject: [PATCH 3/7] Typo fix --- src/travertino/declaration.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/travertino/declaration.py b/src/travertino/declaration.py index 96b5f3b..2910507 100644 --- a/src/travertino/declaration.py +++ b/src/travertino/declaration.py @@ -210,7 +210,7 @@ def validate(self, value): class property_alias: - """A base class for list / composite properties. Not designed to be instnatiated.""" + """A base class for list / composite properties. Not designed to be instantiated.""" def __set_name__(self, owner, name): self.name = name @@ -322,9 +322,9 @@ def __set__(self, obj, value): # Let error propagate if it raises. staged[name] = getattr(obj.__class__, name).validate(val) - # Next, look through the optional values. For each that isn't NORMAL, assign it - # to the first property that a) hasn't already had a value staged, and b) - # validates this value. (No need to handle NORMAL, since everything not + # Next, look through the optional values. For each that isn't resetting, assign + # it to the first property that a) hasn't already had a value staged, and b) + # validates this value. (No need to handle resets, since everything not # specified will be unset anyway.) optional_vals = value[: -len(self.required)] for val in optional_vals: From f8db290bddd31a66d3baa10ddc9315a277be6179 Mon Sep 17 00:00:00 2001 From: Charles Whittington Date: Tue, 15 Oct 2024 07:45:17 -0400 Subject: [PATCH 4/7] Add test for normal, in and out of order --- tests/test_declaration.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_declaration.py b/tests/test_declaration.py index 30a4eca..ee8a78f 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -632,6 +632,22 @@ def test_composite_property(): assert style.explicit_const == VALUE2 assert style.list_prop == [VALUE1, VALUE3] + # Optionals can be reset with reset value (NORMAL). + style.composite_prop = (VALUE2, NORMAL, VALUE2, [VALUE1]) + assert style.composite_prop == (VALUE2, VALUE2, [VALUE1]) + assert style.implicit == VALUE2 + assert "optional_prop" not in style + assert style.explicit_const == VALUE2 + assert style.list_prop == [VALUE1] + + # Reset value can be in any order. + style.composite_prop = (VALUE4, NORMAL, VALUE2, [VALUE1]) + assert style.composite_prop == (VALUE4, VALUE2, [VALUE1]) + assert "implicit" not in style + assert style.optional_prop == VALUE4 + assert style.explicit_const == VALUE2 + assert style.list_prop == [VALUE1] + # Verify that a string passed to the list property is put into a list. style.composite_prop = (VALUE2, VALUE1) From 0b663793ef5ad4fad5b8e191e8bd118cce46e627 Mon Sep 17 00:00:00 2001 From: Charles Whittington Date: Wed, 16 Oct 2024 16:43:38 -0400 Subject: [PATCH 5/7] removed reset param, and reworked assignment logic --- src/travertino/declaration.py | 66 ++++++++------ tests/test_declaration.py | 162 +++++++++++++++++----------------- 2 files changed, 118 insertions(+), 110 deletions(-) diff --git a/src/travertino/declaration.py b/src/travertino/declaration.py index 2910507..d505358 100644 --- a/src/travertino/declaration.py +++ b/src/travertino/declaration.py @@ -270,11 +270,8 @@ def __set__(self, obj, value): ) -NOT_PROVIDED = object() - - class composite_property(property_alias): - def __init__(self, optional, required, reset_value=NOT_PROVIDED): + def __init__(self, optional, required, parse_str=str.split): """Define a property attribute that proxies for an arbitrary set of properties. :param optional: The names of aliased properties that are optional in @@ -283,14 +280,14 @@ def __init__(self, optional, required, reset_value=NOT_PROVIDED): property, in which case it's assigned to the first one available. :param required: Which properties, if any, are required when setting this property. In assignment, these must be specified last and in order. - :param reset_value: Value to provide to reset optional values to their defaults. + :param parse_str: A callable with which to parse a string into valid input. """ self.optional = optional self.required = required self.properties = self.optional + self.required - self.reset_value = reset_value self.min_num = len(self.required) self.max_num = len(self.required) + len(self.optional) + self.parse_str = parse_str def __get__(self, obj, objtype=None): if obj is None: @@ -306,6 +303,9 @@ def __set__(self, obj, value): # supplied. return + if isinstance(value, str): + value = self.parse_str(value) + if not self.min_num <= len(value) <= self.max_num: raise TypeError( f"Composite property {self.name} must be set with at least " @@ -317,39 +317,49 @@ def __set__(self, obj, value): # Handle the required values first. They have to be there, and in order, or the # whole assignment is invalid. - required_vals = value[-len(self.required) :] - for name, val in zip(self.required, required_vals): + required_values = value[-len(self.required) :] + for name, value in zip(self.required, required_values): # Let error propagate if it raises. - staged[name] = getattr(obj.__class__, name).validate(val) + staged[name] = getattr(obj.__class__, name).validate(value) - # Next, look through the optional values. For each that isn't resetting, assign - # it to the first property that a) hasn't already had a value staged, and b) - # validates this value. (No need to handle resets, since everything not - # specified will be unset anyway.) - optional_vals = value[: -len(self.required)] - for val in optional_vals: - if val == self.reset_value: - continue + # Next, look through the optional values. First, for each value, determine which + # properties can accept it. Then assign the values in order of specificity. + # (Values of equal specificity are simply assigned to properties in order.) + optional_values = value[: -len(self.required)] + values_and_valid_props = [] + for value in optional_values: + valid_props = [] for name in self.optional: - if name in staged: - continue - try: - staged[name] = getattr(obj.__class__, name).validate(val) - break + getattr(obj.__class__, name).validate(value) + valid_props.append(name) except ValueError: pass - # no break + if not valid_props: + raise ValueError( + f"Value {value} not valid for any optional properties of composite " + f"property {self.name}" + ) + + values_and_valid_props.append((value, valid_props)) + + for value, valid_props in sorted( + values_and_valid_props, key=lambda x: len(x[1]) + ): + for name in valid_props: + if name not in staged: + staged[name] = value + break else: - # We got to the end and nothing (that wasn't already set) validated this - # item. + # No valid property is still free. raise ValueError( - f"Invalid assignment for composite property {self.name}: {value}" + f"Value {value} not valid for any optional properties of composite " + f"property {self.name} that are not already being assigned." ) - # Update to staged properties, and delete unstaged ones. - for name in self.optional: + # Apply staged properties, and clear any that haven't been staged. + for prop in self.optional: if name not in staged: del obj[name] obj |= staged diff --git a/tests/test_declaration.py b/tests/test_declaration.py index ee8a78f..7533ec6 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -7,7 +7,6 @@ import pytest from tests.test_choices import mock_apply, prep_style_class -from travertino.constants import NORMAL from travertino.declaration import ( BaseStyle, Choices, @@ -57,7 +56,6 @@ class Style(BaseStyle): composite_prop: list = composite_property( optional=["implicit", "optional_prop"], required=["explicit_const", "list_prop"], - reset_value=NORMAL, ) @@ -595,86 +593,86 @@ def test_list_property_list_like(): assert isinstance(prop, Sequence) -def test_composite_property(): - style = Style() - - # Initial values - assert style.composite_prop == (VALUE1, [VALUE2]) - assert "implicit" not in style - assert "optional_prop" not in style - assert style.explicit_const == VALUE1 - assert style.list_prop == [VALUE2] - - # Set all the properties. - style.composite_prop = (VALUE1, VALUE4, VALUE2, [VALUE1, VALUE3]) - - assert style.composite_prop == (VALUE1, VALUE4, VALUE2, [VALUE1, VALUE3]) - assert style.implicit == VALUE1 - assert style.optional_prop == VALUE4 - assert style.explicit_const == VALUE2 - assert style.list_prop == [VALUE1, VALUE3] - - # Set just the required properties. Should unset optionals. - style.composite_prop = (VALUE3, [VALUE2]) - - assert style.composite_prop == (VALUE3, [VALUE2]) - assert "implicit" not in style - assert "optional_prop" not in style - assert style.explicit_const == VALUE3 - assert style.list_prop == [VALUE2] - - # Set all properties, with optionals out of order. - style.composite_prop = (VALUE4, VALUE1, VALUE2, [VALUE1, VALUE3]) - - assert style.composite_prop == (VALUE1, VALUE4, VALUE2, [VALUE1, VALUE3]) - assert style.implicit == VALUE1 - assert style.optional_prop == VALUE4 - assert style.explicit_const == VALUE2 - assert style.list_prop == [VALUE1, VALUE3] - - # Optionals can be reset with reset value (NORMAL). - style.composite_prop = (VALUE2, NORMAL, VALUE2, [VALUE1]) - assert style.composite_prop == (VALUE2, VALUE2, [VALUE1]) - assert style.implicit == VALUE2 - assert "optional_prop" not in style - assert style.explicit_const == VALUE2 - assert style.list_prop == [VALUE1] - - # Reset value can be in any order. - style.composite_prop = (VALUE4, NORMAL, VALUE2, [VALUE1]) - assert style.composite_prop == (VALUE4, VALUE2, [VALUE1]) - assert "implicit" not in style - assert style.optional_prop == VALUE4 - assert style.explicit_const == VALUE2 - assert style.list_prop == [VALUE1] - - # Verify that a string passed to the list property is put into a list. - style.composite_prop = (VALUE2, VALUE1) - - assert style.composite_prop == (VALUE2, [VALUE1]) - assert style.list_prop == [VALUE1] - - -@pytest.mark.parametrize( - "values, error", - [ - # Too few values - ([], TypeError), - ([VALUE3], TypeError), - # Too many values - ([VALUE4, VALUE1, VALUE1, VALUE2, [VALUE1]], TypeError), - # Value not valid for any optional property - (["bogus", VALUE2, [VALUE3]], ValueError), - # Repeated value (VALUE4) that's only valid for one optional property - ([VALUE4, VALUE4, VALUE2, [VALUE3]], ValueError), - # Invalid property for a required property - ([VALUE4, [VALUE3]], ValueError), - ], -) -def test_composite_property_invalid(values, error): - style = Style() - with pytest.raises(error): - style.composite_prop = values +# def test_composite_property(): +# style = Style() + +# # Initial values +# assert style.composite_prop == (VALUE1, [VALUE2]) +# assert "implicit" not in style +# assert "optional_prop" not in style +# assert style.explicit_const == VALUE1 +# assert style.list_prop == [VALUE2] + +# # Set all the properties. +# style.composite_prop = (VALUE1, VALUE4, VALUE2, [VALUE1, VALUE3]) + +# assert style.composite_prop == (VALUE1, VALUE4, VALUE2, [VALUE1, VALUE3]) +# assert style.implicit == VALUE1 +# assert style.optional_prop == VALUE4 +# assert style.explicit_const == VALUE2 +# assert style.list_prop == [VALUE1, VALUE3] + +# # Set just the required properties. Should unset optionals. +# style.composite_prop = (VALUE3, [VALUE2]) + +# assert style.composite_prop == (VALUE3, [VALUE2]) +# assert "implicit" not in style +# assert "optional_prop" not in style +# assert style.explicit_const == VALUE3 +# assert style.list_prop == [VALUE2] + +# # Set all properties, with optionals out of order. +# style.composite_prop = (VALUE4, VALUE1, VALUE2, [VALUE1, VALUE3]) + +# assert style.composite_prop == (VALUE1, VALUE4, VALUE2, [VALUE1, VALUE3]) +# assert style.implicit == VALUE1 +# assert style.optional_prop == VALUE4 +# assert style.explicit_const == VALUE2 +# assert style.list_prop == [VALUE1, VALUE3] + +# # Optionals can be reset with reset value (NORMAL). +# style.composite_prop = (VALUE2, NORMAL, VALUE2, [VALUE1]) +# assert style.composite_prop == (VALUE2, VALUE2, [VALUE1]) +# assert style.implicit == VALUE2 +# assert "optional_prop" not in style +# assert style.explicit_const == VALUE2 +# assert style.list_prop == [VALUE1] + +# # Reset value can be in any order. +# style.composite_prop = (VALUE4, NORMAL, VALUE2, [VALUE1]) +# assert style.composite_prop == (VALUE4, VALUE2, [VALUE1]) +# assert "implicit" not in style +# assert style.optional_prop == VALUE4 +# assert style.explicit_const == VALUE2 +# assert style.list_prop == [VALUE1] + +# # Verify that a string passed to the list property is put into a list. +# style.composite_prop = (VALUE2, VALUE1) + +# assert style.composite_prop == (VALUE2, [VALUE1]) +# assert style.list_prop == [VALUE1] + + +# @pytest.mark.parametrize( +# "values, error", +# [ +# # Too few values +# ([], TypeError), +# ([VALUE3], TypeError), +# # Too many values +# ([VALUE4, VALUE1, VALUE1, VALUE2, [VALUE1]], TypeError), +# # Value not valid for any optional property +# (["bogus", VALUE2, [VALUE3]], ValueError), +# # Repeated value (VALUE4) that's only valid for one optional property +# ([VALUE4, VALUE4, VALUE2, [VALUE3]], ValueError), +# # Invalid property for a required property +# ([VALUE4, [VALUE3]], ValueError), +# ], +# ) +# def test_composite_property_invalid(values, error): +# style = Style() +# with pytest.raises(error): +# style.composite_prop = values @pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) From 7dd68f49f6e661c31d9f301605fb4339be475b0d Mon Sep 17 00:00:00 2001 From: Charles Whittington Date: Wed, 16 Oct 2024 16:50:11 -0400 Subject: [PATCH 6/7] Made property_alias an ABC --- src/travertino/declaration.py | 11 +++++++++-- tox.ini | 3 +++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/travertino/declaration.py b/src/travertino/declaration.py index d505358..2723ed0 100644 --- a/src/travertino/declaration.py +++ b/src/travertino/declaration.py @@ -1,3 +1,4 @@ +from abc import ABC, abstractproperty from collections import defaultdict from collections.abc import Mapping, Sequence from warnings import filterwarnings, warn @@ -209,8 +210,8 @@ def validate(self, value): return ImmutableList(result) -class property_alias: - """A base class for list / composite properties. Not designed to be instantiated.""" +class property_alias(ABC): + """A base class for list / composite properties.""" def __set_name__(self, owner, name): self.name = name @@ -223,6 +224,12 @@ def __delete__(self, obj): def is_set_on(self, obj): return any(hasattr(obj, name) for name in self.properties) + @abstractproperty + def __get__(self, obj, objtype=None): ... + + @abstractproperty + def __set__(self, obj, value): ... + class directional_property(property_alias): DIRECTIONS = [TOP, RIGHT, BOTTOM, LEFT] diff --git a/tox.ini b/tox.ini index 7dbbfa7..205523a 100644 --- a/tox.ini +++ b/tox.ini @@ -14,6 +14,9 @@ ignore = W503, # whitespace before ':' (not PEP 8 compliant, recommended disabled by Black) E203, + # Multiple statements on one line. Black folds "..." onto the same line as a + # function def. + E704, [tox] envlist = towncrier-check,pre-commit,py{38,39,310,311,312,313} From 0b7ada80f0337bfb7be5177c8bc46a1ab196dcad Mon Sep 17 00:00:00 2001 From: Charles Whittington Date: Tue, 19 Nov 2024 23:39:24 -0500 Subject: [PATCH 7/7] Various fixes to composite_property, added font-based tests --- src/travertino/declaration.py | 94 ++++++------- tests/test_composite_property.py | 231 +++++++++++++++++++++++++++++++ tests/test_declaration.py | 89 ------------ 3 files changed, 274 insertions(+), 140 deletions(-) create mode 100644 tests/test_composite_property.py diff --git a/src/travertino/declaration.py b/src/travertino/declaration.py index c0eab43..33b19c1 100644 --- a/src/travertino/declaration.py +++ b/src/travertino/declaration.py @@ -162,8 +162,8 @@ def __delete__(self, obj): obj.apply(self.name, self.initial) @property - def _name_if_set(self, default=""): - return f" {self.name}" if hasattr(self, "name") else default + def _name_if_set(self): + return f" {self.name}" if hasattr(self, "name") else "" def validate(self, value): try: @@ -218,14 +218,17 @@ def __set_name__(self, owner, name): owner._BASE_ALL_PROPERTIES[owner].add(self.name) def __delete__(self, obj): - for name in self.properties: + for name in self.property_names: del obj[name] def is_set_on(self, obj): - return any(hasattr(obj, name) for name in self.properties) + return any(hasattr(obj, name) for name in self.property_names) - @abstractproperty - def __get__(self, obj, objtype=None): ... + def __get__(self, obj, objtype=None): + if obj is None: + return self + + return tuple(obj[name] for name in self.property_names) @abstractproperty def __set__(self, obj, value): ... @@ -245,19 +248,11 @@ def __init__(self, name_format): """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. + be replaced with "_top", "_bottom", etc. """ - self.name_format = name_format - self.properties = [self.format(direction) for direction in self.DIRECTIONS] - - 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[name] for name in self.properties) + self.property_names = [ + name_format.format(f"_{direction}") for direction in self.DIRECTIONS + ] def __set__(self, obj, value): if value is self: @@ -269,7 +264,7 @@ def __set__(self, obj, value): value = (value,) if order := self.ASSIGNMENT_SCHEMES.get(len(value)): - for name, index in zip(self.properties, order): + for name, index in zip(self.property_names, order): obj[name] = value[index] else: raise ValueError( @@ -282,38 +277,31 @@ def __init__(self, optional, required, parse_str=str.split): """Define a property attribute that proxies for an arbitrary set of properties. :param optional: The names of aliased properties that are optional in - assignment. Assigning `reset_value` unsets such a property. Order is - irrelevant, unless the same (non-resetting) value is valid for more than one - property, in which case it's assigned to the first one available. + assignment. Order is irrelevant, unless the same value is valid for more than + one property; in that case, values are assigned by priority of which one coud + apply to the fewest properties, and when that ties, they're assigned in + order. :param required: Which properties, if any, are required when setting this property. In assignment, these must be specified last and in order. :param parse_str: A callable with which to parse a string into valid input. """ self.optional = optional self.required = required - self.properties = self.optional + self.required + self.property_names = self.optional + self.required self.min_num = len(self.required) self.max_num = len(self.required) + len(self.optional) self.parse_str = parse_str - def __get__(self, obj, objtype=None): - if obj is None: - return self - - return tuple(obj[name] for name in self.optional if name in obj) + tuple( - obj[name] for name in self.required - ) - - def __set__(self, obj, value): - if value is self: + def __set__(self, obj, composite_value): + if composite_value is self: # This happens during autogenerated dataclass __init__ when no value is # supplied. return - if isinstance(value, str): - value = self.parse_str(value) + if isinstance(composite_value, str): + composite_value = self.parse_str(composite_value) - if not self.min_num <= len(value) <= self.max_num: + if not self.min_num <= len(composite_value) <= self.max_num: raise TypeError( f"Composite property {self.name} must be set with at least " f"{self.min_num} and no more than {self.max_num} values." @@ -324,17 +312,17 @@ def __set__(self, obj, value): # Handle the required values first. They have to be there, and in order, or the # whole assignment is invalid. - required_values = value[-len(self.required) :] + required_values = composite_value[-len(self.required) :] + for name, value in zip(self.required, required_values): # Let error propagate if it raises. staged[name] = getattr(obj.__class__, name).validate(value) - # Next, look through the optional values. First, for each value, determine which - # properties can accept it. Then assign the values in order of specificity. - # (Values of equal specificity are simply assigned to properties in order.) - optional_values = value[: -len(self.required)] + # Next, look through the optional values. + optional_values = composite_value[: -len(self.required)] - values_and_valid_props = [] + # For each value, determine which properties can accept it. + values_with_valid_props = [] for value in optional_values: valid_props = [] for name in self.optional: @@ -349,24 +337,27 @@ def __set__(self, obj, value): f"property {self.name}" ) - values_and_valid_props.append((value, valid_props)) + values_with_valid_props.append((value, valid_props)) + # Then assign the values in order of specificity; that is, a value + # gets "priority" if it matches fewer possible properties. (Values of equal + # specificity are simply assigned to properties in order.) for value, valid_props in sorted( - values_and_valid_props, key=lambda x: len(x[1]) + values_with_valid_props, key=lambda tup: len(tup[1]) ): for name in valid_props: if name not in staged: staged[name] = value break else: - # No valid property is still free. + # Didn't break; no valid property is still free. raise ValueError( f"Value {value} not valid for any optional properties of composite " f"property {self.name} that are not already being assigned." ) # Apply staged properties, and clear any that haven't been staged. - for prop in self.optional: + for name in self.optional: if name not in staged: del obj[name] obj |= staged @@ -393,8 +384,8 @@ def __init_subclass__(cls): # Fallback in case subclass isn't decorated as dataclass (probably from using # previous API) or for pre-3.10, before kw_only argument existed. - def __init__(self, **style): - self.update(**style) + def __init__(self, **properties): + self.update(**properties) @property def _applicator(self): @@ -437,9 +428,9 @@ def reapply(self): for name in self._PROPERTIES: self.apply(name, self[name]) - def update(self, **styles): + def update(self, **properties): """Set multiple styles on the style definition.""" - for name, value in styles.items(): + for name, value in properties.items(): name = name.replace("-", "_") if name not in self._ALL_PROPERTIES: raise NameError(f"Unknown style {name}") @@ -483,7 +474,7 @@ def __delitem__(self, name): raise KeyError(name) def keys(self): - return {name for name in self} + return {*self} def items(self): return [(name, self[name]) for name in self] @@ -523,6 +514,7 @@ def __ior__(self, other): ###################################################################### # Get the rendered form of the style declaration ###################################################################### + def __str__(self): return "; ".join( f"{name.replace('_', '-')}: {value}" for name, value in sorted(self.items()) diff --git a/tests/test_composite_property.py b/tests/test_composite_property.py new file mode 100644 index 0000000..948edd2 --- /dev/null +++ b/tests/test_composite_property.py @@ -0,0 +1,231 @@ +import pytest + +from tests.utils import prep_style_class +from travertino.constants import ( + BOLD, + CURSIVE, + FANTASY, + ITALIC, + MESSAGE, + MONOSPACE, + NORMAL, + SANS_SERIF, + SERIF, + SMALL_CAPS, + SYSTEM, +) +from travertino.declaration import ( + BaseStyle, + Choices, + composite_property, + list_property, + validated_property, +) + +SYSTEM_DEFAULT_FONTS = {SYSTEM, MESSAGE, SERIF, SANS_SERIF, CURSIVE, FANTASY, MONOSPACE} + + +def _parse_font_str(font_str: str) -> tuple: + """Parse a string into values for the font property alias""" + split = font_str.split() + + for index, part in enumerate(split): + try: + Style.font_size.validate(part) + break + except ValueError: + pass + else: + # No break; no size was found. + raise ValueError(f"Invalid font string: {font_str}") + + # Size is the "delimiter". As long as we found it, we know everything after it is + # the setting for font familes. + optional, size, families = split[:index], part, split[index + 1 :] + + # Add back the spaces previously removed, then parse the whole string. + families = " ".join(families) + try: + families = _parse_quotes(families) + except ValueError: + raise ValueError(f"Invalid string for font families: {families}") + return (*optional, size, families) + + +def _parse_quotes(input_str: str) -> list[str]: + """Break up a comma-delimited string, respecting quotes (and escaped quotes)""" + + def wordify(chars): + return "".join(chars).strip() + + QUOTES = "'\"" + DELIMITER = "," + ESCAPE = "\\" + + items = [] + current_item = [] + quote = None + need_delimiter_next = False + + chars = list(reversed(input_str)) + while chars: + char = chars.pop() + + if need_delimiter_next: + if char == DELIMITER: + need_delimiter_next = False + continue + elif char.isspace(): + continue + else: + raise ValueError("Content after quotes in item") + + if quote: + if char == ESCAPE: + if (escaped_char := chars.pop()) in QUOTES: + current_item.append(escaped_char) + else: + raise ValueError("Unrecognized escape sequence") + elif char == quote: + quote = None + items.append(wordify(current_item)) + current_item = [] + need_delimiter_next = True + else: + current_item.append(char) + + else: + if char in QUOTES: + if current_item and wordify(current_item): + raise ValueError("Quote not at beginning of item") + quote = char + elif char == DELIMITER: + items.append(wordify(current_item)) + current_item = [] + else: + current_item.append(char) + + if current_item: + items.append(wordify(current_item)) + + return items + + +@prep_style_class +class Style(BaseStyle): + font_style: str = validated_property(Choices(NORMAL, ITALIC, BOLD), initial=NORMAL) + font_variant: str = validated_property(Choices(NORMAL, SMALL_CAPS), initial=NORMAL) + font_weight: str = validated_property(Choices(NORMAL, BOLD), initial=NORMAL) + + font_size: int = validated_property(Choices(integer=True), initial=-1) + font_family: list[str] = list_property( + Choices(*SYSTEM_DEFAULT_FONTS, string=True), initial=[SYSTEM] + ) + + font: tuple = composite_property( + optional=("font_style", "font_variant", "font_weight"), + required=("font_size", "font_family"), + parse_str=_parse_font_str, + ) + + +def assert_font(style, values): + # Test against retrieving the composite property + assert style.font == values + + # Also test against the underlying individual properties + (font_style, font_variant, font_weight, font_size, font_family) = values + + assert style.font_style == font_style + assert style.font_variant == font_variant + assert style.font_weight == font_weight + assert style.font_size == font_size + assert style.font_family == font_family + + +def test_default_values(): + assert_font(Style(), (NORMAL, NORMAL, NORMAL, -1, [SYSTEM])) + + +@pytest.mark.parametrize( + "value", + [ + (ITALIC, SMALL_CAPS, BOLD, 12, ["Comic Sans", SANS_SERIF]), + # Should also work with optionals reordered + (SMALL_CAPS, BOLD, ITALIC, 12, ["Comic Sans", SANS_SERIF]), + (BOLD, SMALL_CAPS, ITALIC, 12, ["Comic Sans", SANS_SERIF]), + ], +) +def test_assign_all_non_default(value): + style = Style() + style.font = value + + assert_font(style, (ITALIC, SMALL_CAPS, BOLD, 12, ["Comic Sans", SANS_SERIF])) + + +@pytest.mark.parametrize( + "value", + [ + # Full assignment, in order and out of order + (NORMAL, SMALL_CAPS, NORMAL, 12, ["Comic Sans", SANS_SERIF]), + (NORMAL, NORMAL, SMALL_CAPS, 12, ["Comic Sans", SANS_SERIF]), + # Only the non-default + (SMALL_CAPS, 12, ["Comic Sans", SANS_SERIF]), + # One NORMAL + (SMALL_CAPS, NORMAL, 12, ["Comic Sans", SANS_SERIF]), + (NORMAL, SMALL_CAPS, 12, ["Comic Sans", SANS_SERIF]), + ], +) +def test_assign_one_non_default(value): + style = Style() + style.font = value + + assert_font(style, (NORMAL, SMALL_CAPS, NORMAL, 12, ["Comic Sans", SANS_SERIF])) + + +@pytest.mark.parametrize( + "value", + [ + # Full assignment, in order and out of order + (NORMAL, SMALL_CAPS, NORMAL, 12, ["Comic Sans", SANS_SERIF]), + (NORMAL, NORMAL, SMALL_CAPS, 12, ["Comic Sans", SANS_SERIF]), + # Only the non-default + (SMALL_CAPS, 12, ["Comic Sans", SANS_SERIF]), + # One NORMAL + (SMALL_CAPS, NORMAL, 12, ["Comic Sans", SANS_SERIF]), + (NORMAL, SMALL_CAPS, 12, ["Comic Sans", SANS_SERIF]), + ], +) +def test_assign_one_non_default_after_setting(value): + style = Style() + style.font_weight = BOLD + style.font_style = ITALIC + + style.font = value + + assert_font(style, (NORMAL, SMALL_CAPS, NORMAL, 12, ["Comic Sans", SANS_SERIF])) + + +@pytest.mark.parametrize( + "value, result", + [ + ( + "italic small-caps bold 12 Comic Sans, sans-serif", + (ITALIC, SMALL_CAPS, BOLD, 12, ["Comic Sans", SANS_SERIF]), + ), + ( + 'italic small-caps bold 12 "Comic Sans", sans-serif', + (ITALIC, SMALL_CAPS, BOLD, 12, ["Comic Sans", SANS_SERIF]), + ), + ( + # Gotta escape the escape sequence... + "italic small-caps bold 12 \"Comic Sans\", 'George\\'s Font', sans-serif", + (ITALIC, SMALL_CAPS, BOLD, 12, ["Comic Sans", "George's Font", SANS_SERIF]), + ), + ], +) +def test_string_parsing(value, result): + style = Style() + style.font = value + + assert_font(style, result) diff --git a/tests/test_declaration.py b/tests/test_declaration.py index 7af51a3..ea78a1c 100644 --- a/tests/test_declaration.py +++ b/tests/test_declaration.py @@ -11,7 +11,6 @@ BaseStyle, Choices, ImmutableList, - composite_property, directional_property, list_property, validated_property, @@ -51,12 +50,6 @@ class Style(BaseStyle): # Doesn't need to be tested in deprecated API: list_prop: list[str] = list_property(choices=VALUE_CHOICES, initial=[VALUE2]) - # Same for composite property. - optional_prop = validated_property(choices=OTHER_CHOICES) - composite_prop: list = composite_property( - optional=["implicit", "optional_prop"], - required=["explicit_const", "list_prop"], - ) with catch_warnings(): @@ -619,88 +612,6 @@ def test_list_property_list_like(): assert isinstance(prop, Sequence) -# def test_composite_property(): -# style = Style() - -# # Initial values -# assert style.composite_prop == (VALUE1, [VALUE2]) -# assert "implicit" not in style -# assert "optional_prop" not in style -# assert style.explicit_const == VALUE1 -# assert style.list_prop == [VALUE2] - -# # Set all the properties. -# style.composite_prop = (VALUE1, VALUE4, VALUE2, [VALUE1, VALUE3]) - -# assert style.composite_prop == (VALUE1, VALUE4, VALUE2, [VALUE1, VALUE3]) -# assert style.implicit == VALUE1 -# assert style.optional_prop == VALUE4 -# assert style.explicit_const == VALUE2 -# assert style.list_prop == [VALUE1, VALUE3] - -# # Set just the required properties. Should unset optionals. -# style.composite_prop = (VALUE3, [VALUE2]) - -# assert style.composite_prop == (VALUE3, [VALUE2]) -# assert "implicit" not in style -# assert "optional_prop" not in style -# assert style.explicit_const == VALUE3 -# assert style.list_prop == [VALUE2] - -# # Set all properties, with optionals out of order. -# style.composite_prop = (VALUE4, VALUE1, VALUE2, [VALUE1, VALUE3]) - -# assert style.composite_prop == (VALUE1, VALUE4, VALUE2, [VALUE1, VALUE3]) -# assert style.implicit == VALUE1 -# assert style.optional_prop == VALUE4 -# assert style.explicit_const == VALUE2 -# assert style.list_prop == [VALUE1, VALUE3] - -# # Optionals can be reset with reset value (NORMAL). -# style.composite_prop = (VALUE2, NORMAL, VALUE2, [VALUE1]) -# assert style.composite_prop == (VALUE2, VALUE2, [VALUE1]) -# assert style.implicit == VALUE2 -# assert "optional_prop" not in style -# assert style.explicit_const == VALUE2 -# assert style.list_prop == [VALUE1] - -# # Reset value can be in any order. -# style.composite_prop = (VALUE4, NORMAL, VALUE2, [VALUE1]) -# assert style.composite_prop == (VALUE4, VALUE2, [VALUE1]) -# assert "implicit" not in style -# assert style.optional_prop == VALUE4 -# assert style.explicit_const == VALUE2 -# assert style.list_prop == [VALUE1] - -# # Verify that a string passed to the list property is put into a list. -# style.composite_prop = (VALUE2, VALUE1) - -# assert style.composite_prop == (VALUE2, [VALUE1]) -# assert style.list_prop == [VALUE1] - - -# @pytest.mark.parametrize( -# "values, error", -# [ -# # Too few values -# ([], TypeError), -# ([VALUE3], TypeError), -# # Too many values -# ([VALUE4, VALUE1, VALUE1, VALUE2, [VALUE1]], TypeError), -# # Value not valid for any optional property -# (["bogus", VALUE2, [VALUE3]], ValueError), -# # Repeated value (VALUE4) that's only valid for one optional property -# ([VALUE4, VALUE4, VALUE2, [VALUE3]], ValueError), -# # Invalid property for a required property -# ([VALUE4, [VALUE3]], ValueError), -# ], -# ) -# def test_composite_property_invalid(values, error): -# style = Style() -# with pytest.raises(error): -# style.composite_prop = values - - @pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle]) def test_set_multiple_properties(StyleClass): style = StyleClass()