From 0e01744ffe71e510e027e85a2234b016bd8d9b73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Aracheski?= <21148235+joaoaracheski@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:42:33 -0300 Subject: [PATCH 1/9] Notify observers on in-place operators of observable collections Fixes #6293. ObservableList now handles += and *=, and ObservableDict handles |=; before, these mutated the collection without notifying, and the following attribute assignment of the same object was treated as no change. --- .../flet/src/flet/components/observable.py | 26 +++++++++++++ .../packages/flet/tests/test_observable.py | 39 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/sdk/python/packages/flet/src/flet/components/observable.py b/sdk/python/packages/flet/src/flet/components/observable.py index 5bb13bf913..670f42edb8 100644 --- a/sdk/python/packages/flet/src/flet/components/observable.py +++ b/sdk/python/packages/flet/src/flet/components/observable.py @@ -275,6 +275,24 @@ def extend(self, it): super().extend(self._wrap(v) for v in it) self._touch() + def __iadd__(self, other): + """ + Extend list in place (`+=`) and notify observers. + """ + + # Snapshot first: `items += items` must not iterate a list that grows. + self.extend(list(other)) + return self + + def __imul__(self, n): + """ + Repeat list items in place (`*=`) and notify observers. + """ + + super().__imul__(n) + self._touch() + return self + def insert(self, i, x): """ Insert an item at a specific index and notify observers. @@ -401,6 +419,14 @@ def update(self, *a, **k): super().update(*a, **{kk: self._wrap(vv) for kk, vv in k.items()}) self._touch() + def __ior__(self, other): + """ + Merge a mapping in place (`|=`) and notify observers. + """ + + self.update(other) + return self + def pop(self, k, *d): """ Remove key and return its value, then notify observers. diff --git a/sdk/python/packages/flet/tests/test_observable.py b/sdk/python/packages/flet/tests/test_observable.py index 5fbb147939..4d6cc7a355 100644 --- a/sdk/python/packages/flet/tests/test_observable.py +++ b/sdk/python/packages/flet/tests/test_observable.py @@ -131,3 +131,42 @@ def test_observable_repr(): foo.prop_b = "value2" updated_repr_2 = repr(foo) assert "version=2" in updated_repr_2 + + +def test_list_inplace_operators_notify(): + changes = [] + + def subscriber(sender, field): + changes.append(field) + + baz = Baz(f=1) + baz.subscribe(subscriber) + + baz.prop_d += [4] + assert baz.prop_d == [1, 2, 3, 4] + assert changes == ["prop_d"] + + baz.prop_d *= 2 + assert baz.prop_d == [1, 2, 3, 4, 1, 2, 3, 4] + assert changes == ["prop_d", "prop_d"] + + +def test_list_iadd_with_itself(): + baz = Baz(f=1) + + baz.prop_d += baz.prop_d + assert baz.prop_d == [1, 2, 3, 1, 2, 3] + + +def test_dict_inplace_union_notifies(): + changes = [] + + def subscriber(sender, field): + changes.append(field) + + qux = Qux() + qux.subscribe(subscriber) + + qux.prop_e |= {"key1": "value1"} + assert qux.prop_e == {"key1": "value1"} + assert changes == ["prop_e"] From 8b8a61be88267b99295c47884503136d8b437e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Aracheski?= <21148235+joaoaracheski@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:54:30 -0300 Subject: [PATCH 2/9] Add changelog entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59ea0b80e6..14691f8aae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.1.3 + +### Bug fixes + +* Fix `@ft.observable` not refreshing the UI after in-place operators: `+=` and `*=` on `ObservableList` and `|=` on `ObservableDict` now notify observers ([#6293](https://github.com/flet-dev/flet/issues/6293), [#7086](https://github.com/flet-dev/flet/pull/7086)) by @joaoaracheski. + ## 1.1.2 ### Improvements From 34c858cbaae46e133146f9210e855ae671077117 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sat, 14 Nov 2026 01:23:41 +0100 Subject: [PATCH 3/9] Read items before extending an observable list; wrap all values merged into an observable dict --- .../flet/src/flet/components/observable.py | 14 ++++--- .../packages/flet/tests/test_observable.py | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/sdk/python/packages/flet/src/flet/components/observable.py b/sdk/python/packages/flet/src/flet/components/observable.py index 670f42edb8..004d4a4331 100644 --- a/sdk/python/packages/flet/src/flet/components/observable.py +++ b/sdk/python/packages/flet/src/flet/components/observable.py @@ -270,9 +270,12 @@ def append(self, x): def extend(self, it): """ Extend list with iterable items and notify observers. + + The items are read before the list grows, so `xs.extend(xs)` appends + one copy of the list's items. """ - super().extend(self._wrap(v) for v in it) + super().extend([self._wrap(v) for v in it]) self._touch() def __iadd__(self, other): @@ -280,8 +283,7 @@ def __iadd__(self, other): Extend list in place (`+=`) and notify observers. """ - # Snapshot first: `items += items` must not iterate a list that grows. - self.extend(list(other)) + self.extend(other) return self def __imul__(self, n): @@ -412,11 +414,11 @@ def update(self, *a, **k): """ Update mapping with values and notify observers. - Positional mapping/iterable arguments are forwarded to `dict.update()`; - keyword values are wrapped when needed. + Takes the same arguments as `dict.update()`. List and dict values are + wrapped, whether they come from a mapping, key-value pairs or keywords. """ - super().update(*a, **{kk: self._wrap(vv) for kk, vv in k.items()}) + super().update({kk: self._wrap(vv) for kk, vv in dict(*a, **k).items()}) self._touch() def __ior__(self, other): diff --git a/sdk/python/packages/flet/tests/test_observable.py b/sdk/python/packages/flet/tests/test_observable.py index 4d6cc7a355..607e9f2171 100644 --- a/sdk/python/packages/flet/tests/test_observable.py +++ b/sdk/python/packages/flet/tests/test_observable.py @@ -1,5 +1,7 @@ from dataclasses import dataclass, field +import pytest + import flet as ft @@ -170,3 +172,38 @@ def subscriber(sender, field): qux.prop_e |= {"key1": "value1"} assert qux.prop_e == {"key1": "value1"} assert changes == ["prop_e"] + + +def test_list_extend_with_itself(): + """`extend` with the list itself appends one copy of its items.""" + baz = Baz(f=1) + + baz.prop_d.extend(baz.prop_d) + assert baz.prop_d == [1, 2, 3, 1, 2, 3] + + +@pytest.mark.parametrize( + "merge", + [ + lambda d: d.update({"items": []}), + lambda d: d.update([("items", [])]), + lambda d: d.update(items=[]), + lambda d: d.__ior__({"items": []}), + ], + ids=["mapping", "pairs", "keywords", "ior"], +) +def test_dict_merge_wraps_nested_values(merge): + """Lists merged into an observable dict notify when they change later.""" + changes = [] + + def subscriber(sender, field): + changes.append(field) + + qux = Qux() + qux.subscribe(subscriber) + + merge(qux.prop_e) + changes.clear() + qux.prop_e["items"].append(1) + assert qux.prop_e == {"items": [1]} + assert changes == ["prop_e"] From c7cfedffea16320cda13a5aafd0e97c3fa6c1dc0 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sat, 14 Nov 2026 01:34:02 +0100 Subject: [PATCH 4/9] Group observable tests into classes and add docstrings --- .../packages/flet/tests/test_observable.py | 312 ++++++++++-------- 1 file changed, 166 insertions(+), 146 deletions(-) diff --git a/sdk/python/packages/flet/tests/test_observable.py b/sdk/python/packages/flet/tests/test_observable.py index 607e9f2171..b72a1678e3 100644 --- a/sdk/python/packages/flet/tests/test_observable.py +++ b/sdk/python/packages/flet/tests/test_observable.py @@ -6,204 +6,224 @@ class Foo(ft.Observable): + """Observable with annotated fields and no dataclass.""" + prop_a: str prop_b: str @dataclass class Bar: + """Plain dataclass base for `Baz`.""" + f: int d: list[int] = field(default_factory=list) @dataclass class Baz(Bar, ft.Observable): + """Observable dataclass that inherits fields from a plain dataclass.""" + prop_c: int = 3 prop_d: list[int] = field(default_factory=lambda: [1, 2, 3]) def add_item(self, item: int): + """Appends `item` to `prop_d`.""" self.prop_d.append(item) @dataclass class Qux(ft.Observable): + """Observable dataclass with a dict field.""" + prop_e: dict = field(default_factory=dict) def update_key(self, key, value): + """Sets `key` in `prop_e`.""" self.prop_e[key] = value -def test_foo_observable(): - changes = [] - - def subscriber(sender, field): - changes.append((sender, field)) - - foo = Foo() - foo.subscribe(subscriber) - - foo.prop_a = "test_value" - assert len(changes) == 1 - assert changes[0][0] == foo - assert changes[0][1] == "prop_a" - - foo.prop_b = "another_value" - assert len(changes) == 2 - assert changes[1][0] == foo - assert changes[1][1] == "prop_b" - - -def test_bar_observable(): - changes = [] - - def subscriber(sender, field): - changes.append((sender, field)) - - baz = Baz(42) - baz.subscribe(subscriber) - - baz.prop_a = "new_value" - assert len(changes) == 1 - assert changes[0][0] == baz - assert changes[0][1] == "prop_a" - - baz.prop_c = 100 - assert len(changes) == 2 - assert changes[1][0] == baz - assert changes[1][1] == "prop_c" - - baz.add_item(5) - assert baz.prop_d == [1, 2, 3, 5] - assert len(changes) == 3 - assert changes[2][0] == baz - assert changes[2][1] == "prop_d" - - baz.f = 60 - assert len(changes) == 4 - assert changes[3][0] == baz - assert changes[3][1] == "f" - - baz.d = [1, 2, 3] - assert len(changes) == 5 - assert changes[4][0] == baz - assert changes[4][1] == "d" - - baz.d.reverse() - assert len(changes) == 6 - assert changes[5][0] == baz - assert changes[5][1] == "d" - - -def test_qux_observable(): - changes = [] - - def subscriber(sender, field): - changes.append((sender, field)) - - qux = Qux() - qux.subscribe(subscriber) - - qux.prop_e = {"key1": "value1"} - assert len(changes) == 1 - assert changes[0][0] == qux - assert changes[0][1] == "prop_e" +class TestObservable: + """Notifications and versioning of observable objects.""" - qux.update_key("key2", "value2") - assert qux.prop_e == {"key1": "value1", "key2": "value2"} - assert len(changes) == 2 - assert changes[1][0] == qux - assert changes[1][1] == "prop_e" + def test_field_assignment_notifies(self): + """Assigning a field notifies with the sender and the field name.""" + changes = [] - qux.prop_e["key1"] = "updated_value1" - assert qux.prop_e["key1"] == "updated_value1" - assert len(changes) == 3 - assert changes[2][0] == qux - assert changes[2][1] == "prop_e" + def subscriber(sender, field): + changes.append((sender, field)) + foo = Foo() + foo.subscribe(subscriber) -def test_observable_repr(): - foo = Foo() - initial_repr = repr(foo) - assert "version=0" in initial_repr + foo.prop_a = "test_value" + assert len(changes) == 1 + assert changes[0][0] == foo + assert changes[0][1] == "prop_a" - foo.prop_a = "value1" - updated_repr = repr(foo) - assert "version=1" in updated_repr + foo.prop_b = "another_value" + assert len(changes) == 2 + assert changes[1][0] == foo + assert changes[1][1] == "prop_b" - foo.prop_b = "value2" - updated_repr_2 = repr(foo) - assert "version=2" in updated_repr_2 + def test_dataclass_subclass_notifies(self): + """Own, inherited and list fields of an observable dataclass notify.""" + changes = [] + def subscriber(sender, field): + changes.append((sender, field)) -def test_list_inplace_operators_notify(): - changes = [] + baz = Baz(42) + baz.subscribe(subscriber) - def subscriber(sender, field): - changes.append(field) + baz.prop_a = "new_value" + assert len(changes) == 1 + assert changes[0][0] == baz + assert changes[0][1] == "prop_a" - baz = Baz(f=1) - baz.subscribe(subscriber) + baz.prop_c = 100 + assert len(changes) == 2 + assert changes[1][0] == baz + assert changes[1][1] == "prop_c" - baz.prop_d += [4] - assert baz.prop_d == [1, 2, 3, 4] - assert changes == ["prop_d"] + baz.add_item(5) + assert baz.prop_d == [1, 2, 3, 5] + assert len(changes) == 3 + assert changes[2][0] == baz + assert changes[2][1] == "prop_d" - baz.prop_d *= 2 - assert baz.prop_d == [1, 2, 3, 4, 1, 2, 3, 4] - assert changes == ["prop_d", "prop_d"] + baz.f = 60 + assert len(changes) == 4 + assert changes[3][0] == baz + assert changes[3][1] == "f" + baz.d = [1, 2, 3] + assert len(changes) == 5 + assert changes[4][0] == baz + assert changes[4][1] == "d" -def test_list_iadd_with_itself(): - baz = Baz(f=1) + baz.d.reverse() + assert len(changes) == 6 + assert changes[5][0] == baz + assert changes[5][1] == "d" - baz.prop_d += baz.prop_d - assert baz.prop_d == [1, 2, 3, 1, 2, 3] + def test_repr_shows_version(self): + """`repr` includes a version that grows with each change.""" + foo = Foo() + initial_repr = repr(foo) + assert "version=0" in initial_repr + foo.prop_a = "value1" + updated_repr = repr(foo) + assert "version=1" in updated_repr -def test_dict_inplace_union_notifies(): - changes = [] + foo.prop_b = "value2" + updated_repr_2 = repr(foo) + assert "version=2" in updated_repr_2 - def subscriber(sender, field): - changes.append(field) - qux = Qux() - qux.subscribe(subscriber) +class TestObservableList: + """Notifications from list fields of observable objects.""" - qux.prop_e |= {"key1": "value1"} - assert qux.prop_e == {"key1": "value1"} - assert changes == ["prop_e"] + def test_inplace_operators_notify(self): + """`+=` and `*=` change the list in place and notify once each.""" + changes = [] + def subscriber(sender, field): + changes.append(field) -def test_list_extend_with_itself(): - """`extend` with the list itself appends one copy of its items.""" - baz = Baz(f=1) + baz = Baz(f=1) + baz.subscribe(subscriber) - baz.prop_d.extend(baz.prop_d) - assert baz.prop_d == [1, 2, 3, 1, 2, 3] + baz.prop_d += [4] + assert baz.prop_d == [1, 2, 3, 4] + assert changes == ["prop_d"] + baz.prop_d *= 2 + assert baz.prop_d == [1, 2, 3, 4, 1, 2, 3, 4] + assert changes == ["prop_d", "prop_d"] + + def test_iadd_with_itself(self): + """`+=` with the list itself appends one copy of its items.""" + baz = Baz(f=1) + + baz.prop_d += baz.prop_d + assert baz.prop_d == [1, 2, 3, 1, 2, 3] + + def test_extend_with_itself(self): + """`extend` with the list itself appends one copy of its items.""" + baz = Baz(f=1) + + baz.prop_d.extend(baz.prop_d) + assert baz.prop_d == [1, 2, 3, 1, 2, 3] + + +class TestObservableDict: + """Notifications from dict fields of observable objects.""" + + def test_assignment_and_item_set_notify(self): + """Assigning the dict field and setting its keys notify.""" + changes = [] + + def subscriber(sender, field): + changes.append((sender, field)) + + qux = Qux() + qux.subscribe(subscriber) + + qux.prop_e = {"key1": "value1"} + assert len(changes) == 1 + assert changes[0][0] == qux + assert changes[0][1] == "prop_e" + + qux.update_key("key2", "value2") + assert qux.prop_e == {"key1": "value1", "key2": "value2"} + assert len(changes) == 2 + assert changes[1][0] == qux + assert changes[1][1] == "prop_e" + + qux.prop_e["key1"] = "updated_value1" + assert qux.prop_e["key1"] == "updated_value1" + assert len(changes) == 3 + assert changes[2][0] == qux + assert changes[2][1] == "prop_e" + + def test_inplace_union_notifies(self): + """`|=` merges into the dict in place and notifies once.""" + changes = [] + + def subscriber(sender, field): + changes.append(field) + + qux = Qux() + qux.subscribe(subscriber) + + qux.prop_e |= {"key1": "value1"} + assert qux.prop_e == {"key1": "value1"} + assert changes == ["prop_e"] -@pytest.mark.parametrize( - "merge", - [ - lambda d: d.update({"items": []}), - lambda d: d.update([("items", [])]), - lambda d: d.update(items=[]), - lambda d: d.__ior__({"items": []}), - ], - ids=["mapping", "pairs", "keywords", "ior"], -) -def test_dict_merge_wraps_nested_values(merge): - """Lists merged into an observable dict notify when they change later.""" - changes = [] + @pytest.mark.parametrize( + "merge", + [ + lambda d: d.update({"items": []}), + lambda d: d.update([("items", [])]), + lambda d: d.update(items=[]), + lambda d: d.__ior__({"items": []}), + ], + ids=["mapping", "pairs", "keywords", "ior"], + ) + def test_merge_wraps_nested_values(self, merge): + """Lists merged into the dict notify when they change later.""" + changes = [] - def subscriber(sender, field): - changes.append(field) + def subscriber(sender, field): + changes.append(field) - qux = Qux() - qux.subscribe(subscriber) + qux = Qux() + qux.subscribe(subscriber) - merge(qux.prop_e) - changes.clear() - qux.prop_e["items"].append(1) - assert qux.prop_e == {"items": [1]} - assert changes == ["prop_e"] + merge(qux.prop_e) + changes.clear() + qux.prop_e["items"].append(1) + assert qux.prop_e == {"items": [1]} + assert changes == ["prop_e"] From b45124c96df355d29acfaae8e18c310eb18e2b5c Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sat, 14 Nov 2026 03:06:06 +0100 Subject: [PATCH 5/9] Mention the extend and update fixes in the changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14691f8aae..507dbc6401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### Bug fixes -* Fix `@ft.observable` not refreshing the UI after in-place operators: `+=` and `*=` on `ObservableList` and `|=` on `ObservableDict` now notify observers ([#6293](https://github.com/flet-dev/flet/issues/6293), [#7086](https://github.com/flet-dev/flet/pull/7086)) by @joaoaracheski. +* Fix `@ft.observable` not refreshing the UI after `+=` and `*=` on lists and `|=` on dicts, `extend()` never returning when given the list itself, and lists or dicts merged into a dict with `update()` or `|=` not notifying when changed later ([#6293](https://github.com/flet-dev/flet/issues/6293), [#7086](https://github.com/flet-dev/flet/pull/7086)) by @joaoaracheski. ## 1.1.2 From f1ac7b0bea24e34ae5cd3a2a94814ef500780035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Aracheski?= <21148235+joaoaracheski@users.noreply.github.com> Date: Sat, 14 Nov 2026 00:18:59 -0300 Subject: [PATCH 6/9] Keep extend and update incremental, skip same-child write-backs, document notification ordering - extend() appends one item at a time again; self-extension uses list.extend(self) to keep references. - update() inserts entries one at a time, wraps values as they are inserted and notifies once. - Writing the same observable child back into its slot (nested +=, *=, |=) no longer notifies twice. - Document that augmented assignments notify before Python writes the result back. --- CHANGELOG.md | 2 +- .../flet/src/flet/components/observable.py | 91 +++++++++++-- .../packages/flet/tests/test_observable.py | 120 ++++++++++++++++++ 3 files changed, 202 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 507dbc6401..d77a19005e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### Bug fixes -* Fix `@ft.observable` not refreshing the UI after `+=` and `*=` on lists and `|=` on dicts, `extend()` never returning when given the list itself, and lists or dicts merged into a dict with `update()` or `|=` not notifying when changed later ([#6293](https://github.com/flet-dev/flet/issues/6293), [#7086](https://github.com/flet-dev/flet/pull/7086)) by @joaoaracheski. +* Fix `@ft.observable` not refreshing the UI after `+=` and `*=` on lists and `|=` on dicts, `extend()` never returning when given the list itself, lists or dicts merged into a dict with `update()` or `|=` not notifying when changed later, and `+=`, `*=` and `|=` on a nested list or dict notifying twice ([#6293](https://github.com/flet-dev/flet/issues/6293), [#7086](https://github.com/flet-dev/flet/pull/7086)) by @joaoaracheski. ## 1.1.2 diff --git a/sdk/python/packages/flet/src/flet/components/observable.py b/sdk/python/packages/flet/src/flet/components/observable.py index 004d4a4331..63a3dd878c 100644 --- a/sdk/python/packages/flet/src/flet/components/observable.py +++ b/sdk/python/packages/flet/src/flet/components/observable.py @@ -34,6 +34,13 @@ class MyDataClass: x: int y: int ``` + + Note: + Listeners are called synchronously. With augmented assignments such as + `obj.items += [x]`, `*=` on lists and `|=` on dicts, they run before + Python assigns the result back to the field, so a listener that + replaces `obj.items` at that moment is overwritten. Direct calls such + as `obj.items.extend([x])` keep the listener's replacement. """ if Observable in cls.__mro__: return cls @@ -271,16 +278,24 @@ def extend(self, it): """ Extend list with iterable items and notify observers. - The items are read before the list grows, so `xs.extend(xs)` appends - one copy of the list's items. + Items are appended one at a time, like `list.extend()`. `xs.extend(xs)` + appends one copy of the list's items, keeping their references. """ - super().extend([self._wrap(v) for v in it]) + if it is self: + super().extend(self) + else: + super().extend(self._wrap(v) for v in it) self._touch() def __iadd__(self, other): """ Extend list in place (`+=`) and notify observers. + + Observers are notified before Python assigns the result back to the + field, so a listener that replaces the field during `obj.items += [x]` + is overwritten by that assignment. Call `obj.items.extend([x])` instead + to keep such a replacement. """ self.extend(other) @@ -289,6 +304,9 @@ def __iadd__(self, other): def __imul__(self, n): """ Repeat list items in place (`*=`) and notify observers. + + Observers are notified before Python assigns the result back, as with + `+=`. """ super().__imul__(n) @@ -351,6 +369,13 @@ def pop(self, i=-1): return v def __setitem__(self, i, v): + # `items[0] += [x]` writes the same child back after it already notified. + if ( + isinstance(i, int) + and -len(self) <= i < len(self) + and _is_written_back(self, super().__getitem__(i), v) + ): + return super().__setitem__(i, self._wrap(v)) self._touch() @@ -359,6 +384,32 @@ def __delitem__(self, i): self._touch() +def _is_written_back(container, current, value) -> bool: + """ + Tell whether `value` is the observable child already stored in that slot. + + Python ends an augmented assignment on an item, such as + `obj.mapping["items"] *= 2`, by writing the result back into the + container. The child already notified, so that write must not notify again. + + Args: + container: Observable list or dict receiving the write. + current: Value currently stored in the slot. + value: Value being written. + + Returns: + `True` when `value` is `current` and both wrappers report to the same + owner and field. + """ + + return ( + value is current + and isinstance(value, (ObservableList, ObservableDict)) + and value._owner_ref() is container._owner_ref() + and value._field == container._field + ) + + class ObservableDict(dict): """ Dict wrapper that reports mutating operations to its owning observable. @@ -395,6 +446,9 @@ def _wrap(self, v): return owner._wrap_if_collection(self._field, v) if owner else v def __setitem__(self, k, v): + # `d["items"] += [x]` writes the same child back after it already notified. + if k in self and _is_written_back(self, super().__getitem__(k), v): + return super().__setitem__(k, self._wrap(v)) self._touch() @@ -412,18 +466,35 @@ def clear(self): def update(self, *a, **k): """ - Update mapping with values and notify observers. - - Takes the same arguments as `dict.update()`. List and dict values are - wrapped, whether they come from a mapping, key-value pairs or keywords. - """ - - super().update({kk: self._wrap(vv) for kk, vv in dict(*a, **k).items()}) + Update mapping with values and notify observers once. + + Takes the same arguments as `dict.update()`: a mapping or an iterable of + key-value pairs, then keywords. Entries are inserted one at a time and + list and dict values are wrapped as they are inserted. + """ + + if len(a) > 1: + raise TypeError(f"update expected at most 1 argument, got {len(a)}") + if a: + other = a[0] + if hasattr(other, "keys"): + # `dict.update()` reads `keys()`, which a mapping may define + # differently from iteration. + for kk in other.keys(): # noqa: SIM118 + super().__setitem__(kk, self._wrap(other[kk])) + else: + for kk, vv in other: + super().__setitem__(kk, self._wrap(vv)) + for kk, vv in k.items(): + super().__setitem__(kk, self._wrap(vv)) self._touch() def __ior__(self, other): """ Merge a mapping in place (`|=`) and notify observers. + + Observers are notified before Python assigns the result back, as with + `ObservableList`'s `+=`. """ self.update(other) diff --git a/sdk/python/packages/flet/tests/test_observable.py b/sdk/python/packages/flet/tests/test_observable.py index b72a1678e3..c80776e826 100644 --- a/sdk/python/packages/flet/tests/test_observable.py +++ b/sdk/python/packages/flet/tests/test_observable.py @@ -43,6 +43,15 @@ def update_key(self, key, value): self.prop_e[key] = value +@dataclass +class Quux(ft.Observable): + """Observable dataclass with collections nested in its fields.""" + + mapping: dict = field(default_factory=dict) + grid: list = field(default_factory=list) + rows: list = field(default_factory=lambda: [[1], [2]]) + + class TestObservable: """Notifications and versioning of observable objects.""" @@ -157,6 +166,26 @@ def test_extend_with_itself(self): baz.prop_d.extend(baz.prop_d) assert baz.prop_d == [1, 2, 3, 1, 2, 3] + def test_extend_with_itself_keeps_references(self): + """`extend` with the list itself appends the same nested objects.""" + quux = Quux() + + quux.rows.extend(quux.rows) + assert quux.rows == [[1], [2], [1], [2]] + assert quux.rows[2] is quux.rows[0] + assert quux.rows[3] is quux.rows[1] + + def test_extend_appends_items_one_at_a_time(self): + """A generator passed to `extend` sees the items it already yielded.""" + baz = Baz(f=1) + + def following(): + for _ in range(2): + yield baz.prop_d[-1] + 1 + + baz.prop_d.extend(following()) + assert baz.prop_d == [1, 2, 3, 4, 5] + class TestObservableDict: """Notifications from dict fields of observable objects.""" @@ -227,3 +256,94 @@ def subscriber(sender, field): qux.prop_e["items"].append(1) assert qux.prop_e == {"items": [1]} assert changes == ["prop_e"] + + def test_update_inserts_entries_one_at_a_time(self): + """A generator passed to `update` sees the entries it already yielded.""" + changes = [] + + def subscriber(sender, field): + changes.append(field) + + qux = Qux() + qux.subscribe(subscriber) + + def pairs(): + yield "a", len(qux.prop_e) + yield "b", len(qux.prop_e) + + qux.prop_e.update(pairs()) + assert qux.prop_e == {"a": 0, "b": 1} + assert changes == ["prop_e"] + + +class TestNestedCollections: + """Notifications from collections nested in observable fields.""" + + @pytest.mark.parametrize( + ("container", "slot", "child", "operation", "expected"), + [ + ("mapping", "items", [1], "iadd", [1, 2]), + ("mapping", "items", [1], "imul", [1, 1]), + ("mapping", "sub", {}, "ior", {"a": 1}), + ("grid", 0, [1], "iadd", [1, 2]), + ("grid", 0, [1], "imul", [1, 1]), + ("grid", 0, {}, "ior", {"a": 1}), + ], + ids=[ + "dict-iadd", + "dict-imul", + "dict-ior", + "list-iadd", + "list-imul", + "list-ior", + ], + ) + def test_augmented_assignment_notifies_once( + self, container, slot, child, operation, expected + ): + """`+=`, `*=` and `|=` on a nested collection notify once.""" + changes = [] + + def subscriber(sender, field): + changes.append(field) + + quux = Quux() + outer = getattr(quux, container) + if isinstance(outer, list): + outer.append(child) + else: + outer[slot] = child + quux.subscribe(subscriber) + + if operation == "iadd": + outer[slot] += [2] + elif operation == "imul": + outer[slot] *= 2 + else: + outer[slot] |= {"a": 1} + + assert outer[slot] == expected + assert changes == [container] + + @pytest.mark.parametrize( + ("container", "slot"), [("mapping", "items"), ("grid", 0)], ids=["dict", "list"] + ) + def test_same_child_assignment_is_noop(self, container, slot): + """Assigning a nested collection back to its own slot does not notify.""" + changes = [] + + def subscriber(sender, field): + changes.append(field) + + quux = Quux() + outer = getattr(quux, container) + if isinstance(outer, list): + outer.append([1]) + else: + outer[slot] = [1] + child = outer[slot] + quux.subscribe(subscriber) + + outer[slot] = outer[slot] + assert outer[slot] is child + assert changes == [] From 38af439e2ec681c6972a1c16cd9b244507f22c3d Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sat, 14 Nov 2026 04:23:07 +0100 Subject: [PATCH 7/9] Notify on partial extend/update failures, cover any non-slice index, and merge the observable tests --- CHANGELOG.md | 2 +- .../flet/src/flet/components/observable.py | 162 ++++++----- .../packages/flet/tests/test_observable.py | 265 +++++++++++------- 3 files changed, 263 insertions(+), 166 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d77a19005e..507dbc6401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### Bug fixes -* Fix `@ft.observable` not refreshing the UI after `+=` and `*=` on lists and `|=` on dicts, `extend()` never returning when given the list itself, lists or dicts merged into a dict with `update()` or `|=` not notifying when changed later, and `+=`, `*=` and `|=` on a nested list or dict notifying twice ([#6293](https://github.com/flet-dev/flet/issues/6293), [#7086](https://github.com/flet-dev/flet/pull/7086)) by @joaoaracheski. +* Fix `@ft.observable` not refreshing the UI after `+=` and `*=` on lists and `|=` on dicts, `extend()` never returning when given the list itself, and lists or dicts merged into a dict with `update()` or `|=` not notifying when changed later ([#6293](https://github.com/flet-dev/flet/issues/6293), [#7086](https://github.com/flet-dev/flet/pull/7086)) by @joaoaracheski. ## 1.1.2 diff --git a/sdk/python/packages/flet/src/flet/components/observable.py b/sdk/python/packages/flet/src/flet/components/observable.py index 63a3dd878c..f8e4c32a6a 100644 --- a/sdk/python/packages/flet/src/flet/components/observable.py +++ b/sdk/python/packages/flet/src/flet/components/observable.py @@ -230,6 +230,33 @@ def __repr__(self): # Observable collections ----------------------------------------- +_MISSING = object() + + +def _is_written_back(container, current: Any, value: Any) -> bool: + """ + Tell whether `value` is the observable child already stored in a slot. + + Python writes the result of an augmented assignment such as + `d["items"] += [1]` back into the slot. The child has notified already, so + writing the identical child back to the same owner and field is a no-op. + + Args: + container: Observable list or dict that holds the slot. + current: Value in the slot, or `_MISSING` when the slot is empty. + value: Value being written into the slot. + + Returns: + `True` when `value` is `current` and reports to the same owner and field. + """ + + return ( + value is current + and isinstance(value, (ObservableList, ObservableDict)) + and value._owner_ref() is container._owner_ref() + and value._field == container._field + ) + class ObservableList(list): """ @@ -278,24 +305,31 @@ def extend(self, it): """ Extend list with iterable items and notify observers. - Items are appended one at a time, like `list.extend()`. `xs.extend(xs)` - appends one copy of the list's items, keeping their references. + Items are appended one at a time, as with `list.extend()`. + `xs.extend(xs)` appends the list's current items once, as the same + objects. If the iterable raises after some items were appended, + observers are still notified before the error propagates. """ - if it is self: - super().extend(self) - else: - super().extend(self._wrap(v) for v in it) + size = len(self) + try: + if it is self: + super().extend(self) + else: + super().extend(self._wrap(v) for v in it) + except BaseException: + if len(self) != size: + self._touch() + raise self._touch() def __iadd__(self, other): """ Extend list in place (`+=`) and notify observers. - Observers are notified before Python assigns the result back to the - field, so a listener that replaces the field during `obj.items += [x]` - is overwritten by that assignment. Call `obj.items.extend([x])` instead - to keep such a replacement. + Observers are notified before Python assigns the list back to its + field, so a subscriber that replaces the field during `+=` has its + replacement overwritten. Call `extend()` to keep such a replacement. """ self.extend(other) @@ -305,8 +339,9 @@ def __imul__(self, n): """ Repeat list items in place (`*=`) and notify observers. - Observers are notified before Python assigns the result back, as with - `+=`. + Observers are notified before Python assigns the list back to its + field, so a subscriber that replaces the field during `*=` has its + replacement overwritten. """ super().__imul__(n) @@ -369,13 +404,20 @@ def pop(self, i=-1): return v def __setitem__(self, i, v): - # `items[0] += [x]` writes the same child back after it already notified. - if ( - isinstance(i, int) - and -len(self) <= i < len(self) - and _is_written_back(self, super().__getitem__(i), v) - ): - return + """ + Set an item or a slice and notify observers. + + Writing the item's own observable child back to its index, as Python + does after `xs[0] += [1]`, changes nothing and doesn't notify again. + """ + + if not isinstance(i, slice): + try: + current = list.__getitem__(self, i) + except (IndexError, TypeError): + current = _MISSING + if _is_written_back(self, current, v): + return super().__setitem__(i, self._wrap(v)) self._touch() @@ -384,32 +426,6 @@ def __delitem__(self, i): self._touch() -def _is_written_back(container, current, value) -> bool: - """ - Tell whether `value` is the observable child already stored in that slot. - - Python ends an augmented assignment on an item, such as - `obj.mapping["items"] *= 2`, by writing the result back into the - container. The child already notified, so that write must not notify again. - - Args: - container: Observable list or dict receiving the write. - current: Value currently stored in the slot. - value: Value being written. - - Returns: - `True` when `value` is `current` and both wrappers report to the same - owner and field. - """ - - return ( - value is current - and isinstance(value, (ObservableList, ObservableDict)) - and value._owner_ref() is container._owner_ref() - and value._field == container._field - ) - - class ObservableDict(dict): """ Dict wrapper that reports mutating operations to its owning observable. @@ -446,8 +462,14 @@ def _wrap(self, v): return owner._wrap_if_collection(self._field, v) if owner else v def __setitem__(self, k, v): - # `d["items"] += [x]` writes the same child back after it already notified. - if k in self and _is_written_back(self, super().__getitem__(k), v): + """ + Set a key and notify observers. + + Writing the key's own observable child back, as Python does after + `d["items"] += [1]`, changes nothing and doesn't notify again. + """ + + if _is_written_back(self, dict.get(self, k, _MISSING), v): return super().__setitem__(k, self._wrap(v)) self._touch() @@ -466,35 +488,45 @@ def clear(self): def update(self, *a, **k): """ - Update mapping with values and notify observers once. + Update mapping with values and notify observers. - Takes the same arguments as `dict.update()`: a mapping or an iterable of - key-value pairs, then keywords. Entries are inserted one at a time and - list and dict values are wrapped as they are inserted. + Takes the same arguments as `dict.update()` and inserts entries in the + same order: the mapping's keys or the key-value pairs first, one at a + time, then the keywords. List and dict values are wrapped as they are + inserted. Observers are notified once; if the input raises after some + entries were inserted, they are notified before the error propagates. """ if len(a) > 1: raise TypeError(f"update expected at most 1 argument, got {len(a)}") - if a: - other = a[0] - if hasattr(other, "keys"): - # `dict.update()` reads `keys()`, which a mapping may define - # differently from iteration. - for kk in other.keys(): # noqa: SIM118 - super().__setitem__(kk, self._wrap(other[kk])) - else: - for kk, vv in other: - super().__setitem__(kk, self._wrap(vv)) - for kk, vv in k.items(): - super().__setitem__(kk, self._wrap(vv)) + changed = False + try: + if a: + other = a[0] + if hasattr(other, "keys"): + for kk in other.keys(): # noqa: SIM118 + dict.__setitem__(self, kk, self._wrap(other[kk])) + changed = True + else: + for kk, vv in other: + dict.__setitem__(self, kk, self._wrap(vv)) + changed = True + for kk, vv in k.items(): + dict.__setitem__(self, kk, self._wrap(vv)) + changed = True + except BaseException: + if changed: + self._touch() + raise self._touch() def __ior__(self, other): """ Merge a mapping in place (`|=`) and notify observers. - Observers are notified before Python assigns the result back, as with - `ObservableList`'s `+=`. + Observers are notified before Python assigns the dict back to its + field, so a subscriber that replaces the field during `|=` has its + replacement overwritten. Call `update()` to keep such a replacement. """ self.update(other) diff --git a/sdk/python/packages/flet/tests/test_observable.py b/sdk/python/packages/flet/tests/test_observable.py index c80776e826..4663b8c204 100644 --- a/sdk/python/packages/flet/tests/test_observable.py +++ b/sdk/python/packages/flet/tests/test_observable.py @@ -44,12 +44,26 @@ def update_key(self, key, value): @dataclass -class Quux(ft.Observable): - """Observable dataclass with collections nested in its fields.""" +class Store(ft.Observable): + """Observable dataclass with a list field and a dict field.""" + items: list = field(default_factory=list) mapping: dict = field(default_factory=dict) - grid: list = field(default_factory=list) - rows: list = field(default_factory=lambda: [[1], [2]]) + + +def _watch(obj): + """Subscribes to `obj` and returns the list of changed field names. + + The listener is stored on `obj`, because observables hold listeners weakly. + """ + changes = [] + + def listener(sender, field): + changes.append(field) + + obj._test_listener = listener + obj.subscribe(listener) + return changes class TestObservable: @@ -160,31 +174,67 @@ def test_iadd_with_itself(self): assert baz.prop_d == [1, 2, 3, 1, 2, 3] def test_extend_with_itself(self): - """`extend` with the list itself appends one copy of its items.""" - baz = Baz(f=1) + """`extend` with the list itself appends its items once, as the same objects.""" + store = Store(items=[[1], 2]) + changes = _watch(store) - baz.prop_d.extend(baz.prop_d) - assert baz.prop_d == [1, 2, 3, 1, 2, 3] - - def test_extend_with_itself_keeps_references(self): - """`extend` with the list itself appends the same nested objects.""" - quux = Quux() + store.items.extend(store.items) + assert store.items == [[1], 2, [1], 2] + assert store.items[2] is store.items[0] + assert changes == ["items"] - quux.rows.extend(quux.rows) - assert quux.rows == [[1], [2], [1], [2]] - assert quux.rows[2] is quux.rows[0] - assert quux.rows[3] is quux.rows[1] + def test_extend_reads_iterable_lazily(self): + """`extend` appends each item before reading the next one.""" + store = Store(items=[0]) - def test_extend_appends_items_one_at_a_time(self): - """A generator passed to `extend` sees the items it already yielded.""" - baz = Baz(f=1) - - def following(): + def gen(): for _ in range(2): - yield baz.prop_d[-1] + 1 + yield store.items[-1] + 1 + + store.items.extend(gen()) + assert store.items == [0, 1, 2] + + def test_extend_notifies_when_iterable_fails_partway(self): + """`extend` notifies about items appended before the iterable raised.""" + store = Store(items=[0]) + changes = _watch(store) + + def gen(): + yield 1 + raise RuntimeError("boom") + + with pytest.raises(RuntimeError): + store.items.extend(gen()) + assert store.items == [0, 1] + assert changes == ["items"] + + def test_extend_keeps_subscriber_replacement(self): + """A subscriber that replaces the field during `extend` keeps its value.""" + store = Store(items=[1]) + replaced = [] + + def listener(sender, field): + if not replaced: + replaced.append(True) + sender.items = [99] + + store.subscribe(listener) + store.items.extend([2]) + assert store.items == [99] + + def test_same_child_assignment_is_noop(self): + """Writing an item's own observable child back to its index doesn't notify.""" + store = Store(items=[[1]]) + store.items[0] = [1] + changes = _watch(store) + + store.items[0] = store.items[0] + store.items[-1] = store.items[-1] + assert changes == [] - baz.prop_d.extend(following()) - assert baz.prop_d == [1, 2, 3, 4, 5] + store.items[0] = [2] + store.items[0:1] = store.items[0:1] + assert changes == ["items", "items"] class TestObservableDict: @@ -257,93 +307,108 @@ def subscriber(sender, field): assert qux.prop_e == {"items": [1]} assert changes == ["prop_e"] - def test_update_inserts_entries_one_at_a_time(self): - """A generator passed to `update` sees the entries it already yielded.""" - changes = [] + def test_update_reads_input_lazily(self): + """`update` inserts each pair before reading the next one.""" + store = Store() + changes = _watch(store) - def subscriber(sender, field): - changes.append(field) + def pairs(): + for key in ("x", "y"): + yield key, len(store.mapping) + + store.mapping.update(pairs()) + assert store.mapping == {"x": 0, "y": 1} + assert changes == ["mapping"] + + def test_update_applies_keywords_last(self): + """Keyword values override the mapping's values for the same key.""" + store = Store() + + store.mapping.update({"a": 1, "b": 2}, b=3) + assert store.mapping == {"a": 1, "b": 3} + + def test_update_notifies_when_input_fails_partway(self): + """`update` notifies about entries inserted before the input raised.""" + store = Store() + changes = _watch(store) + + with pytest.raises(ValueError): + store.mapping.update([("a", 1), ("b", 2, 3)]) + assert store.mapping == {"a": 1} + assert changes == ["mapping"] + + def test_update_rejects_extra_arguments(self): + """`update` with two positional arguments raises without notifying.""" + store = Store() + changes = _watch(store) + + with pytest.raises(TypeError): + store.mapping.update({"a": 1}, {"b": 2}) + assert store.mapping == {} + assert changes == [] - qux = Qux() - qux.subscribe(subscriber) + def test_same_child_assignment_is_noop(self): + """Writing a key's own observable child back doesn't notify.""" + store = Store() + store.mapping["items"] = [] + changes = _watch(store) - def pairs(): - yield "a", len(qux.prop_e) - yield "b", len(qux.prop_e) + store.mapping["items"] = store.mapping["items"] + assert changes == [] - qux.prop_e.update(pairs()) - assert qux.prop_e == {"a": 0, "b": 1} - assert changes == ["prop_e"] + store.mapping["items"] = [] + assert changes == ["mapping"] -class TestNestedCollections: - """Notifications from collections nested in observable fields.""" +def _iadd_list_in_dict(store): + store.mapping["list"] += [1] - @pytest.mark.parametrize( - ("container", "slot", "child", "operation", "expected"), - [ - ("mapping", "items", [1], "iadd", [1, 2]), - ("mapping", "items", [1], "imul", [1, 1]), - ("mapping", "sub", {}, "ior", {"a": 1}), - ("grid", 0, [1], "iadd", [1, 2]), - ("grid", 0, [1], "imul", [1, 1]), - ("grid", 0, {}, "ior", {"a": 1}), - ], - ids=[ - "dict-iadd", - "dict-imul", - "dict-ior", - "list-iadd", - "list-imul", - "list-ior", - ], - ) - def test_augmented_assignment_notifies_once( - self, container, slot, child, operation, expected - ): - """`+=`, `*=` and `|=` on a nested collection notify once.""" - changes = [] - def subscriber(sender, field): - changes.append(field) +def _imul_list_in_dict(store): + store.mapping["list"] *= 2 - quux = Quux() - outer = getattr(quux, container) - if isinstance(outer, list): - outer.append(child) - else: - outer[slot] = child - quux.subscribe(subscriber) - if operation == "iadd": - outer[slot] += [2] - elif operation == "imul": - outer[slot] *= 2 - else: - outer[slot] |= {"a": 1} +def _ior_dict_in_dict(store): + store.mapping["dict"] |= {"a": 1} - assert outer[slot] == expected - assert changes == [container] - @pytest.mark.parametrize( - ("container", "slot"), [("mapping", "items"), ("grid", 0)], ids=["dict", "list"] - ) - def test_same_child_assignment_is_noop(self, container, slot): - """Assigning a nested collection back to its own slot does not notify.""" - changes = [] +def _iadd_list_in_list(store): + store.items[0] += [1] - def subscriber(sender, field): - changes.append(field) - quux = Quux() - outer = getattr(quux, container) - if isinstance(outer, list): - outer.append([1]) - else: - outer[slot] = [1] - child = outer[slot] - quux.subscribe(subscriber) - - outer[slot] = outer[slot] - assert outer[slot] is child - assert changes == [] +def _imul_list_in_list(store): + store.items[-2] *= 2 + + +def _ior_dict_in_list(store): + store.items[1] |= {"a": 1} + + +class TestNestedInplaceOperators: + """In-place operators on collections nested in observable collections.""" + + @pytest.mark.parametrize( + "op, field, child, expected", + [ + (_iadd_list_in_dict, "mapping", lambda s: s.mapping["list"], [0, 1]), + (_imul_list_in_dict, "mapping", lambda s: s.mapping["list"], [0, 0]), + (_ior_dict_in_dict, "mapping", lambda s: s.mapping["dict"], {"a": 1}), + (_iadd_list_in_list, "items", lambda s: s.items[0], [0, 1]), + (_imul_list_in_list, "items", lambda s: s.items[0], [0, 0]), + (_ior_dict_in_list, "items", lambda s: s.items[1], {"a": 1}), + ], + ids=["dict+=", "dict*=", "dict|=", "list+=", "list*=", "list|="], + ) + def test_notifies_once(self, op, field, child, expected): + """The nested collection notifies once; writing it back adds nothing.""" + store = Store() + store.mapping["list"] = [0] + store.mapping["dict"] = {} + store.items.extend([[0], {}]) + before = child(store) + changes = _watch(store) + + op(store) + assert child(store) is before + assert child(store) == expected + assert changes == [field] From e7f578ac425d25ee0c44d669a7bd2e44419d51cf Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sat, 14 Nov 2026 04:26:41 +0100 Subject: [PATCH 8/9] Read a custom list index once when setting an observable list item ObservableList.__setitem__ checks whether the value being written is the item's own observable child, which Python writes back after an augmented assignment such as `xs[0] += [1]`. That check read the current item with the raw index, and the assignment then converted the index again. An index object whose `__index__()` has side effects was therefore called twice and could target a different item: with `[10, 20]`, `xs[index] = 99` produced `[10, 99]` where a plain list produces `[99, 20]` (found in a Codex review). The index is now converted once with `operator.index()` and that integer is used for both the check and the assignment. Indexes that aren't integers (e.g. a str or a float) still go straight to `list.__setitem__`, so they raise the same TypeError, with the same message, as a plain list; an out-of-range index raises the same IndexError. Tests: - `test_set_item_reads_custom_index_once`: `__index__()` is called once and the right item changes (fails before this change). - `test_update_reads_mapping_keys`: `update()` takes a mapping's entries from `keys()`, as `dict.update()` does, and wraps the inserted values. - `test_merge_wraps_nested_values` gains an `ior-pairs` case: `|=` with an iterable of key-value pairs. --- .../flet/src/flet/components/observable.py | 18 ++++++-- .../packages/flet/tests/test_observable.py | 46 ++++++++++++++++++- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/sdk/python/packages/flet/src/flet/components/observable.py b/sdk/python/packages/flet/src/flet/components/observable.py index f8e4c32a6a..0754f066ef 100644 --- a/sdk/python/packages/flet/src/flet/components/observable.py +++ b/sdk/python/packages/flet/src/flet/components/observable.py @@ -1,6 +1,7 @@ from __future__ import annotations import contextlib +import operator import weakref from dataclasses import InitVar, dataclass from typing import TYPE_CHECKING, Any, Callable, Optional @@ -409,15 +410,22 @@ def __setitem__(self, i, v): Writing the item's own observable child back to its index, as Python does after `xs[0] += [1]`, changes nothing and doesn't notify again. + An index object's `__index__()` is called once, as with `list`. """ if not isinstance(i, slice): try: - current = list.__getitem__(self, i) - except (IndexError, TypeError): - current = _MISSING - if _is_written_back(self, current, v): - return + i = operator.index(i) + except TypeError: + pass + else: + current = ( + list.__getitem__(self, i) + if -len(self) <= i < len(self) + else _MISSING + ) + if _is_written_back(self, current, v): + return super().__setitem__(i, self._wrap(v)) self._touch() diff --git a/sdk/python/packages/flet/tests/test_observable.py b/sdk/python/packages/flet/tests/test_observable.py index 4663b8c204..86528bcb21 100644 --- a/sdk/python/packages/flet/tests/test_observable.py +++ b/sdk/python/packages/flet/tests/test_observable.py @@ -173,6 +173,26 @@ def test_iadd_with_itself(self): baz.prop_d += baz.prop_d assert baz.prop_d == [1, 2, 3, 1, 2, 3] + def test_set_item_reads_custom_index_once(self): + """Setting an item calls the index object's `__index__()` once.""" + + class Index: + def __init__(self): + self.calls = 0 + + def __index__(self): + self.calls += 1 + return self.calls - 1 + + store = Store(items=[10, 20]) + changes = _watch(store) + index = Index() + + store.items[index] = 99 + assert store.items == [99, 20] + assert index.calls == 1 + assert changes == ["items"] + def test_extend_with_itself(self): """`extend` with the list itself appends its items once, as the same objects.""" store = Store(items=[[1], 2]) @@ -288,8 +308,9 @@ def subscriber(sender, field): lambda d: d.update([("items", [])]), lambda d: d.update(items=[]), lambda d: d.__ior__({"items": []}), + lambda d: d.__ior__([("items", [])]), ], - ids=["mapping", "pairs", "keywords", "ior"], + ids=["mapping", "pairs", "keywords", "ior", "ior-pairs"], ) def test_merge_wraps_nested_values(self, merge): """Lists merged into the dict notify when they change later.""" @@ -327,6 +348,29 @@ def test_update_applies_keywords_last(self): store.mapping.update({"a": 1, "b": 2}, b=3) assert store.mapping == {"a": 1, "b": 3} + def test_update_reads_mapping_keys(self): + """`update` takes a mapping's entries from `keys()`, as `dict.update` does.""" + + class Mapping: + def keys(self): + return ["a"] + + def __getitem__(self, key): + return [key] + + def __iter__(self): + return iter(["ignored"]) + + store = Store() + changes = _watch(store) + + store.mapping.update(Mapping()) + assert store.mapping == {"a": ["a"]} + assert changes == ["mapping"] + changes.clear() + store.mapping["a"].append(1) + assert changes == ["mapping"] + def test_update_notifies_when_input_fails_partway(self): """`update` notifies about entries inserted before the input raised.""" store = Store() From 10f28ac206e17323db73e01b6c3f7b40d0626bd6 Mon Sep 17 00:00:00 2001 From: ndonkoHenri Date: Sat, 14 Nov 2026 04:33:07 +0100 Subject: [PATCH 9/9] Share observable collection plumbing through a private base class ObservableList and ObservableDict each had identical copies of `_touch()` (notify the owner under the field name) and `_wrap()` (wrap a nested list or dict before storing it), and the same-child write-back check lived in a module-level `_is_written_back(container, current, value)` function that both classes called. They now inherit from a private `_ObservableCollection` base class that holds all three: - `_touch()` and `_wrap()` move there unchanged; - `_is_written_back(current, value)` becomes a regular method, since it compares the value against the container's own owner and field, so the `container` argument is gone; - `isinstance(value, _ObservableCollection)` replaces the check against the two concrete types. The base class declares empty `__slots__` (plus type hints for `_owner_ref` and `_field`), and each subclass keeps its own `__init__` and `__slots__`, so instances still have no `__dict__`, the constructors keep their signatures (`iterable=` / `mapping=`), and `isinstance(x, list)` / `isinstance(x, dict)` still hold. No behavior change: the existing observable tests (nested in-place operators, same-child write-backs, custom index, notifications) pass unchanged, and pyright and mypy report the same diagnostics on the file as before. --- .../flet/src/flet/components/observable.py | 107 ++++++++---------- 1 file changed, 49 insertions(+), 58 deletions(-) diff --git a/sdk/python/packages/flet/src/flet/components/observable.py b/sdk/python/packages/flet/src/flet/components/observable.py index 0754f066ef..84dce6b026 100644 --- a/sdk/python/packages/flet/src/flet/components/observable.py +++ b/sdk/python/packages/flet/src/flet/components/observable.py @@ -234,42 +234,18 @@ def __repr__(self): _MISSING = object() -def _is_written_back(container, current: Any, value: Any) -> bool: +class _ObservableCollection: """ - Tell whether `value` is the observable child already stored in a slot. + Base for collections that report changes to a field of an observable. - Python writes the result of an augmented assignment such as - `d["items"] += [1]` back into the slot. The child has notified already, so - writing the identical child back to the same owner and field is a no-op. - - Args: - container: Observable list or dict that holds the slot. - current: Value in the slot, or `_MISSING` when the slot is empty. - value: Value being written into the slot. - - Returns: - `True` when `value` is `current` and reports to the same owner and field. - """ - - return ( - value is current - and isinstance(value, (ObservableList, ObservableDict)) - and value._owner_ref() is container._owner_ref() - and value._field == container._field - ) - - -class ObservableList(list): - """ - List wrapper that reports mutating operations to its owning observable. + Subclasses store a weak reference to the owning observable in `_owner_ref` + and the field's name in `_field`, both declared in their `__slots__`. """ - __slots__ = ("_owner_ref", "_field") + __slots__ = () - def __init__(self, owner: Observable, field: str, iterable=()): - super().__init__(iterable) - self._owner_ref = weakref.ref(owner) - self._field = field + _owner_ref: weakref.ref + _field: str def _touch(self): """ @@ -285,7 +261,7 @@ def _wrap(self, v): Wrap nested collections into observable variants before storing. Args: - v: Value being inserted into the list. + v: Value being inserted into the collection. Returns: Wrapped value when applicable. @@ -294,6 +270,44 @@ def _wrap(self, v): owner = self._owner_ref() return owner._wrap_if_collection(self._field, v) if owner else v + def _is_written_back(self, current: Any, value: Any) -> bool: + """ + Tell whether `value` is the observable child already stored in a slot. + + Python writes the result of an augmented assignment such as + `d["items"] += [1]` back into the slot. The child has notified already, + so writing the identical child back to the same owner and field is a + no-op. + + Args: + current: Value in the slot, or `_MISSING` when the slot is empty. + value: Value being written into the slot. + + Returns: + `True` when `value` is `current` and reports to the same owner and + field. + """ + + return ( + value is current + and isinstance(value, _ObservableCollection) + and value._owner_ref() is self._owner_ref() + and value._field == self._field + ) + + +class ObservableList(_ObservableCollection, list): + """ + List wrapper that reports mutating operations to its owning observable. + """ + + __slots__ = ("_owner_ref", "_field") + + def __init__(self, owner: Observable, field: str, iterable=()): + super().__init__(iterable) + self._owner_ref = weakref.ref(owner) + self._field = field + def append(self, x): """ Append an item and notify observers. @@ -424,7 +438,7 @@ def __setitem__(self, i, v): if -len(self) <= i < len(self) else _MISSING ) - if _is_written_back(self, current, v): + if self._is_written_back(current, v): return super().__setitem__(i, self._wrap(v)) self._touch() @@ -434,7 +448,7 @@ def __delitem__(self, i): self._touch() -class ObservableDict(dict): +class ObservableDict(_ObservableCollection, dict): """ Dict wrapper that reports mutating operations to its owning observable. """ @@ -446,29 +460,6 @@ def __init__(self, owner: Observable, field: str, mapping=()): self._owner_ref = weakref.ref(owner) self._field = field - def _touch(self): - """ - Emit a change notification for the parent field. - """ - - owner = self._owner_ref() - if owner: - owner._notify(self._field) - - def _wrap(self, v): - """ - Wrap nested collections into observable variants before storing. - - Args: - v: Value being inserted into the dict. - - Returns: - Wrapped value when applicable. - """ - - owner = self._owner_ref() - return owner._wrap_if_collection(self._field, v) if owner else v - def __setitem__(self, k, v): """ Set a key and notify observers. @@ -477,7 +468,7 @@ def __setitem__(self, k, v): `d["items"] += [1]`, changes nothing and doesn't notify again. """ - if _is_written_back(self, dict.get(self, k, _MISSING), v): + if self._is_written_back(dict.get(self, k, _MISSING), v): return super().__setitem__(k, self._wrap(v)) self._touch()