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

Decorate Pack as dataclass #3215

Draft
wants to merge 20 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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/3213.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Travertino now has an ``aliased_property`` descriptor to support declaration of property name aliases in styles.
1 change: 1 addition & 0 deletions changes/3213.misc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Pack's aliases, deprecated names, and hyphenated style names now function correctly with ``name in style``.
1 change: 1 addition & 0 deletions changes/3215.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +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.
283 changes: 139 additions & 144 deletions core/src/toga/style/pack.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import sys
import warnings
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
Expand Down Expand Up @@ -36,6 +38,7 @@
VISIBLE,
)
from travertino.layout import BaseBox
from travertino.properties.aliased import Condition, aliased_property
from travertino.properties.shorthand import directional_property
from travertino.properties.validated import validated_property
from travertino.size import BaseIntrinsicSize
Expand All @@ -57,11 +60,114 @@

PACK = "pack"

# Used in backwards compatibility section below
ALIGNMENT = "alignment"
ALIGN_ITEMS = "align_items"
######################################################################
# 2024-12: Backwards compatibility for Toga < 0.5.0
######################################################################


class AlignmentCondition(Condition):
def __init__(self, main_value, /, **properties):
super().__init__(**properties)
self.main_value = main_value

def match(self, style, main_name=None):
# main_name can't be accessed the "normal" way without causing a loop; we need
# to access the private stored value.
return (
super().match(style) and getattr(style, f"_{main_name}") == self.main_value
)


class alignment_property(validated_property):
def __set_name__(self, owner, name):
# Hard-coded because it's only called on alignment, not align_items.

self.name = "alignment"
owner._BASE_ALL_PROPERTIES[owner].add("alignment")
self.other = "align_items"
self.derive = {
AlignmentCondition(CENTER): CENTER,
AlignmentCondition(START, direction=COLUMN, text_direction=LTR): LEFT,
AlignmentCondition(START, direction=COLUMN, text_direction=RTL): RIGHT,
AlignmentCondition(START, direction=ROW): TOP,
AlignmentCondition(END, direction=COLUMN, text_direction=LTR): RIGHT,
AlignmentCondition(END, direction=COLUMN, text_direction=RTL): LEFT,
AlignmentCondition(END, direction=ROW): BOTTOM,
}

# Replace the align_items validated_property
owner.align_items = alignment_property(START, CENTER, END)
owner.align_items.name = "align_items"
owner.align_items.other = "alignment"
owner.align_items.derive = {
AlignmentCondition(result, **condition.properties): condition.main_value
for condition, result in self.derive.items()
}

def __get__(self, obj, objtype=None):
if obj is None:
return self

self.warn_if_deprecated()

if not hasattr(obj, f"_{self.name}"):
if hasattr(obj, f"_{self.other}"):
for condition, value in self.derive.items():
if condition.match(obj, main_name=self.other):
return value

return self.initial

return super().__get__(obj)

def __set__(self, obj, value):
if value is self:
# This happens during autogenerated dataclass __init__ when no value is
# supplied.
return

self.warn_if_deprecated()

try:
delattr(obj, f"_{self.other}")
except AttributeError:
pass
super().__set__(obj, value)

def __delete__(self, obj):
self.warn_if_deprecated()

try:
delattr(obj, f"_{self.other}")
except AttributeError:
pass
super().__delete__(obj)

def is_set_on(self, obj):
self.warn_if_deprecated()

return super().is_set_on(obj) or hasattr(obj, f"_{self.other}")

def warn_if_deprecated(self):
if self.name == "alignment":
warnings.warn(
"Pack.alignment is deprecated. Use Pack.align_items instead.",
DeprecationWarning,
stacklevel=3,
)


######################################################################
# End backwards compatibility
######################################################################

if sys.version_info < (3, 10):
_DATACLASS_KWARGS = {"init": False}
else:
_DATACLASS_KWARGS = {"kw_only": True}


@dataclass(**_DATACLASS_KWARGS)
class Pack(BaseStyle):
_doc_link = ":doc:`style properties </reference/style/pack>`"

Expand All @@ -77,9 +183,6 @@ class IntrinsicSize(BaseIntrinsicSize):
visibility: str = validated_property(VISIBLE, HIDDEN, initial=VISIBLE)
direction: str = validated_property(ROW, COLUMN, initial=ROW)
align_items: str | None = validated_property(START, CENTER, END)
alignment: str | None = validated_property(
LEFT, RIGHT, TOP, BOTTOM, CENTER
) # Deprecated
justify_content: str | None = validated_property(START, CENTER, END, initial=START)
gap: int = validated_property(integer=True, initial=0)

Expand Down Expand Up @@ -109,153 +212,48 @@ class IntrinsicSize(BaseIntrinsicSize):
font_weight: str = validated_property(*FONT_WEIGHTS, initial=NORMAL)
font_size: int = validated_property(integer=True, initial=SYSTEM_DEFAULT_FONT_SIZE)

@classmethod
def _debug(cls, *args: str) -> None: # pragma: no cover
print(" " * cls._depth, *args)
######################################################################
# Directional aliases
######################################################################

@property
def _hidden(self) -> bool:
"""Does this style declaration define an object that should be hidden."""
return self.visibility == HIDDEN
horizontal_align_content: str | None = aliased_property(
source={Condition(direction=ROW): "justify_content"}
)
horizontal_align_items: str | None = aliased_property(
source={Condition(direction=COLUMN): "align_items"}
)
vertical_align_content: str | None = aliased_property(
source={Condition(direction=COLUMN): "justify_content"}
)
vertical_align_items: str | None = aliased_property(
source={Condition(direction=ROW): "align_items"}
)

######################################################################
# 2024-12: Backwards compatibility for Toga < 0.5.0
######################################################################

def update(self, **properties):
# Set direction first, as it may change the interpretation of direction-based
# property aliases in _update_property_name.
if direction := properties.pop("direction", None):
self.direction = direction
padding: int | tuple[int] = aliased_property(source="margin", deprecated=True)
padding_top: int = aliased_property(source="margin_top", deprecated=True)
padding_right: int = aliased_property(source="margin_right", deprecated=True)
padding_bottom: int = aliased_property(source="margin_bottom", deprecated=True)
padding_left: int = aliased_property(source="margin_left", deprecated=True)

properties = {
self._update_property_name(name.replace("-", "_")): value
for name, value in properties.items()
}
super().update(**properties)

_DEPRECATED_PROPERTIES = {
# Map each deprecated property name to its replacement.
# alignment / align_items is handled separately.
"padding": "margin",
"padding_top": "margin_top",
"padding_right": "margin_right",
"padding_bottom": "margin_bottom",
"padding_left": "margin_left",
}

_ALIASES = {
"horizontal_align_content": {ROW: "justify_content"},
"horizontal_align_items": {COLUMN: "align_items"},
"vertical_align_content": {COLUMN: "justify_content"},
"vertical_align_items": {ROW: "align_items"},
}

def _update_property_name(self, name):
if aliases := self._ALIASES.get(name):
try:
name = aliases[self.direction]
except KeyError:
raise AttributeError(
f"{name!r} is not supported on a {self.direction}"
) from None

if new_name := self._DEPRECATED_PROPERTIES.get(name):
self._warn_deprecated(name, new_name, stacklevel=4)
name = new_name

return name

def _warn_deprecated(self, old_name, new_name, stacklevel=3):
msg = f"Pack.{old_name} is deprecated; use {new_name} instead"
warnings.warn(msg, DeprecationWarning, stacklevel=stacklevel)

# Dot lookup

def __getattribute__(self, name):
if name.startswith("_"):
return super().__getattribute__(name)

# Align_items and alignment are paired. Both can never be set at the same time;
# if one is requested, and the other one is set, compute the requested value
# from the one that is set.
if name == ALIGN_ITEMS and (alignment := super().__getattribute__(ALIGNMENT)):
if alignment == CENTER:
return CENTER

if self.direction == ROW:
if alignment == TOP:
return START
if alignment == BOTTOM:
return END

# No remaining valid combinations
return None

# direction must be COLUMN
if alignment == LEFT:
return START if self.text_direction == LTR else END
if alignment == RIGHT:
return START if self.text_direction == RTL else END

# No remaining valid combinations
return None

if name == ALIGNMENT:
# Warn, whether it's set or not.
self._warn_deprecated(ALIGNMENT, ALIGN_ITEMS)

if align_items := super().__getattribute__(ALIGN_ITEMS):
if align_items == START:
if self.direction == COLUMN:
return LEFT if self.text_direction == LTR else RIGHT
return TOP # for ROW

if align_items == END:
if self.direction == COLUMN:
return RIGHT if self.text_direction == LTR else LEFT
return BOTTOM # for ROW

# Only CENTER remains
return CENTER

return super().__getattribute__(self._update_property_name(name))

def __setattr__(self, name, value):
# Only one of these can be set at a time.
if name == ALIGN_ITEMS:
super().__delattr__(ALIGNMENT)
elif name == ALIGNMENT:
self._warn_deprecated(ALIGNMENT, ALIGN_ITEMS)
super().__delattr__(ALIGN_ITEMS)

super().__setattr__(self._update_property_name(name), value)

def __delattr__(self, name):
# If one of the two is being deleted, delete the other also.
if name == ALIGN_ITEMS:
super().__delattr__(ALIGNMENT)
elif name == ALIGNMENT:
self._warn_deprecated(ALIGNMENT, ALIGN_ITEMS)
super().__delattr__(ALIGN_ITEMS)

super().__delattr__(self._update_property_name(name))

# Index notation

def __getitem__(self, name):
return super().__getitem__(self._update_property_name(name.replace("-", "_")))

def __setitem__(self, name, value):
super().__setitem__(self._update_property_name(name.replace("-", "_")), value)

def __delitem__(self, name):
super().__delitem__(self._update_property_name(name.replace("-", "_")))
alignment: str | None = alignment_property(TOP, RIGHT, BOTTOM, LEFT, CENTER)

######################################################################
# End backwards compatibility
######################################################################

@classmethod
def _debug(cls, *args: str) -> None: # pragma: no cover
print(" " * cls._depth, *args)

@property
def _hidden(self) -> bool:
"""Does this style declaration define an object that should be hidden."""
return self.visibility == HIDDEN

def apply(self, *names: list[str]) -> None:
if self._applicator:
for name in names or self._PROPERTIES:
Expand Down Expand Up @@ -939,6 +937,3 @@ def __css__(self) -> str:
css.append(f"font-variant: {self.font_variant};")

return " ".join(css)


Pack._BASE_ALL_PROPERTIES[Pack].update(Pack._ALIASES)
10 changes: 10 additions & 0 deletions core/tests/style/pack/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,13 @@ def delitem(obj, name):

def delitem_hyphen(obj, name):
del obj[name.replace("_", "-")]


def assert_name_in(name, style):
assert name in style
assert name.replace("_", "-") in style


def assert_name_not_in(name, style):
assert name not in style
assert name.replace("_", "-") not in style
Loading
Loading