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 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all 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.
175 changes: 141 additions & 34 deletions src/travertino/declaration.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from abc import ABC, abstractproperty
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 +32,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 +121,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 +210,28 @@ def validate(self, value):
return ImmutableList(result)


class directional_property:
class property_alias(ABC):
"""A base class for list / composite properties."""

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)

@abstractproperty
def __get__(self, obj, objtype=None): ...

@abstractproperty
def __set__(self, obj, value): ...


class directional_property(property_alias):
DIRECTIONS = [TOP, RIGHT, BOTTOM, LEFT]
ASSIGNMENT_SCHEMES = {
# T R B L
Expand All @@ -226,10 +248,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 +257,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 +269,108 @@ 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
class composite_property(property_alias):
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.
: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.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:
# This happens during autogenerated dataclass __init__ when no value is
# 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 "
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_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(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)]

values_and_valid_props = []
for value in optional_values:
valid_props = []
for name in self.optional:
try:
getattr(obj.__class__, name).validate(value)
valid_props.append(name)
except ValueError:
pass
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:
# 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:
if name not in staged:
del obj[name]
obj |= staged


class BaseStyle:
"""A base class for style declarations.
Expand All @@ -276,15 +381,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 +422,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 +431,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 +458,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
Loading