Skip to content

Commit 8e222c2

Browse files
authored
Consistently handle style properties that are explicitly set to initial value (beeware#3151)
Differentiates between unset values, and values that have been explicitly set to their initial value.
1 parent 71a5826 commit 8e222c2

File tree

4 files changed

+112
-57
lines changed

4 files changed

+112
-57
lines changed

changes/3151.misc.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Style properties are now consistently counted as having been set, even when they are explicitly set to their default initial value.

core/src/toga/compat/collections/abc.py

+3
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ def __init__(self, abc_cls):
4343
def __getitem__(self, key):
4444
return self.abc_cls
4545

46+
def register(self, cls):
47+
pass
48+
4649

4750
for cls_name in __all__:
4851
globals()[cls_name] = _SubscriptWrapper(type(cls_name, (object,), {}))

travertino/src/travertino/declaration.py

+72-57
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,18 @@ def __str__(self):
3131
def __repr__(self):
3232
return repr(self._data)
3333

34+
def __reversed__(self):
35+
return reversed(self._data)
36+
37+
def index(self, value):
38+
return self._data.index(value)
39+
40+
def count(self, value):
41+
return self._data.count(value)
42+
43+
44+
Sequence.register(ImmutableList)
45+
3446

3547
class Choices:
3648
"A class to define allowable data types for a property"
@@ -108,24 +120,13 @@ def __init__(
108120
:param integer: Are integers allowed as values?
109121
:param number: Are numbers allowed as values?
110122
:param color: Are colors allowed as values?
111-
:param initial: The initial value for the property.
123+
:param initial: The initial value for the property. If the property has not been
124+
explicitly set, this is what is returned when it's accessed.
112125
"""
113126
self.choices = Choices(
114127
*constants, string=string, integer=integer, number=number, color=color
115128
)
116-
self.initial = None
117-
118-
try:
119-
# If an initial value has been provided, it must be consistent with
120-
# the choices specified.
121-
if initial is not None:
122-
self.initial = self.validate(initial)
123-
except ValueError:
124-
# Unfortunately, __set_name__ hasn't been called yet, so we don't know the
125-
# property's name.
126-
raise ValueError(
127-
f"Invalid initial value {initial!r}. Available choices: {self.choices}"
128-
)
129+
self.initial = None if initial is None else self.validate(initial)
129130

130131
def __set_name__(self, owner, name):
131132
self.name = name
@@ -153,7 +154,15 @@ def __set__(self, obj, value):
153154

154155
value = self.validate(value)
155156

156-
if value != getattr(obj, f"_{self.name}", self.initial):
157+
if (current := getattr(obj, f"_{self.name}", None)) is None:
158+
# If the value has not been explicitly set already, then we always want to
159+
# assign to the attribute -- even if the value being assigned is identical
160+
# to the initial value.
161+
setattr(obj, f"_{self.name}", value)
162+
if value != self.initial:
163+
obj.apply(self.name, value)
164+
165+
elif value != current:
157166
setattr(obj, f"_{self.name}", value)
158167
obj.apply(self.name, value)
159168

@@ -166,8 +175,8 @@ def __delete__(self, obj):
166175
obj.apply(self.name, self.initial)
167176

168177
@property
169-
def _name_if_set(self, default=""):
170-
return f" {self.name}" if hasattr(self, "name") else default
178+
def _name_if_set(self):
179+
return f" {self.name}" if hasattr(self, "name") else ""
171180

172181
def validate(self, value):
173182
try:
@@ -230,20 +239,19 @@ def __init__(self, name_format):
230239
:param name_format: The format from which to generate subproperties. "{}" will
231240
be replaced with "_top", etc.
232241
"""
233-
self.name_format = name_format
242+
self.property_names = [
243+
name_format.format(f"_{direction}") for direction in self.DIRECTIONS
244+
]
234245

235246
def __set_name__(self, owner, name):
236247
self.name = name
237248
owner._BASE_ALL_PROPERTIES[owner].add(self.name)
238249

239-
def format(self, direction):
240-
return self.name_format.format(f"_{direction}")
241-
242250
def __get__(self, obj, objtype=None):
243251
if obj is None:
244252
return self
245253

246-
return tuple(obj[self.format(direction)] for direction in self.DIRECTIONS)
254+
return tuple(obj[name] for name in self.property_names)
247255

248256
def __set__(self, obj, value):
249257
if value is self:
@@ -255,22 +263,20 @@ def __set__(self, obj, value):
255263
value = (value,)
256264

257265
if order := self.ASSIGNMENT_SCHEMES.get(len(value)):
258-
for direction, index in zip(self.DIRECTIONS, order):
259-
obj[self.format(direction)] = value[index]
266+
for name, index in zip(self.property_names, order):
267+
obj[name] = value[index]
260268
else:
261269
raise ValueError(
262270
f"Invalid value for '{self.name}'; value must be a number, or a 1-4 "
263271
f"tuple."
264272
)
265273

266274
def __delete__(self, obj):
267-
for direction in self.DIRECTIONS:
268-
del obj[self.format(direction)]
275+
for name in self.property_names:
276+
del obj[name]
269277

270278
def is_set_on(self, obj):
271-
return any(
272-
hasattr(obj, self.format(direction)) for direction in self.DIRECTIONS
273-
)
279+
return any(hasattr(obj, name) for name in self.property_names)
274280

275281

276282
class BaseStyle:
@@ -297,8 +303,8 @@ def _ALL_PROPERTIES(self):
297303

298304
# Fallback in case subclass isn't decorated as subclass (probably from using
299305
# previous API) or for pre-3.10, before kw_only argument existed.
300-
def __init__(self, **style):
301-
self.update(**style)
306+
def __init__(self, **properties):
307+
self.update(**properties)
302308

303309
@property
304310
def _applicator(self):
@@ -324,6 +330,34 @@ def _applicator(self, value):
324330
stacklevel=2,
325331
)
326332

333+
def reapply(self):
334+
for name in self._PROPERTIES:
335+
self.apply(name, self[name])
336+
337+
def copy(self, applicator=None):
338+
"""Create a duplicate of this style declaration."""
339+
dup = self.__class__()
340+
dup.update(**self)
341+
342+
######################################################################
343+
# 10-2024: Backwards compatibility for Toga <= 0.4.8
344+
######################################################################
345+
346+
if applicator is not None:
347+
warn(
348+
"Providing an applicator to BaseStyle.copy() is deprecated. Set "
349+
"applicator afterward on the returned copy.",
350+
DeprecationWarning,
351+
stacklevel=2,
352+
)
353+
dup._applicator = applicator
354+
355+
######################################################################
356+
# End backwards compatibility
357+
######################################################################
358+
359+
return dup
360+
327361
######################################################################
328362
# Interface that style declarations must define
329363
######################################################################
@@ -342,35 +376,15 @@ def layout(self, viewport):
342376
# Provide a dict-like interface
343377
######################################################################
344378

345-
def reapply(self):
346-
for name in self._PROPERTIES:
347-
self.apply(name, self[name])
348-
349-
def update(self, **styles):
350-
"Set multiple styles on the style definition."
351-
for name, value in styles.items():
379+
def update(self, **properties):
380+
"""Set multiple styles on the style definition."""
381+
for name, value in properties.items():
352382
name = name.replace("-", "_")
353383
if name not in self._ALL_PROPERTIES:
354384
raise NameError(f"Unknown style '{name}'")
355385

356386
self[name] = value
357387

358-
def copy(self, applicator=None):
359-
"Create a duplicate of this style declaration."
360-
dup = self.__class__()
361-
dup.update(**self)
362-
363-
if applicator is not None:
364-
warn(
365-
"Providing an applicator to BaseStyle.copy() is deprecated. Set "
366-
"applicator afterward on the returned copy.",
367-
DeprecationWarning,
368-
stacklevel=2,
369-
)
370-
dup._applicator = applicator
371-
372-
return dup
373-
374388
def __getitem__(self, name):
375389
name = name.replace("-", "_")
376390
if name in self._ALL_PROPERTIES:
@@ -392,13 +406,13 @@ def __delitem__(self, name):
392406
raise KeyError(name)
393407

394408
def keys(self):
395-
return {name for name in self._PROPERTIES if name in self}
409+
return {name for name in self}
396410

397411
def items(self):
398-
return [(name, self[name]) for name in self._PROPERTIES if name in self]
412+
return [(name, self[name]) for name in self]
399413

400414
def __len__(self):
401-
return sum(1 for name in self._PROPERTIES if name in self)
415+
return sum(1 for _ in self)
402416

403417
def __contains__(self, name):
404418
return name in self._ALL_PROPERTIES and (
@@ -432,6 +446,7 @@ def __ior__(self, other):
432446
######################################################################
433447
# Get the rendered form of the style declaration
434448
######################################################################
449+
435450
def __str__(self):
436451
return "; ".join(
437452
f"{name.replace('_', '-')}: {value}" for name, value in sorted(self.items())

travertino/tests/test_declaration.py

+36
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from collections.abc import Sequence
34
from unittest.mock import call
45
from warnings import catch_warnings, filterwarnings
56

@@ -606,6 +607,14 @@ def test_list_property_list_like():
606607
count += 1
607608
assert count == 4
608609

610+
assert [*reversed(prop)] == [VALUE2, 3, 2, 1]
611+
612+
assert prop.index(3) == 2
613+
614+
assert prop.count(VALUE2) == 1
615+
616+
assert isinstance(prop, Sequence)
617+
609618

610619
@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle])
611620
def test_set_multiple_properties(StyleClass):
@@ -754,6 +763,33 @@ def test_dict(StyleClass):
754763
del style["no-such-property"]
755764

756765

766+
@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle])
767+
def test_set_to_initial(StyleClass):
768+
"""A property set to its initial value is distinct from an unset property."""
769+
style = StyleClass()
770+
771+
# explicit_const's initial value is VALUE1.
772+
assert style.explicit_const == VALUE1
773+
assert "explicit_const" not in style
774+
775+
# The unset property shouldn't affect the value when overlaid over a style with
776+
# that property set.
777+
non_initial_style = StyleClass(explicit_const=VALUE2)
778+
union = non_initial_style | style
779+
assert union.explicit_const == VALUE2
780+
assert "explicit_const" in union
781+
782+
# The property should count as set, even when set to the same initial value.
783+
style.explicit_const = VALUE1
784+
assert style.explicit_const == VALUE1
785+
assert "explicit_const" in style
786+
787+
# The property should now overwrite.
788+
union = non_initial_style | style
789+
assert union.explicit_const == VALUE1
790+
assert "explicit_const" in union
791+
792+
757793
@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle])
758794
@pytest.mark.parametrize("instantiate", [True, False])
759795
def test_union_operators(StyleClass, instantiate):

0 commit comments

Comments
 (0)