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

Set properties on copied style before setting applicator #224

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

HalfWhitt
Copy link
Contributor

Fixes beeware/toga#2916

As discussed in beeware/toga#2919, the root cause wasn't in Toga, but in Travertino. Setting the applicator before assigning properties on the newly created duplicate style meant that those assignments were triggering apply, even though no implementation was yet available.

This passes both Travertino's tests as well as Toga's. CI will only verify the former, so double-checking my work on a local copy of the two together probably isn't a bad idea.

Speaking of which, that's something that seems to come up pretty often for me in working on what is primarily a subproject like this. I bet it would be complicated to set up some way to trigger running Toga's tests against a branch here, but it would be an extra bit of insurance... this bug would've been impossible to miss back in March.

PR Checklist:

  • All new features have been tested
  • All new features have been documented
  • I have read the CONTRIBUTING.md file
  • I will abide by the code of conduct

@HalfWhitt HalfWhitt changed the title Delay assigning applicator until after setting properties on copied s… Set properties on copied style before setting applicator Oct 18, 2024
@HalfWhitt
Copy link
Contributor Author

HalfWhitt commented Oct 18, 2024

Actually now that I think about it, is there a reason the applicator needs to be set in the copy method? Couldn't the caller just set the applicator on the copy after making it?

@HalfWhitt
Copy link
Contributor Author

HalfWhitt commented Oct 18, 2024

Just for some more context of what's happening when, the tests in test_apply.py all behave pretty similarly:

def test_set_default_right_textalign_when_rtl():
    root = ExampleNode("app", style=Pack(text_direction=RTL))
    root.style.reapply()
    # Two calls; one caused by text_align, one because text_direction
    # implies a change to text alignment.
    assert root._impl.set_alignment.mock_calls == [call(RIGHT), call(RIGHT)]

This works as expected because ExampleNode._impl isn't mocked until after it's called super.__init__():

class ExampleNode(Node):
    def __init__(self, name, style, size=None, children=None):
        super().__init__(
            style=style, children=children, applicator=TogaApplicator(self)
        )

        self.name = name
        self._impl = Mock()

If you move self._impl = Mock() to before super.__init(), the mock records an extra call(RIGHT); the intended two during reapply and an extra time first, before Node is finished initializing. The equivalent happens for all the other tests in the file.

This extra call — which in the case of Widget always fails because there's no _impl — is what the except in Travertino has been hiding all this time.

I still don't 100% understand the intended behavior of all the internals at play here, but the way these tests are written leads me to believe those early calls are not intended behavior, right? If they are supposed to be doing something, then yeah, _impl needs to be set sooner. Either that or like you mentioned, an apply afterward.

@freakboy3742
Copy link
Member

Actually now that I think about it, is there a reason the applicator needs to be set in the copy method? Couldn't the caller just set the applicator on the copy after making it?

I think the motivation to passing the applicator into the copy call was to ensure that the applicator is in place before the style values are set, so that all the properties being set are applied. This could also be achieved by applying all the values at the time the applicator is assigned - arguably, that would be a better approach, because at present, changing the applicator won't have the same effect as copying a style and passing in the new applicator.

I still don't 100% understand the intended behavior of all the internals at play here, but the way these tests are written leads me to believe those early calls are not intended behavior, right? If they are supposed to be doing something, then yeah, _impl needs to be set sooner. Either that or like you mentioned, an apply afterward.

If it makes you feel any better... I'm not sure I understand the intended behavior either... :-)

The current implementation definitely works... but it definitely appears that there's some implied behaviour and reliance on specific behaviours in Toga's usage of Travertino.

The core requirements are:

  1. An applicator is needed to apply a style to a node.
  2. We can't use an applicator fully until the node has been fully instiated.
  3. We can set values on the style before the applicator is set.
  4. We need all values of the style to be set using the applicator.

Trying to reverse engineer what I was thinking (mumble) years ago, I think I was trying to ensure that the applicator always existed, so you could always set properties on it, and then catch the consequences of an incomplete Node in the applicator. However, in retrospect, I think it might be better to use the existence of the applicator as the signal that a style can be applied, use the act of setting the applicator to apply the "initial" style, and then require that the node is ready to be applied onto at the time the applicator is assigned. Does that make sense?

Speaking of which, that's something that seems to come up pretty often for me in working on what is primarily a subproject like this. I bet it would be complicated to set up some way to trigger running Toga's tests against a branch here, but it would be an extra bit of insurance... this bug would've been impossible to miss back in March.

Agreed that a "canary" like this would be a worthwhile addition - since Toga is the main consumer of Travertino, it would be worth asserting that updates in Toga (or Travertino, for that matter), aren't going to break Toga on the next release.

@HalfWhitt HalfWhitt marked this pull request as draft October 22, 2024 02:14
@HalfWhitt
Copy link
Contributor Author

HalfWhitt commented Oct 22, 2024

So you're proposing that applicator become a property? I think that would make sense, and would go well with doing the same to style like I described in the latter half of my comment on the other pull request. Setting applicator always triggers a reapply, and setting style does so as well, but only if applicator is already set.

So, looking through the code, this is a sketch of what's currently happening:

WidgetSubclass.__init__(style)
|   
|   Widget.__init__(style=style)
|   |   
|   |   Node.__init__(style=style if style else Pack(), applicator=TogaApplicator(self))
|   |   |   
|   |   |   self.applicator = applicator
|   |   |   self.style = style.copy(applicator)
|   |   |   |   
|   |   |   |   BaseStyle.copy(applicator)
|   |   |   |   |   
|   |   |   |   |   dup.applicator = applicator
|   |   |   |   |   dup.update(**self)
|   |   |   |   |   # Each property added sends a call to Pack.apply, which fails
|   |   |   |   |   # with an AttributeError because the widget doesn't have _impl set yet.
|   |   |   |   |   
|   |   |   |   |   return dup
|   |   |   |   |___
|   |   |   |_______
|   |   |___________
|   |   
|   |   self._impl = None
|   |   self.factory = get_platform_factory()
|   |______
| 
|   self._impl = self.factory.WidgetSubClass(interface=self)
|   |   
|   |   ImplementationWidget.__init__(interface=interface)
|   |   |   
|   |   |   self.interface = interface
|   |   |   self.interface._impl = self
|   |   |   # Unnecessary, since this is already being assigned to self._impl above
|   |   |   
|   |   |   self.create()
|   |   |   self.interface.style.reapply()
|   |   |   # This is where all the styles are actually applied for the first time.
|   |   |___________
|   |_______________
|___________________

That final self.interface.reapply() is, if I'm not mistaken, the place where the style is actually applied, and I imagine that's why it was never apparent that the earlier attempt was silently failing.

Now, currently the widget subclass is calling Widget.__init__ first, and then creating its implementation. It would be nice to put the final applicator assignment (and thus style application) in Widget.__init__, since it will have to happen for every widget. But we can't swap the order and create the implementation before Widget.__init__, since that's where the factory is being assigned.

As far as I can see, each widget always calls self.factory.NameOfThisSubclass. That could become getattr(self.factory, type(self).__name__) and get shoved into the base Widget initializer... except that would break any subclass of a Toga widget. Because of the shared logic and necessary order, I'm thinking it might still be worth factoring that out into a class attribute (or a read-only property?) of each widget subclass.

So I think we're looking at something like this:

WidgetSubclass.__init__(style)
|
|   Widget.__init__(style=style)
|   |   
|   |   Node.__init__(style=style if style else Pack(), applicator=None)
|   |   |   
|   |   |   self.applicator = applicator # which will have been passed in as None
|   |   |   self.style = style
|   |   |   # Style property triggers a copy when set.
|   |   |   |   
|   |   |   |   BaseStyle.copy()
|   |   |   |   |   dup.update(**self)
|   |   |   |   |   # Each call to Pack.apply will see a lack of applicator and do nothing.
|   |   |   |   |   
|   |   |   |   |   return dup
|   |   |   |   |___
|   |   |   |_______
|   |   |___________
|   |
|   |   self.factory = get_platform_factory()
|   |   self._impl = getattr(self.factory, self.however_we_store_implementation_name)(interface=self)
|   |   |
|   |   |   ImplementationWidget.__init__(interface=interface)
|   |   |   |   
|   |   |   |   self.interface = interface
|   |   |   |   self.create()
|   |   |   |_______
|   |   |___________
|   |
|   |   self.applicator = TogaApplicator(self)
|   |   # Applicator property will trigger a reapply when set.
|   |_______________
|___________________

I will, of course, have to pay close attention that updates to Travertino are backwards compatible with Toga. One of the first things will be to deprecate the applicator argument to BaseStyle.copy().

Does all that seem like a reasonable starting point?

@freakboy3742
Copy link
Member

So you're proposing that applicator become a property?

Correct.

I will, of course, have to pay close attention that updates to Travertino are backwards compatible with Toga. One of the first things will be to deprecate the applicator argument to BaseStyle.copy().

I don't think that's necessarily a requirement - it would be possible to accomodate the API that accepts applicator, and just assign applicator after all the properties have been assigned. However, I think it might be worth doing as a "why is this even possible in the first place" API cleanup.

Does all that seem like a reasonable starting point?

Yes - the summary of current/future state looks right to me, and the broad approach you've outlined looks like it makes sense.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

'ExampleWidget' object has no attribute '_impl' when using main branch Travertino
2 participants