Skip to content

Commit 0d7254a

Browse files
authored
Decorate Pack as dataclass (#3215)
Decorated Pack as dataclass, and tweaked error messages to preserve detailed reporting.
1 parent 3156192 commit 0d7254a

File tree

6 files changed

+81
-9
lines changed

6 files changed

+81
-9
lines changed

changes/3215.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Pack is now a dataclass. This should allow most IDEs to infer the names and types of properties and suggest them in creating a Pack instance.

core/src/toga/style/pack.py

+9-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

3+
import sys
34
import warnings
5+
from dataclasses import dataclass
46
from typing import TYPE_CHECKING, Any
57

68
if TYPE_CHECKING:
@@ -132,8 +134,7 @@ def __get__(self, obj, objtype=None):
132134
return super().__get__(obj)
133135

134136
def __set__(self, obj, value):
135-
# This won't be executed until @dataclass is added
136-
if value is self: # pragma: no cover
137+
if value is self:
137138
# This happens during autogenerated dataclass __init__ when no value is
138139
# supplied.
139140
return
@@ -176,7 +177,13 @@ def warn_if_deprecated(self):
176177
# End backwards compatibility
177178
######################################################################
178179

180+
if sys.version_info < (3, 10):
181+
_DATACLASS_KWARGS = {"init": False, "repr": False}
182+
else:
183+
_DATACLASS_KWARGS = {"kw_only": True, "repr": False}
179184

185+
186+
@dataclass(**_DATACLASS_KWARGS)
180187
class Pack(BaseStyle):
181188
_doc_link = ":doc:`style properties </reference/style/pack>`"
182189

core/tests/style/test_mixin.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ def test_constructor(name, value, default):
2727
assert getattr(widget.style, name) == value
2828
assert widget.style.display == "none"
2929

30-
with raises(NameError, match="Unknown style 'nonexistent'"):
30+
with raises(
31+
TypeError,
32+
match=r"Pack\.__init__\(\) got an unexpected keyword argument 'nonexistent'",
33+
):
3134
ExampleWidget(nonexistent=None)
3235

3336

travertino/src/travertino/style.py

+39-4
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@ class BaseStyle:
1313
"""A base class for style declarations.
1414
1515
Exposes a dict-like interface. Designed for subclasses to be decorated
16-
with @dataclass(kw_only=True), which most IDEs should be able to interpret and
17-
provide autocompletion of argument names. On Python < 3.10, init=False can be used
18-
to still get the keyword-only behavior from the included __init__.
16+
with @dataclass(kw_only=True, repr=False).
17+
18+
The kw_only parameter was added in Python 3.10; for 3.9, init=False can be used
19+
instead to still get the keyword-only behavior from the included __init__.
20+
21+
Most IDEs should see the dataclass decorator and provide autocompletion / type hints
22+
for parameters to the constructor.
1923
"""
2024

2125
_BASE_PROPERTIES = defaultdict(set)
@@ -26,10 +30,35 @@ def __init_subclass__(cls):
2630
cls._PROPERTIES = cls._BASE_PROPERTIES[cls]
2731
cls._ALL_PROPERTIES = cls._BASE_ALL_PROPERTIES[cls]
2832

33+
########################################################################
34+
# 03-2025: Backwards compatibility for Toga < 0.5.0 *and* for Python 3.9
35+
########################################################################
36+
2937
# Fallback in case subclass isn't decorated as dataclass (probably from using
3038
# previous API) or for pre-3.10, before kw_only argument existed.
3139
def __init__(self, **properties):
32-
self.update(**properties)
40+
try:
41+
self.update(**properties)
42+
except NameError:
43+
# It still makes sense for update() to raise a NameError. However, here we
44+
# simulate the behavior of the dataclass-generated __init__() for
45+
# consistency.
46+
for name in properties:
47+
# This is redoing work, but it should only ever happen when a property
48+
# name is invalid, and only in outdated Python or Toga, and only once.
49+
if name not in self._ALL_PROPERTIES:
50+
raise TypeError(
51+
f"{type(self).__name__}.__init__() got an unexpected keyword "
52+
f"argument '{name}'"
53+
)
54+
# The above for loop should never run to completion, so that needs to be
55+
# excluded from coverage.
56+
else: # pragma: no cover
57+
pass
58+
59+
######################################################################
60+
# End backwards compatibility
61+
######################################################################
3362

3463
@property
3564
def _applicator(self):
@@ -197,6 +226,12 @@ def __str__(self):
197226
f"{name.replace('_', '-')}: {value}" for name, value in sorted(self.items())
198227
)
199228

229+
def __repr__(self):
230+
properties = ", ".join(
231+
f"{name}={repr(value)}" for name, value in sorted(self.items())
232+
)
233+
return f"{type(self).__name__}({properties})"
234+
200235
######################################################################
201236
# Backwards compatibility
202237
######################################################################

travertino/tests/test_style.py

+26
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,16 @@ def test_positional_argument(StyleClass):
150150
StyleClass(5)
151151

152152

153+
@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle])
154+
def test_constructor_invalid_property(StyleClass):
155+
"""Whether dataclass or not, the error should be the same."""
156+
with pytest.raises(
157+
TypeError,
158+
match=r"Style\.__init__\(\) got an unexpected keyword argument 'bogus'",
159+
):
160+
StyleClass(explicit_const=5, bogus=None)
161+
162+
153163
@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle])
154164
def test_create_and_copy(StyleClass):
155165
style = StyleClass(explicit_const=VALUE2, implicit=VALUE3)
@@ -704,6 +714,22 @@ def test_str(StyleClass):
704714
)
705715

706716

717+
def test_repr():
718+
# Doesn't need to be tested with deprecated API.
719+
style = Style(explicit_const=VALUE2, explicit_value=20, thing=(30, 40, 50, 60))
720+
721+
assert repr(style) == (
722+
"Style("
723+
"explicit_const='value2', "
724+
"explicit_value=20, "
725+
"thing_bottom=50, "
726+
"thing_left=60, "
727+
"thing_right=40, "
728+
"thing_top=30"
729+
")"
730+
)
731+
732+
707733
@pytest.mark.parametrize("StyleClass", [Style, DeprecatedStyle])
708734
def test_dict(StyleClass):
709735
"Style declarations expose a dict-like interface"

travertino/tests/utils.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
from travertino.colors import hsl, hsla, rgb, rgba
88

99
if sys.version_info < (3, 10):
10-
_DATACLASS_KWARGS = {"init": False}
10+
_DATACLASS_KWARGS = {"init": False, "repr": False}
1111
else:
12-
_DATACLASS_KWARGS = {"kw_only": True}
12+
_DATACLASS_KWARGS = {"kw_only": True, "repr": False}
1313

1414

1515
def apply_dataclass(cls):

0 commit comments

Comments
 (0)