Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added composite property (and some nearby cleanups) #222

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/222.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added a ``composite_property`` for aliasing an arbitrary set of other properties.
158 changes: 124 additions & 34 deletions src/travertino/declaration.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 instantiated."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid confusion, this should be marked as an ABC, with any property requirements either documented, or defines as @abstractproperty

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!


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
Expand All @@ -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}")
Expand All @@ -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:
Expand All @@ -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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does a reset value differ from (and interact with) a default value on the underlying property?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That... is a very good point. In my first pass the "parser" checked explicitly for NORMAL, and I think I generalized in the wrong direction. Of course in the real font property, that will just be the default values of the three optional properties, which I can check programmatically.

It's an unusual exercise, generalizing from something there only two specific (and pretty different) instances of.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the reset parameter and overhauled the assignment of optional properties; more below.

"""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 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

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.
Expand All @@ -276,15 +364,17 @@ 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):
# Give the subclass a direct reference to its properties.
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)
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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 (
Expand Down
105 changes: 104 additions & 1 deletion tests/test_declaration.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -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():
Expand Down Expand Up @@ -573,6 +586,96 @@ 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]

# 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):
Expand Down
Loading