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

Add list_property, and other property cleanups #148

Merged
merged 16 commits into from
Mar 16, 2024
Merged
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/148.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added a ``list_property`` for storing multi-valued elements.
190 changes: 99 additions & 91 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
from typing import Mapping, Sequence
from warnings import filterwarnings, warn

from .colors import color
Expand All @@ -9,6 +9,29 @@
filterwarnings("default", category=DeprecationWarning)


class ImmutableList:
def __init__(self, iterable):
self._data = [*iterable]

def __getitem__(self, index):
return self._data[index]

def __len__(self):
return len(self._data)

def __iter__(self):
return iter(self._data)

def __eq__(self, other):
return self._data == other

def __str__(self):
return str(self._data)

def __repr__(self):
return repr(self._data)


class Choices:
"A class to define allowable data types for a property"

Expand Down Expand Up @@ -91,7 +114,7 @@ def __init__(self, choices, initial=None):
# 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)
self.initial = self.validate(initial)
except ValueError:
# Unfortunately, __set_name__ hasn't been called yet, so we don't know the
# property's name.
Expand All @@ -101,8 +124,8 @@ def __init__(self, choices, initial=None):

def __set_name__(self, owner, name):
self.name = name
owner._PROPERTIES[owner].add(name)
owner._ALL_PROPERTIES[owner].add(name)
owner._BASE_PROPERTIES[owner].add(name)
owner._BASE_ALL_PROPERTIES[owner].add(name)

def __get__(self, obj, objtype=None):
if obj is None:
Expand All @@ -120,16 +143,10 @@ def __set__(self, obj, value):
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}`"
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}"
)
value = self.validate(value)

if value != getattr(obj, f"_{self.name}", None):
setattr(obj, f"_{self.name}", value)
Expand All @@ -143,10 +160,55 @@ def __delete__(self, obj):
else:
obj.apply(self.name, self.initial)

@property
def _name_if_set(self, default=""):
return f" {self.name}" if hasattr(self, "name") else default

def validate(self, value):
try:
return self.choices.validate(value)
except ValueError:
raise ValueError(
f"Invalid value {value!r} for property{self._name_if_set}; "
f"Valid values are: {self.choices}"
)

def is_set_on(self, obj):
return hasattr(obj, f"_{self.name}")


class list_property(validated_property):
def validate(self, value):
if isinstance(value, str):
value = [value]
elif not isinstance(value, Sequence):
raise TypeError(
f"Value for list property{self._name_if_set} must be a sequence."
)
HalfWhitt marked this conversation as resolved.
Show resolved Hide resolved

if not value:
name = getattr(self, "name", "prop_name")
raise ValueError(
"List properties cannot be set to an empty sequence; "
f"to reset a property, use del `style.{name}`."
)

# This could be a comprehension, but then the error couldn't specify which value
# is at fault.
result = []
for item in value:
try:
item = self.choices.validate(item)
except ValueError:
raise ValueError(
f"Invalid item value {item!r} for list property{self._name_if_set}; "
f"Valid values are: {self.choices}"
)
result.append(item)

return ImmutableList(result)


class directional_property:
DIRECTIONS = [TOP, RIGHT, BOTTOM, LEFT]
ASSIGNMENT_SCHEMES = {
Expand All @@ -157,21 +219,17 @@ class directional_property:
4: [0, 1, 2, 3],
}

def __init__(self, name_format, choices=None, initial=None):
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.
: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)
owner._BASE_ALL_PROPERTIES[owner].add(self.name)

def format(self, direction):
return self.name_format.format(f"_{direction}")
Expand Down Expand Up @@ -218,8 +276,13 @@ class BaseStyle:
to still get the keyword-only behavior from the included __init__.
"""

_PROPERTIES = defaultdict(set)
_ALL_PROPERTIES = defaultdict(set)
_BASE_PROPERTIES = defaultdict(set)
_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
# previous API) or for pre-3.10, before kw_only argument existed.
Expand Down Expand Up @@ -248,79 +311,61 @@ def apply(self, property, value):
######################################################################

def reapply(self):
for style in self._PROPERTIES[self.__class__]:
self.apply(style, getattr(self, style))
for name in self._PROPERTIES:
self.apply(name, self[name])

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[self.__class__]:
if name not in self._ALL_PROPERTIES:
raise NameError(f"Unknown style {name}")

setattr(self, name, value)
self[name] = value

def copy(self, applicator=None):
"Create a duplicate of this style declaration."
dup = self.__class__()
dup._applicator = applicator
for style in self._PROPERTIES[self.__class__]:
try:
setattr(dup, style, getattr(self, f"_{style}"))
except AttributeError:
pass
dup.update(**self)
return dup

def __getitem__(self, name):
name = name.replace("-", "_")
if name in self._PROPERTIES[self.__class__]:
if name in self._ALL_PROPERTIES:
return getattr(self, name)
raise KeyError(name)

def __setitem__(self, name, value):
name = name.replace("-", "_")
if name in self._PROPERTIES[self.__class__]:
if name in self._ALL_PROPERTIES:
setattr(self, name, value)
else:
raise KeyError(name)

def __delitem__(self, name):
name = name.replace("-", "_")
if name in self._PROPERTIES[self.__class__]:
if name in self._ALL_PROPERTIES:
delattr(self, name)
else:
raise KeyError(name)

def keys(self):
return {
name
for name in self._PROPERTIES[self.__class__]
if hasattr(self, f"_{name}")
}
return {name for name in self._PROPERTIES if name in self}

def items(self):
return [
(name, value)
for name in self._PROPERTIES[self.__class__]
if (value := getattr(self, f"_{name}", None)) is not None
]
return [(name, self[name]) for name in self._PROPERTIES if name in self]

def __len__(self):
return sum(
1 for name in self._PROPERTIES[self.__class__] if hasattr(self, f"_{name}")
)
return sum(1 for name in self._PROPERTIES if name in self)

def __contains__(self, name):
return name in self._ALL_PROPERTIES[self.__class__] and (
return name in self._ALL_PROPERTIES 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}")
)
yield from (name for name in self._PROPERTIES if name in self)

def __or__(self, other):
if isinstance(other, BaseStyle):
Expand All @@ -347,14 +392,9 @@ def __ior__(self, other):
# Get the rendered form of the style declaration
######################################################################
def __str__(self):
non_default = []
for name in self._PROPERTIES[self.__class__]:
try:
non_default.append((name.replace("_", "-"), getattr(self, f"_{name}")))
except AttributeError:
pass

return "; ".join(f"{name}: {value}" for name, value in sorted(non_default))
return "; ".join(
f"{name.replace('_', '-')}: {value}" for name, value in sorted(self.items())
)

######################################################################
# Backwards compatibility
Expand Down Expand Up @@ -385,35 +425,3 @@ def directional_property(cls, name):
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."
# initial = choices.validate(initial)

# def getter(self):
# return getattr(self, '_%s' % name, initial)

# def setter(self, values):
# try:
# value = [choices.validate(v) for v in values.split(',')]
# except ValueError:
# raise ValueError("Invalid value in for list property '%s'; Valid values are: %s" % (
# name, choices
# ))

# if value != getattr(self, '_%s' % name, initial):
# setattr(self, '_%s' % name, value)
# self.apply(name, value)

# def deleter(self):
# try:
# delattr(self, '_%s' % name)
# self.apply(name, value)
# except AttributeError:
# # Attribute doesn't exist
# pass

# _PROPERTIES.add(name)
# return property(getter, setter, deleter)
Loading