diff --git a/android/src/toga_android/widgets/detailedlist.py b/android/src/toga_android/widgets/detailedlist.py index 1ee020efe5..53c4599dcf 100644 --- a/android/src/toga_android/widgets/detailedlist.py +++ b/android/src/toga_android/widgets/detailedlist.py @@ -211,16 +211,68 @@ def change_source(self, source): def after_on_refresh(self, widget, result): self._refresh_layout.setRefreshing(False) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): self._load_data() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): self._load_data() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): self._load_data() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self._load_data() def _clear_selection(self): diff --git a/android/src/toga_android/widgets/selection.py b/android/src/toga_android/widgets/selection.py index 2fd49b9442..5aefbf60f5 100644 --- a/android/src/toga_android/widgets/selection.py +++ b/android/src/toga_android/widgets/selection.py @@ -42,7 +42,20 @@ def on_change(self, index): self.interface.on_change() self.last_selection = index + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): self.adapter.insert(self.interface._title_for_item(item), index) if self.last_selection is None: self.select_item(0) @@ -51,14 +64,40 @@ def insert(self, index, item): self.last_selection += 1 self.select_item(self.last_selection) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): # Instead of calling self.insert and self.remove, use direct native calls to # avoid disturbing the selection. index = self.interface._items.index(item) self.adapter.insert(self.interface._title_for_item(item), index) self.adapter.remove(self.adapter.getItem(index + 1)) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item=None): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item=None): self.adapter.remove(self.adapter.getItem(index)) # Adjust the selection index, but only generate an event if the selected item @@ -82,7 +121,20 @@ def get_selected_index(self): selected = self.native.getSelectedItemPosition() return None if selected == Spinner.INVALID_POSITION else selected + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self.adapter.clear() self.on_change(None) diff --git a/android/src/toga_android/widgets/table.py b/android/src/toga_android/widgets/table.py index c7a30f69c6..4f7103a337 100644 --- a/android/src/toga_android/widgets/table.py +++ b/android/src/toga_android/widgets/table.py @@ -173,16 +173,68 @@ def get_selection(self): else: return selection[0] + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): self.change_source(getattr(self.interface, "data", None)) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self.change_source(getattr(self.interface, "data", None)) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): self.change_source(getattr(self.interface, "data", None)) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): self.change_source(getattr(self.interface, "data", None)) def scroll_to_row(self, index): diff --git a/changes/4046.bugfix.md b/changes/4046.bugfix.md new file mode 100644 index 0000000000..41cbd522d0 --- /dev/null +++ b/changes/4046.bugfix.md @@ -0,0 +1 @@ +The `ListListener` and `TreeListener` protocols no longer incompatibly override the `insert`, `remove` and `clear` methods of `ListSource` and `TreeSource`, permitting mutable sources which are also listeners. diff --git a/changes/4046.removal.md b/changes/4046.removal.md new file mode 100644 index 0000000000..7ffee9501f --- /dev/null +++ b/changes/4046.removal.md @@ -0,0 +1 @@ +Sources now look for methods with names of the form `source_{notification}`, rather than just `notification` when the `notify` method is called. If you have a custom listener class with methods like `change`, `insert`, `remove` or `clear`, you should re-name them to have a `source_` prefix: `source_change`, `source_insert` and so on. diff --git a/cocoa/src/toga_cocoa/widgets/detailedlist.py b/cocoa/src/toga_cocoa/widgets/detailedlist.py index d1a17875ca..ad294e3676 100644 --- a/cocoa/src/toga_cocoa/widgets/detailedlist.py +++ b/cocoa/src/toga_cocoa/widgets/detailedlist.py @@ -176,20 +176,72 @@ def create(self): def change_source(self, source): self.native_detailedlist.reloadData() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): self.native_detailedlist.reloadData() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): self.native_detailedlist.reloadData() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): self.native_detailedlist.reloadData() # After deletion, the selection changes, but Cocoa doesn't send # a tableViewSelectionDidChange: message. self.interface.on_select() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self.native_detailedlist.reloadData() def set_refresh_enabled(self, enabled): diff --git a/cocoa/src/toga_cocoa/widgets/selection.py b/cocoa/src/toga_cocoa/widgets/selection.py index 7100f4c553..ec114084d5 100644 --- a/cocoa/src/toga_cocoa/widgets/selection.py +++ b/cocoa/src/toga_cocoa/widgets/selection.py @@ -42,7 +42,20 @@ def set_color(self, color): def set_background_color(self, color): pass + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): # Issue 2319 - if item titles are not unique, macOS will move the existing item, # rather than creating a duplicate item. To work around this, create an item # with a temporary but unique name, then change the name. `_title_for_item()` @@ -59,14 +72,40 @@ def insert(self, index, item): if len(self.interface.items) == 1: self.interface.on_change() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): index = self.interface._items.index(item) native_item = self.native.itemAtIndex(index) native_item.title = self.interface._title_for_item(item) # Changing the item text can change the layout size self.interface.refresh() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): selection_change = self.native.indexOfSelectedItem == index self.native.removeItemAtIndex(index) @@ -74,7 +113,20 @@ def remove(self, index, item): if selection_change: self.interface.on_change() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self.native.removeAllItems() self.interface.on_change() diff --git a/cocoa/src/toga_cocoa/widgets/table.py b/cocoa/src/toga_cocoa/widgets/table.py index daab07b811..8a0155bddd 100644 --- a/cocoa/src/toga_cocoa/widgets/table.py +++ b/cocoa/src/toga_cocoa/widgets/table.py @@ -153,7 +153,20 @@ def create(self): def change_source(self, source): self.native_table.reloadData() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): # set parent = None if inserting to the root item index_set = NSIndexSet.indexSetWithIndex(index) @@ -161,7 +174,20 @@ def insert(self, index, item): index_set, withAnimation=NSTableViewAnimation.EffectNone ) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): row_index = self.interface.data.index(item) row_indexes = NSIndexSet.indexSetWithIndex(row_index) column_indexes = NSIndexSet.indexSetWithIndexesInRange( @@ -172,13 +198,39 @@ def change(self, item): row_indexes, columnIndexes=column_indexes ) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): indexes = NSIndexSet.indexSetWithIndex(index) self.native_table.removeRowsAtIndexes( indexes, withAnimation=NSTableViewAnimation.EffectNone ) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self.native_table.reloadData() def get_selection(self): diff --git a/cocoa/src/toga_cocoa/widgets/tree.py b/cocoa/src/toga_cocoa/widgets/tree.py index 3690388d71..1a380ddbc7 100644 --- a/cocoa/src/toga_cocoa/widgets/tree.py +++ b/cocoa/src/toga_cocoa/widgets/tree.py @@ -191,7 +191,20 @@ def create(self): def change_source(self, source): self.native_tree.reloadData() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item, parent=None): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item, parent=parent) + + def source_insert(self, *, index, item, parent=None): index_set = NSIndexSet.indexSetWithIndex(index) self.native_tree.insertItemsAtIndexes( index_set, @@ -199,10 +212,36 @@ def insert(self, index, item, parent=None): withAnimation=NSTableViewAnimation.SlideDown.value, ) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): self.native_tree.reloadItem(node_impl(item)) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item, parent=None): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item, parent=parent) + + def source_remove(self, *, index, item, parent=None): try: index = self.native_tree.childIndexForItem(item._impl) index_set = NSIndexSet.indexSetWithIndex(index) @@ -215,7 +254,20 @@ def remove(self, index, item, parent=None): except AttributeError: pass + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self.native_tree.reloadData() def get_selection(self): diff --git a/core/src/toga/sources/base.py b/core/src/toga/sources/base.py index 078f77949c..08796e671a 100644 --- a/core/src/toga/sources/base.py +++ b/core/src/toga/sources/base.py @@ -8,11 +8,11 @@ @runtime_checkable class ValueListener(Protocol, Generic[ItemT]): - """The protocol that must be implemented by objects that will act as a listener on a - value data source. + """The protocol that must be implemented by objects that will act as a listener on + a value data source. """ - def change(self, *, item: ItemT) -> None: + def source_change(self, *, item: ItemT) -> None: """A change has occurred in an item. :param item: The data object that has changed. @@ -21,35 +21,41 @@ def change(self, *, item: ItemT) -> None: @runtime_checkable class ListListener(ValueListener[ItemT], Protocol, Generic[ItemT]): - """The protocol that must be implemented by objects that will act as a listener on a - list data source. + """The protocol that must be implemented by objects that will act as a listener on + a list data source. """ - def insert(self, *, index: int, item: ItemT) -> None: + def source_insert(self, *, index: int, item: ItemT) -> None: """An item has been added to the data source. :param index: The 0-index position in the data. :param item: The data object that was added. """ - def remove(self, *, index: int, item: ItemT) -> None: + def source_remove(self, *, index: int, item: ItemT) -> None: """An item has been removed from the data source. :param index: The 0-index position in the data. :param item: The data object that was added. """ - def clear(self) -> None: + def source_clear(self) -> None: """All items have been removed from the data source.""" @runtime_checkable class TreeListener(ListListener[ItemT], Protocol, Generic[ItemT]): - """The protocol that must be implemented by objects that will act as a listener on a - tree data source. + """The protocol that must be implemented by objects that will act as a listener on + a tree data source. """ - def insert(self, *, index: int, item: object, parent: ItemT | None = None) -> None: + def source_insert( + self, + *, + index: int, + item: object, + parent: ItemT | None = None, + ) -> None: """An item has been added to the data source. :param index: The 0-index position in the data. @@ -58,7 +64,13 @@ def insert(self, *, index: int, item: object, parent: ItemT | None = None) -> No if it is a root item. """ - def remove(self, *, index: int, item: object, parent: ItemT | None = None) -> None: + def source_remove( + self, + *, + index: int, + item: object, + parent: ItemT | None = None, + ) -> None: """An item has been removed from the data source. :param index: The 0-index position in the data. @@ -86,8 +98,8 @@ def listeners(self) -> list[ListenerT]: def add_listener(self, listener: ListenerT) -> None: """Add a new listener to this data source. - If the listener is already registered on this data source, the request to add is - ignored. + If the listener is already registered on this data source, the request to add + is ignored. :param listener: The listener to add """ @@ -108,10 +120,23 @@ def notify(self, notification: str, **kwargs: object) -> None: :param kwargs: The data associated with the notification. """ for listener in self._listeners: - try: - method = getattr(listener, notification) - except AttributeError: - method = None + method = getattr(listener, f"source_{notification}", None) + + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' + if method is None: + method = getattr(listener, notification, None) + if method is not None: + import warnings + + warnings.warn( + f"Notification handler methods on Listeners now start with " + f"'source_'. Change the method name to " + f"'source_{notification}'.", + DeprecationWarning, + stacklevel=2, + ) if method: method(**kwargs) @@ -122,7 +147,7 @@ def __getattr__(name): import warnings # Alias for backwards compatibility: - # Jan 2025: In 0.5.3 and earlier, ListListener was named Listener + # Jan 2026: In 0.5.3 and earlier, ListListener was named Listener global Listener Listener = ListListener warnings.warn( diff --git a/core/src/toga/widgets/selection.py b/core/src/toga/widgets/selection.py index 3695c7ff13..fa79848402 100644 --- a/core/src/toga/widgets/selection.py +++ b/core/src/toga/widgets/selection.py @@ -110,9 +110,9 @@ def items(self, items: ListSourceT | Iterable | None) -> None: self.on_change = None # Clear the widget, and insert all the data rows - self._impl.clear() + self._impl.source_clear() for index, item in enumerate(self.items): - self._impl.insert(index, item) + self._impl.source_insert(index=index, item=item) # Restore the original change handler and trigger it. self._on_change = orig_on_change diff --git a/core/tests/sources/test_list_source.py b/core/tests/sources/test_list_source.py index 2941ba1979..bb8f452fa6 100644 --- a/core/tests/sources/test_list_source.py +++ b/core/tests/sources/test_list_source.py @@ -92,7 +92,7 @@ def test_tuples(): assert source[1].val1 == "new element" assert source[1].val2 == 999 - listener.change.assert_called_once_with(item=source[1]) + listener.source_change.assert_called_once_with(item=source[1]) def test_list(): @@ -125,7 +125,7 @@ def test_list(): assert source[1].val1 == "new element" assert source[1].val2 == 999 - listener.change.assert_called_once_with(item=source[1]) + listener.source_change.assert_called_once_with(item=source[1]) def test_dict(): @@ -158,7 +158,7 @@ def test_dict(): assert source[1].val1 == "new element" assert source[1].val2 == 999 - listener.change.assert_called_once_with(item=source[1]) + listener.source_change.assert_called_once_with(item=source[1]) def test_flat_list(): @@ -246,7 +246,7 @@ def test_clear(source): assert len(source) == 0 # A notification was sent - listener.clear.assert_called_once_with() + listener.source_clear.assert_called_once_with() def test_insert_kwarg(source): @@ -263,7 +263,7 @@ def test_insert_kwarg(source): assert row.val1 == "new element" assert row.val2 == 999 - listener.insert.assert_called_once_with(index=1, item=row) + listener.source_insert.assert_called_once_with(index=1, item=row) def test_insert_positional(source): @@ -280,7 +280,7 @@ def test_insert_positional(source): assert row.val1 == "new element" assert row.val2 == 999 - listener.insert.assert_called_once_with(index=1, item=row) + listener.source_insert.assert_called_once_with(index=1, item=row) def test_append_dict(source): @@ -297,7 +297,7 @@ def test_append_dict(source): assert row.val1 == "new element" assert row.val2 == 999 - listener.insert.assert_called_once_with(index=3, item=row) + listener.source_insert.assert_called_once_with(index=3, item=row) def test_append_positional(source): @@ -314,7 +314,7 @@ def test_append_positional(source): assert row.val1 == "new element" assert row.val2 == 999 - listener.insert.assert_called_once_with(index=3, item=row) + listener.source_insert.assert_called_once_with(index=3, item=row) def test_del(source): @@ -333,7 +333,7 @@ def test_del(source): assert source[1].val1 == "third" assert source[1].val2 == 333 - listener.remove.assert_called_once_with(item=row, index=1) + listener.source_remove.assert_called_once_with(item=row, index=1) def test_remove(source): @@ -352,7 +352,7 @@ def test_remove(source): assert source[1].val1 == "third" assert source[1].val2 == 333 - listener.remove.assert_called_once_with(item=row, index=1) + listener.source_remove.assert_called_once_with(item=row, index=1) def test_index(source): diff --git a/core/tests/sources/test_source.py b/core/tests/sources/test_source.py index 5649b2d8c3..9929c8ffb7 100644 --- a/core/tests/sources/test_source.py +++ b/core/tests/sources/test_source.py @@ -23,11 +23,11 @@ def test_notify(): # activate listener source.notify("message1") - listener1.message1.assert_called_once_with() + listener1.source_message1.assert_called_once_with() # activate listener with data source.notify("message2", arg1=11, arg2=22) - listener1.message2.assert_called_once_with(arg1=11, arg2=22) + listener1.source_message2.assert_called_once_with(arg1=11, arg2=22) # add more widgets to listeners listener2 = Mock() @@ -36,13 +36,13 @@ def test_notify(): # activate listener source.notify("message3") - listener1.message3.assert_called_once_with() - listener2.message3.assert_called_once_with() + listener1.source_message3.assert_called_once_with() + listener2.source_message3.assert_called_once_with() # activate listener with data source.notify("message4", arg1=11, arg2=22) - listener1.message4.assert_called_once_with(arg1=11, arg2=22) - listener2.message4.assert_called_once_with(arg1=11, arg2=22) + listener1.source_message4.assert_called_once_with(arg1=11, arg2=22) + listener2.source_message4.assert_called_once_with(arg1=11, arg2=22) # remove listener2 source.remove_listener(listener2) @@ -50,8 +50,8 @@ def test_notify(): # Activate listeners; listener2 not notified. source.notify("message5") - listener1.message5.assert_called_once_with() - listener2.message5.assert_not_called() + listener1.source_message5.assert_called_once_with() + listener2.source_message5.assert_not_called() def test_missing_listener_method(): @@ -68,7 +68,33 @@ def test_missing_listener_method(): # This shouldn't raise an error source.notify("message1") - full_listener.message1.assert_called_once_with() + full_listener.source_message1.assert_called_once_with() + + +def test_deprecated_listener_method(): + """Listener method names should start with 'source'.""" + + class Listener: + pass + + listener = Listener() + listener.message1 = Mock() + + source = Source() + + source.add_listener(listener) + assert source.listeners == [listener] + + with pytest.warns( + DeprecationWarning, + match=( + r"Notification handler methods on Listeners now start with " + r"'source_'\. Change the method name to 'source_message1'\." + ), + ): + source.notify("message1") + + listener.message1.assert_called_once_with() def test_deprecate_listener(): diff --git a/core/tests/sources/test_tree_source.py b/core/tests/sources/test_tree_source.py index 4438130397..4f2b092657 100644 --- a/core/tests/sources/test_tree_source.py +++ b/core/tests/sources/test_tree_source.py @@ -389,7 +389,7 @@ def test_modify_roots(source, listener): del source[1] # Removal notification was sent - listener.remove.assert_called_once_with(parent=None, index=1, item=root) + listener.source_remove.assert_called_once_with(parent=None, index=1, item=root) listener.reset_mock() # Root is no longer associated with the source @@ -412,7 +412,7 @@ def test_modify_roots(source, listener): assert old_root_0._parent is None # Change notification was sent, the change is associated with the new item - listener.change.assert_called_once_with(item=source[0]) + listener.source_change.assert_called_once_with(item=source[0]) # Source's root count hasn't changed assert len(source) == 1 @@ -430,7 +430,7 @@ def test_clear(source, listener): assert len(source) == 0 # Clear notification was sent - listener.clear.assert_called_once_with() + listener.source_clear.assert_called_once_with() @pytest.mark.parametrize( @@ -454,7 +454,7 @@ def test_insert(source, listener, index, actual_index): assert source[actual_index].val1 == "new" # Insert notification was sent, the change is associated with the new item - listener.insert.assert_called_once_with( + listener.source_insert.assert_called_once_with( parent=None, index=actual_index, item=new_child, @@ -487,7 +487,7 @@ def test_insert_with_children(source, listener): assert not source[1][1].can_have_children() # Insert notification was sent, the change is associated with the new item - listener.insert.assert_called_once_with( + listener.source_insert.assert_called_once_with( parent=None, index=1, item=new_child, @@ -506,7 +506,7 @@ def test_append(source, listener): assert source[2].val1 == "new" # Insert notification was sent, the change is associated with the new item - listener.insert.assert_called_once_with( + listener.source_insert.assert_called_once_with( parent=None, index=2, item=new_child, @@ -538,7 +538,7 @@ def test_append_with_children(source, listener): assert not source[2][1].can_have_children() # Insert notification was sent, the change is associated with the new item - listener.insert.assert_called_once_with( + listener.source_insert.assert_called_once_with( parent=None, index=2, item=new_child, @@ -557,7 +557,7 @@ def test_remove_root(source, listener): assert root._source is None # Removal notification was sent - listener.remove.assert_called_once_with(parent=None, index=1, item=root) + listener.source_remove.assert_called_once_with(parent=None, index=1, item=root) def test_remove_child(source, listener): @@ -576,7 +576,7 @@ def test_remove_child(source, listener): assert node._parent is None # Removal notification was sent - listener.remove.assert_called_once_with(parent=source[1], index=1, item=node) + listener.source_remove.assert_called_once_with(parent=source[1], index=1, item=node) def test_remove_non_root(source, listener): diff --git a/core/tests/sources/test_value_source.py b/core/tests/sources/test_value_source.py index 9c44ce23db..6135a97236 100644 --- a/core/tests/sources/test_value_source.py +++ b/core/tests/sources/test_value_source.py @@ -39,16 +39,16 @@ def test_listener(): source.value = 37 - listener.change.assert_called_once_with(item=37) + listener.source_change.assert_called_once_with(item=37) # Reset the mock; clear the value listener.reset_mock() source.value = None - listener.change.assert_called_once_with(item=None) + listener.source_change.assert_called_once_with(item=None) # Reset the mock; set a *different* attribute on the source listener.reset_mock() source.something = 1234 - listener.change.assert_not_called() + listener.source_change.assert_not_called() diff --git a/docs/en/topics/data-sources.md b/docs/en/topics/data-sources.md index 85fec3348e..8998c3aed0 100644 --- a/docs/en/topics/data-sources.md +++ b/docs/en/topics/data-sources.md @@ -18,7 +18,7 @@ Although Toga provides these built-in data sources, in general, *you shouldn't u ### Listeners -Data sources communicate using a `Listener` interface which specifies the methods a listener object should implement to handle particular change notifications. Each type of Source has a corresponding Listener interface: [`ValueListener`][toga.sources.ValueListener], [`ListListener`][toga.sources.ListListener] and [`TreeListener`][toga.sources.TreeListener]. +Data sources communicate using a `Listener` interface which specifies the methods a listener object should implement to handle particular change notifications. Each type of Source has a corresponding Listener interface: [`ValueListener`][toga.sources.ValueListener], [`ListListener`][toga.sources.ListListener] and [`TreeListener`][toga.sources.TreeListener]. All these interfaces have method names which start with `source_`; when the source's `notify` method is called with a notification name and arguments, the corresponding `source_` method is called with those arguments, so that `source.notify("change", item=item)` will call every listener's `source_change` method with the `item` keyword argument. When any significant event occurs to the data source, all listeners will be notified. This includes: diff --git a/dummy/src/toga_dummy/widgets/detailedlist.py b/dummy/src/toga_dummy/widgets/detailedlist.py index df4326cc66..705e710c37 100644 --- a/dummy/src/toga_dummy/widgets/detailedlist.py +++ b/dummy/src/toga_dummy/widgets/detailedlist.py @@ -9,16 +9,68 @@ def change_source(self, source): self._action("change source", source=source) self.interface.on_select() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): self._action("insert item", index=index, item=item) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): self._action("change item", item=item) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): self._action("remove item", index=index, item=item) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self._action("clear") def get_selection(self): diff --git a/dummy/src/toga_dummy/widgets/selection.py b/dummy/src/toga_dummy/widgets/selection.py index d5af292365..7f4358b1ce 100644 --- a/dummy/src/toga_dummy/widgets/selection.py +++ b/dummy/src/toga_dummy/widgets/selection.py @@ -6,17 +6,56 @@ def create(self): self._action("create Selection") self._items = [] + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): self._action("insert item", index=index, item=item) self._items.insert(index, item) # If this is the first item to be inserted, it should be selected. if len(self._items) == 1: self.simulate_selection(self._items[0]) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): self._action("change item", item=item) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): self._action("remove item", index=index, item=item) del self._items[index] @@ -28,7 +67,20 @@ def remove(self, index, item): selected = None self.simulate_selection(selected) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self._action("clear") self._items = [] self.simulate_selection(None) diff --git a/dummy/src/toga_dummy/widgets/table.py b/dummy/src/toga_dummy/widgets/table.py index 2aab49e637..e6e77795e3 100644 --- a/dummy/src/toga_dummy/widgets/table.py +++ b/dummy/src/toga_dummy/widgets/table.py @@ -9,16 +9,68 @@ def change_source(self, source): self._action("change source", source=source) self.interface.on_select() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): self._action("insert row", index=index, item=item) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): self._action("change row", item=item) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): self._action("remove row", item=item, index=index) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self._action("clear") def get_selection(self): diff --git a/dummy/src/toga_dummy/widgets/tree.py b/dummy/src/toga_dummy/widgets/tree.py index 66a1f3078c..05cf6dbbd6 100644 --- a/dummy/src/toga_dummy/widgets/tree.py +++ b/dummy/src/toga_dummy/widgets/tree.py @@ -19,16 +19,68 @@ def change_source(self, source): self._action("change source", source=source) self.interface.on_select() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item, parent=None): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item, parent=parent) + + def source_insert(self, *, index, item, parent=None): self._action("insert node", parent=parent, index=index, item=item) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): self._action("change node", item=item) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item, parent=None): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item, parent=parent) + + def source_remove(self, *, index, item, parent=None): self._action("remove node", parent=parent, index=index, item=item) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self._action("clear") def get_selection(self): diff --git a/examples/table_source/table_source/app.py b/examples/table_source/table_source/app.py index b42784c047..1a07595944 100644 --- a/examples/table_source/table_source/app.py +++ b/examples/table_source/table_source/app.py @@ -83,7 +83,7 @@ def index(self, entry): # A listener that passes on all notifications, but only if they apply # to the filtered data source - def insert(self, index, item): + def source_insert(self, *, index, item): # If the item exists in the filtered list, propagate the notification for i, filtered_item in enumerate(self._filtered()): if filtered_item == item: @@ -91,7 +91,7 @@ def insert(self, index, item): # *filtered* list. self.notify("insert", index=i, item=item) - def pre_remove(self, index, item): + def source_pre_remove(self, index, item): # If the item exists in the filtered list, track that it is being # removed; but don't propagate the removal notification until it has # been removed from the base data source @@ -100,7 +100,7 @@ def pre_remove(self, index, item): # Track that the object *was* in the data source self._removals[item] = i - def remove(self, index, item): + def source_remove(self, *, index, item): # If the removed item previously existed in the filtered data source, # propagate the removal notification. try: @@ -110,7 +110,7 @@ def remove(self, index, item): # object wasn't previously in the data source pass - def clear(self): + def source_clear(self): self.notify("clear") diff --git a/gtk/src/toga_gtk/widgets/detailedlist.py b/gtk/src/toga_gtk/widgets/detailedlist.py index 489637983b..626c4eb5a7 100644 --- a/gtk/src/toga_gtk/widgets/detailedlist.py +++ b/gtk/src/toga_gtk/widgets/detailedlist.py @@ -250,22 +250,74 @@ def change_source(self, source): for item in source: self.store.append(self.row_factory(item)) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): self.hide_actions() item_impl = self.row_factory(item) self.store.insert(index, item_impl) self.native_detailedlist.show_all() self.update_refresh_button() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): item._impl.update(self.interface, item) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, item, index): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): self.hide_actions() self.store.remove(index) self.update_refresh_button() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self.hide_actions() self.store.remove_all() self.update_refresh_button() diff --git a/gtk/src/toga_gtk/widgets/selection.py b/gtk/src/toga_gtk/widgets/selection.py index 20d00c3b75..eda277430a 100644 --- a/gtk/src/toga_gtk/widgets/selection.py +++ b/gtk/src/toga_gtk/widgets/selection.py @@ -45,7 +45,20 @@ def gtk_on_changed(self, widget): # selector=".toga, .toga button", # ) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): index = self.interface._items.index(item) selection = self.native.get_active() # Insert a new entry at the same index, @@ -59,7 +72,20 @@ def change(self, item): # Changing the item text can change the layout size self.interface.refresh() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 with self.suspend_notifications(): self.native.insert_text(index, self.interface._title_for_item(item)) @@ -70,7 +96,20 @@ def insert(self, index, item): else: # pragma: no-cover-if-gtk3 pass + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): selection = self.native.get_active() with self.suspend_notifications(): self.native.remove(index) @@ -80,7 +119,20 @@ def remove(self, index, item): if index == selection: self.native.set_active(0) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 with self.suspend_notifications(): self.native.remove_all() diff --git a/gtk/src/toga_gtk/widgets/table.py b/gtk/src/toga_gtk/widgets/table.py index c7702a6603..db8b92d002 100644 --- a/gtk/src/toga_gtk/widgets/table.py +++ b/gtk/src/toga_gtk/widgets/table.py @@ -127,14 +127,27 @@ def change_source(self, source): self.store = Gtk.ListStore(*types) for i, row in enumerate(self.interface.data): - self.insert(i, row) + self.source_insert(index=i, item=row) self.native_table.set_model(self.store) self.refresh() else: # pragma: no-cover-if-gtk3 pass + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): row = TogaRow(item) values = [row] for column in self.interface._columns: @@ -149,7 +162,20 @@ def insert(self, index, item): self.store.insert(index, values) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): index = self.interface.data.index(item) row = self.store[index] for i, column in enumerate(self.interface._columns): @@ -157,10 +183,36 @@ def change(self, item): row[i * 2 + 2] = row[0].text(column, self.interface.missing_value) row[0].warn_widget(column) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): del self.store[index] + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self.store.clear() def get_selection(self): diff --git a/gtk/src/toga_gtk/widgets/tree.py b/gtk/src/toga_gtk/widgets/tree.py index a959fa414e..8bf96c260c 100644 --- a/gtk/src/toga_gtk/widgets/tree.py +++ b/gtk/src/toga_gtk/widgets/tree.py @@ -86,14 +86,27 @@ def change_source(self, source): self.store = Gtk.TreeStore(*types) for i, row in enumerate(self.interface.data): - self.insert(parent=None, index=i, item=row) + self.source_insert(parent=None, index=i, item=row) self.native_tree.set_model(self.store) self.refresh() else: # pragma: no-cover-if-gtk3 pass + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item, parent=None): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item, parent=parent) + + def source_insert(self, *, index, item, parent=None): row = TogaRow(item) values = [row] for column in self.interface._columns: @@ -113,20 +126,59 @@ def insert(self, index, item, parent=None): item._impl = self.store.insert(iter, index, values) for i, child in enumerate(item): - self.insert(parent=item, index=i, item=child) + self.source_insert(parent=item, index=i, item=child) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): row = self.store[item._impl] for i, column in enumerate(self.interface._columns): row[i * 2 + 1] = row[0].icon(column) row[i * 2 + 2] = row[0].text(column, self.interface.missing_value) row[0].warn_widget(column) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item, parent=None): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item, parent=parent) + + def source_remove(self, *, index, item, parent=None): del self.store[item._impl] item._impl = None + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self.store.clear() def get_selection(self): diff --git a/iOS/src/toga_iOS/widgets/detailedlist.py b/iOS/src/toga_iOS/widgets/detailedlist.py index 5dd9b8a51a..51b8e3595b 100644 --- a/iOS/src/toga_iOS/widgets/detailedlist.py +++ b/iOS/src/toga_iOS/widgets/detailedlist.py @@ -194,16 +194,68 @@ def after_on_refresh(self, widget, result): def change_source(self, source): self.native.reloadData() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): self.native.reloadData() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): self.native.reloadData() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, item, index): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): self.native.reloadData() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self.native.reloadData() def get_selection(self): diff --git a/iOS/src/toga_iOS/widgets/selection.py b/iOS/src/toga_iOS/widgets/selection.py index 3aa05c2441..1ceaccb064 100644 --- a/iOS/src/toga_iOS/widgets/selection.py +++ b/iOS/src/toga_iOS/widgets/selection.py @@ -110,7 +110,20 @@ def _reset_selection(self): self.select_item(0, default_item) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): if self._empty: # If you're inserting the first item, make sure it's selected self.select_item(index, item) @@ -127,7 +140,20 @@ def insert(self, index, item): # Get rid of focus to force the user to re-open the selection self.native_picker.resignFirstResponder() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): index = self.interface.items.index(item) if self.native_picker.selectedRowInComponent(0) == index: self.native.text = self.interface._title_for_item(item) @@ -138,7 +164,20 @@ def change(self, item): # Changing the item text can change the layout size self.interface.refresh() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): selection_change = self.native_picker.selectedRowInComponent(0) == index # Get rid of focus to force the user to re-open the selection @@ -147,7 +186,20 @@ def remove(self, index, item): if selection_change: self._reset_selection() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self._empty = True # Get rid of focus to force the user to re-open the selection self.native_picker.resignFirstResponder() diff --git a/qt/src/toga_qt/widgets/detailedlist.py b/qt/src/toga_qt/widgets/detailedlist.py index 87bf8c3cf6..4dd0c160c3 100644 --- a/qt/src/toga_qt/widgets/detailedlist.py +++ b/qt/src/toga_qt/widgets/detailedlist.py @@ -546,16 +546,68 @@ def change_source(self, source): # Listener Protocol implementation + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): self.native_model.insert_item(index) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): self.native_model.item_changed(item) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): self.native_model.remove_item(index) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self.native_model.reset_source() def update_toolbar(self): diff --git a/qt/src/toga_qt/widgets/selection.py b/qt/src/toga_qt/widgets/selection.py index 3e8a97b314..89004297ef 100644 --- a/qt/src/toga_qt/widgets/selection.py +++ b/qt/src/toga_qt/widgets/selection.py @@ -36,21 +36,73 @@ def qt_on_current_index_changed(self): self.interface.on_change() self._last_selected_item_id = self.native.currentData() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self.native.clear() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): self._item_id_count += 1 self.native.insertItem( index, self.interface._title_for_item(item), self._item_id_count ) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): index = self.interface._items.index(item) self.native.setItemText(index, self.interface._title_for_item(item)) self.interface.refresh() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): current_index = self.native.currentIndex() with self.suspend_notifications(): self.native.removeItem(index) diff --git a/qt/src/toga_qt/widgets/table.py b/qt/src/toga_qt/widgets/table.py index 4578f8466a..3fcf071ca2 100644 --- a/qt/src/toga_qt/widgets/table.py +++ b/qt/src/toga_qt/widgets/table.py @@ -198,16 +198,68 @@ def change_source(self, source): # Listener Protocol implementation + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): self.native_model.insert_item(index) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): self.native_model.item_changed(item) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): self.native_model.remove_item(index) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self.native_model.reset_source() def get_selection(self): diff --git a/qt/src/toga_qt/widgets/tree.py b/qt/src/toga_qt/widgets/tree.py index c9ed6c1c77..5356d1113e 100644 --- a/qt/src/toga_qt/widgets/tree.py +++ b/qt/src/toga_qt/widgets/tree.py @@ -265,16 +265,68 @@ def change_source(self, source): # Listener Protocol implementation + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item, parent=None): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item, parent=parent) + + def source_insert(self, *, index, item, parent=None): self.native_model.insert_item(item=item, index=index, parent=parent) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): self.native_model.item_changed(item) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item, parent=None): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item, parent=parent) + + def source_remove(self, *, index, item, parent=None): self.native_model.remove_item(item=item, index=index, parent=parent) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self.native_model.reset_source() def get_selection(self): diff --git a/testbed/tests/widgets/test_detailedlist.py b/testbed/tests/widgets/test_detailedlist.py index b200ee56ba..1723ae9c57 100644 --- a/testbed/tests/widgets/test_detailedlist.py +++ b/testbed/tests/widgets/test_detailedlist.py @@ -325,3 +325,29 @@ async def test_actions( def test_list_listener(widget): """Does the widget implement the ListListener API""" assert isinstance(widget._impl, ListListener) + + +@pytest.mark.parametrize( + "method_name,args", + [ + ("clear", {}), + ("change", {"item": "item"}), + ("insert", {"index": 0, "item": "item"}), + ("remove", {"index": 0, "item": "item"}), + ], +) +def test_deprecated_methods(widget, method_name, args): + """Does the widget warn about the old ListListener API""" + impl = widget._impl + mock_method = Mock() + setattr(impl, f"source_{method_name}", mock_method) + method = getattr(impl, method_name) + warning_pattern = ( + f"The {method_name}\\(\\) method is deprecated. " + f"Use source_{method_name}\\(\\) instead." + ) + + with pytest.warns(DeprecationWarning, match=warning_pattern): + method(**args) + + mock_method.assert_called_once_with(**args) diff --git a/testbed/tests/widgets/test_selection.py b/testbed/tests/widgets/test_selection.py index 0b615e4d59..0bc5f1e530 100644 --- a/testbed/tests/widgets/test_selection.py +++ b/testbed/tests/widgets/test_selection.py @@ -332,3 +332,29 @@ async def test_resize_on_content_change(widget, probe): def test_list_listener(widget): """Does the widget implement the ListListener API""" assert isinstance(widget._impl, ListListener) + + +@pytest.mark.parametrize( + "method_name,args", + [ + ("clear", {}), + ("change", {"item": "item"}), + ("insert", {"index": 0, "item": "item"}), + ("remove", {"index": 0, "item": "item"}), + ], +) +def test_deprecated_methods(widget, method_name, args): + """Does the widget warn about the old ListListener API""" + impl = widget._impl + mock_method = Mock() + setattr(impl, f"source_{method_name}", mock_method) + method = getattr(impl, method_name) + warning_pattern = ( + f"The {method_name}\\(\\) method is deprecated. " + f"Use source_{method_name}\\(\\) instead." + ) + + with pytest.warns(DeprecationWarning, match=warning_pattern): + method(**args) + + mock_method.assert_called_once_with(**args) diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 2be57ea63f..c9b8a21c4c 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -687,3 +687,29 @@ async def test_cell_widget(widget, probe): def test_list_listener(widget): """Does the widget implement the ListListener API""" assert isinstance(widget._impl, ListListener) + + +@pytest.mark.parametrize( + "method_name,args", + [ + ("clear", {}), + ("change", {"item": "item"}), + ("insert", {"index": 0, "item": "item"}), + ("remove", {"index": 0, "item": "item"}), + ], +) +def test_deprecated_methods(widget, method_name, args): + """Does the widget warn about the old ListListener API""" + impl = widget._impl + mock_method = Mock() + setattr(impl, f"source_{method_name}", mock_method) + method = getattr(impl, method_name) + warning_pattern = ( + f"The {method_name}\\(\\) method is deprecated. " + f"Use source_{method_name}\\(\\) instead." + ) + + with pytest.warns(DeprecationWarning, match=warning_pattern): + method(**args) + + mock_method.assert_called_once_with(**args) diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index c9c94025af..1aff557385 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -944,3 +944,47 @@ def test_tree_listener(widget): TreeListener APIs""" assert isinstance(widget._impl, ListListener) assert isinstance(widget._impl, TreeListener) + + +@pytest.mark.parametrize( + "method_name,args,expected_args", + [ + ("clear", {}, {}), + ("change", {"item": "item"}, {"item": "item"}), + ( + "insert", + {"index": 0, "item": "item"}, + {"index": 0, "item": "item", "parent": None}, + ), + ( + "insert", + {"index": 0, "item": "item", "parent": "parent"}, + {"index": 0, "item": "item", "parent": "parent"}, + ), + ( + "remove", + {"index": 0, "item": "item"}, + {"index": 0, "item": "item", "parent": None}, + ), + ( + "remove", + {"index": 0, "item": "item", "parent": "parent"}, + {"index": 0, "item": "item", "parent": "parent"}, + ), + ], +) +def test_deprecated_methods(widget, method_name, args, expected_args): + """Does the widget warn about the old ListListener API""" + impl = widget._impl + mock_method = Mock() + setattr(impl, f"source_{method_name}", mock_method) + method = getattr(impl, method_name) + warning_pattern = ( + f"The {method_name}\\(\\) method is deprecated. " + f"Use source_{method_name}\\(\\) instead." + ) + + with pytest.warns(DeprecationWarning, match=warning_pattern): + method(**args) + + mock_method.assert_called_once_with(**expected_args) diff --git a/web/src/toga_web/widgets/selection.py b/web/src/toga_web/widgets/selection.py index fca5db7eb4..ed62a21d9c 100644 --- a/web/src/toga_web/widgets/selection.py +++ b/web/src/toga_web/widgets/selection.py @@ -13,11 +13,37 @@ def create(self): def dom_sl_change(self, event): self.interface.on_change() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): while self.native.firstElementChild: self.native.removeChild(self.native.firstElementChild) + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): display_text = self.interface._title_for_item(item) option = self._create_native_widget("sl-option") option.value = str(index) diff --git a/winforms/src/toga_winforms/widgets/selection.py b/winforms/src/toga_winforms/widgets/selection.py index e57ef1f63b..dc366961c6 100644 --- a/winforms/src/toga_winforms/widgets/selection.py +++ b/winforms/src/toga_winforms/widgets/selection.py @@ -30,27 +30,79 @@ def on_change(self): if self._send_notifications: self.interface.on_change() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self.native.Items.Clear() self.on_change() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): self.native.Items.Insert(index, self.interface._title_for_item(item)) # WinfForm.ComboBox does not select the first item, so it's done here. if self.native.SelectedIndex == -1: self.native.SelectedIndex = 0 + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): index = self.interface._items.index(item) with self.suspend_notifications(): - self.insert(index, item) - self.remove(index + 1, item) + self.source_insert(index=index, item=item) + self.source_remove(index=index + 1, item=item) # Changing the item text can change the layout size. self.interface.refresh() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): selection_change = self.get_selected_index() == index self.native.Items.RemoveAt(index) diff --git a/winforms/src/toga_winforms/widgets/table.py b/winforms/src/toga_winforms/widgets/table.py index f1e099be1d..2617775bf8 100644 --- a/winforms/src/toga_winforms/widgets/table.py +++ b/winforms/src/toga_winforms/widgets/table.py @@ -315,16 +315,68 @@ def update_data(self): self.native.VirtualListSize = len(self._data) self._cache = [] + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def insert(self, index, item): + import warnings + + warnings.warn( + "The insert() method is deprecated. Use source_insert() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_insert(index=index, item=item) + + def source_insert(self, *, index, item): self.update_data() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def change(self, item): + import warnings + + warnings.warn( + "The change() method is deprecated. Use source_change() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_change(item=item) + + def source_change(self, *, item): self.update_data() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def remove(self, index, item): + import warnings + + warnings.warn( + "The remove() method is deprecated. Use source_remove() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_remove(index=index, item=item) + + def source_remove(self, *, index, item): self.update_data() + # Alias for backwards compatibility: + # March 2026: In 0.5.3 and earlier, notification methods + # didn't start with 'source_' def clear(self): + import warnings + + warnings.warn( + "The clear() method is deprecated. Use source_clear() instead.", + DeprecationWarning, + stacklevel=1, + ) + self.source_clear() + + def source_clear(self): self.update_data() def get_selection(self):