diff --git a/traitsui/editors/csv_list_editor.py b/traitsui/editors/csv_list_editor.py index d7080a197..cad8fc653 100644 --- a/traitsui/editors/csv_list_editor.py +++ b/traitsui/editors/csv_list_editor.py @@ -24,7 +24,6 @@ from traits.trait_handlers import RangeTypes from .text_editor import TextEditor -from ..editor_factory import EditorFactory from ..helper import enum_values_changed @@ -153,7 +152,36 @@ def _validate_range_value(range_object, object, name, value): range_object.error(object, name, value) -class CSVListEditor(EditorFactory): +def _prepare_method(cls, parent): + """ Unbound implementation of the prepare editor method to add a + change notification hook in the items of the list before calling + the parent prepare method of the parent class. + + """ + name = cls.extended_name + if name != 'None': + cls.context_object.on_trait_change(cls._update_editor, + name + '[]', + dispatch='ui') + super(cls.__class__, cls).prepare(parent) + +def _dispose_method(cls): + """ Unbound implementation of the dispose editor method to remove + the change notification hook in the items of the list before calling + the parent dispose method of the parent class. + + """ + if cls.ui is None: + return + + name = cls.extended_name + if name != 'None': + cls.context_object.on_trait_change(cls._update_editor, + name + '[]', + remove=True) + super(cls.__class__, cls).dispose() + +class CSVListEditor(TextEditor): """A text editor for a List. This editor provides a single line of input text of comma separated @@ -161,9 +189,9 @@ class CSVListEditor(EditorFactory): changed.) The editor can only be used with List traits whose inner trait is one of Int, Float, Str, Enum, or Range. - The 'simple', 'text' and 'custom' styles are all the same. The - 'readonly' style provides the same formatting in the text field as - the other editors, but the user can not change the value. + The 'simple', 'text', 'custom' and readonly styles are based on + TextEditor. The 'readonly' style provides the same formatting in the + text field as the other editors, but the user cannot change the value. Like other Traits editors, the background of the text field will turn red if the user enters an incorrectly formatted list or if the values @@ -309,73 +337,51 @@ def _funcs(self, object, name): return evaluate, fmt_func - def _make_text_editor(self, ui, object, name, description, parent, - readonly=False): - """Create the actual text editor for this list. - - Parameters - ---------- - ui : instance of traitsui.ui.UI - Passed on to factory functions of the TextEditor instance. - - object : instance of HasTraits - The HasTraits instance with the trait `name`. - - name : str - The name of the trait on `object`. - - description : str - This is a description of the trait. If, for example, the Item - holding the trait defines a tooltip, that string will end up - in `description`. - It is passed on to the factory functions of the TextEditor instance. - parent : object - The parent of the backend-dependent control that will be used - to implement the editor. + #--------------------------------------------------------------------------- + # Methods that generate backend toolkit-specific editors. + #--------------------------------------------------------------------------- - readonly : bool - If True, create a read-only editor. Otherwise, the editor field - will be editable by the user. - - Returns - ------- - ed : object - An editor generated by either TextEditor.simple_editor(...) - or TextEditor.readonly_editor(...), depending on the value - of `readonly`. + def simple_editor ( self, ui, object, name, description, parent ): + """ Generates an editor using the "simple" style. """ - evaluate, fmt_func = self._funcs(object, name) - # Create a TextEditor with the appropriate evaluation and formatting - # functions. - editor_factory = TextEditor(evaluate=evaluate, format_func=fmt_func, - auto_set=self.auto_set, enter_set=self.enter_set) - - # Call the appropriate factory function to create the actual editor. - if readonly: - ed = editor_factory.readonly_editor(ui, object, name, description, - parent) - else: - ed = editor_factory.simple_editor(ui, object, name, description, - parent) - - # Hook up a listener on `object` so that the display is updated if - # the list changes external to the editor. - object.on_trait_change(ed.update_editor, name + '[]') - - return ed - - def simple_editor(self, ui, object, name, description, parent): - e = self._make_text_editor(ui, object, name, description, parent) - return e - - def custom_editor(self, ui, object, name, description, parent): - return self.simple_editor(ui, object, name, description, parent) - - def text_editor(self, ui, object, name, description, parent): - return self.simple_editor(ui, object, name, description, parent) - - def readonly_editor(self, ui, object, name, description, parent): - e = self._make_text_editor(ui, object, name, description, parent, - readonly=True) - return e + self.evaluate, self.format_func = self._funcs(object, name) + return self.simple_editor_class( parent, + factory = self, + ui = ui, + object = object, + name = name, + description = description ) + + def custom_editor ( self, ui, object, name, description, parent ): + """ Generates an editor using the "custom" style. + """ + self.evaluate, self.format_func = self._funcs(object, name) + return self.custom_editor_class( parent, + factory = self, + ui = ui, + object = object, + name = name, + description = description ) + + def text_editor ( self, ui, object, name, description, parent ): + """ Generates an editor using the "text" style. + """ + self.evaluate, self.format_func = self._funcs(object, name) + return self.text_editor_class( parent, + factory = self, + ui = ui, + object = object, + name = name, + description = description ) + + def readonly_editor ( self, ui, object, name, description, parent ): + """ Generates an "editor" that is read-only. + """ + self.evaluate, self.format_func = self._funcs(object, name) + return self.readonly_editor_class( parent, + factory = self, + ui = ui, + object = object, + name = name, + description = description ) diff --git a/traitsui/qt4/csv_list_editor.py b/traitsui/qt4/csv_list_editor.py new file mode 100644 index 000000000..8add9182c --- /dev/null +++ b/traitsui/qt4/csv_list_editor.py @@ -0,0 +1,47 @@ +#------------------------------------------------------------------------------ +# +# Copyright (c) 2012, Enthought, Inc. +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/LICENSE.txt and may be redistributed only +# under the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +# +# Author: Ioannis Tziakos +# Date: 11 Jan 2012 +# +#------------------------------------------------------------------------------ + +""" Defines the various text editors for the Qt user interface toolkit. + The module is mainly a place-folder for TextEditor factories that have + been augmented to also listen to changes in the items of the list object. +""" + +#------------------------------------------------------------------------------- +# Imports: +#------------------------------------------------------------------------------ + +from .text_editor import SimpleEditor as QtSimpleEditor +from .text_editor import CustomEditor as QtCustomEditor +from .text_editor import ReadonlyEditor as QtReadonlyEditor +from ..editors.csv_list_editor import _prepare_method, _dispose_method + +class SimpleEditor(QtSimpleEditor): + """ Simple Editor style for CSVListEditor. """ + prepare = _prepare_method + dispose = _dispose_method + +class CustomEditor(QtCustomEditor): + """ Custom Editor style for CSVListEditor. """ + prepare = _prepare_method + dispose = _dispose_method + +class ReadonlyEditor(QtReadonlyEditor): + """ Readonly Editor style for CSVListEditor. """ + prepare = _prepare_method + dispose = _dispose_method + +TextEditor = SimpleEditor diff --git a/traitsui/tests/_tools.py b/traitsui/tests/_tools.py index 0a83966a7..69743a181 100644 --- a/traitsui/tests/_tools.py +++ b/traitsui/tests/_tools.py @@ -1,3 +1,18 @@ +#------------------------------------------------------------------------------ +# +# Copyright (c) 2012, Enthought, Inc. +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/LICENSE.txt and may be redistributed only +# under the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Author: Pietro Berkes +# Date: Jan 2012 +# +#------------------------------------------------------------------------------ + from functools import partial from contextlib import contextmanager import nose @@ -6,6 +21,7 @@ import traceback from traits.etsconfig.api import ETSConfig +import traits.trait_notifiers # ######### Testing tools @@ -13,29 +29,48 @@ def store_exceptions_on_all_threads(): """Context manager that captures all exceptions, even those coming from the UI thread. On exit, the first exception is raised (if any). + + It also temporarily overwrites the global function + traits.trait_notifier.handle_exception , which logs exceptions to + console without re-raising them by default. """ exceptions = [] - def excepthook(type, value, tb): - exceptions.append(value) + def _print_uncaught_exception(type, value, tb): message = 'Uncaught exception:\n' message += ''.join(traceback.format_exception(type, value, tb)) print message + def excepthook(type, value, tb): + exceptions.append(value) + _print_uncaught_exception(type, value, tb) + + def handle_exception(object, trait_name, old, new): + type, value, tb = sys.exc_info() + exceptions.append(value) + _print_uncaught_exception(type, value, tb) + + _original_handle_exception = traits.trait_notifiers.handle_exception try: sys.excepthook = excepthook + traits.trait_notifiers.handle_exception = handle_exception yield finally: if len(exceptions) > 0: raise exceptions[0] sys.excepthook = sys.__excepthook__ + traits.trait_notifiers.handle_exception = _original_handle_exception + + +def _is_current_backend(backend_name=''): + return ETSConfig.toolkit == backend_name def skip_if_not_backend(test_func, backend_name=''): """Decorator that skip tests if the backend is not the desired one.""" - if ETSConfig.toolkit != backend_name: + if not _is_current_backend(backend_name): # preserve original name so that it appears in the report orig_name = test_func.__name__ def test_func(): @@ -45,6 +80,12 @@ def test_func(): return test_func +#: Return True if current backend is 'wx' +is_current_backend_wx = partial(_is_current_backend, backend_name='wx') + +#: Return True if current backend is 'qt4' +is_current_backend_qt4 = partial(_is_current_backend, backend_name='qt4') + #: Test decorator: Skip test if backend is not 'wx' skip_if_not_wx = partial(skip_if_not_backend, backend_name='wx') @@ -76,6 +117,25 @@ def get_children(node): return node.children() +def press_ok_button(ui): + """Press the OK button in a wx or qt dialog.""" + + if is_current_backend_wx(): + import wx + + ok_button = ui.control.FindWindowByName('button') + click_event = wx.CommandEvent(wx.wxEVT_COMMAND_BUTTON_CLICKED, + ok_button.GetId()) + ok_button.ProcessEvent(click_event) + + elif is_current_backend_qt4(): + from pyface import qt + + # press the OK button and close the dialog + ok_button = ui.control.findChild(qt.QtGui.QPushButton) + ok_button.click() + + # ######### Debug tools def apply_on_children(func, node, _level=0): @@ -89,12 +149,22 @@ def apply_on_children(func, node, _level=0): def wx_print_names(node): """Print the name and id of `node` and its children. + + Use as:: + + >>> ui = xxx.edit_traits() + >>> wx_print_names(ui.control) """ apply_on_children(lambda n: (n.GetName(), n.GetId()), node) def qt_print_names(node): """Print the name of `node` and its children. + + Use as:: + + >>> ui = xxx.edit_traits() + >>> wx_print_names(ui.control) """ apply_on_children(lambda n: n.objectName(), node) diff --git a/traitsui/tests/test_csv_editor.py b/traitsui/tests/test_csv_editor.py new file mode 100644 index 000000000..4eaf02d49 --- /dev/null +++ b/traitsui/tests/test_csv_editor.py @@ -0,0 +1,107 @@ +#------------------------------------------------------------------------------ +# +# Copyright (c) 2012, Enthought, Inc. +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/LICENSE.txt and may be redistributed only +# under the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Author: Pietro Berkes +# Date: Jan 2012 +# +#------------------------------------------------------------------------------ + +from traits.has_traits import HasTraits +from traits.trait_types import Float, List, Instance +from traitsui.handler import ModelView +from traitsui.view import View +from traitsui.item import Item +from traitsui.editors.csv_list_editor import CSVListEditor +import traitsui.editors.csv_list_editor as csv_list_editor + +from _tools import * + + +class ListOfFloats(HasTraits): + data = List(Float) + + +class ListOfFloatsWithCSVEditor(ModelView): + model = Instance(ListOfFloats) + + traits_view = View( + Item(label="Close the window to append data"), + Item('model.data', editor = CSVListEditor()), + buttons = ['OK'] + ) + + +def test_csv_editor_disposal(): + # Bug: CSVListEditor does not un-hook the traits notifications after its + # disposal, causing errors when the hooked data is accessed after + # the window is closed (Issue #48) + + try: + with store_exceptions_on_all_threads(): + list_of_floats = ListOfFloats(data=[1,2,3]) + csv_view = ListOfFloatsWithCSVEditor(model=list_of_floats) + ui = csv_view.edit_traits() + press_ok_button(ui) + + # raise an exception if still hooked + list_of_floats.data.append(2) + + except AttributeError: + # if all went well, we should not be here + assert False, "AttributeError raised" + + +def test_csv_editor_external_append(): + # Behavior: CSV editor is notified when an element is appended to the + # list externally + + def _wx_get_text_value(ui): + txt_ctrl = ui.control.FindWindowByName('text') + return txt_ctrl.GetValue() + + def _qt_get_text_value(ui): + from pyface import qt + txt_ctrl = ui.control.findChild(qt.QtGui.QLineEdit) + return txt_ctrl.text() + + with store_exceptions_on_all_threads(): + list_of_floats = ListOfFloats(data=[1.0]) + csv_view = ListOfFloatsWithCSVEditor(model=list_of_floats) + ui = csv_view.edit_traits() + + # add element to list, make sure that editor knows about it + list_of_floats.data.append(3.14) + + # get current value from editor + if is_current_backend_wx(): + value_str = _wx_get_text_value(ui) + elif is_current_backend_qt4(): + value_str = _qt_get_text_value(ui) + else: + raise Exception('Unknown backend', ETSConfig.toolkit) + + expected = csv_list_editor._format_list_str([1.0, 3.14]) + nose.tools.assert_equal(value_str, expected) + + press_ok_button(ui) + + +if __name__ == '__main__': + # Executing the file opens the dialog for manual testing + list_of_floats = ListOfFloats(data=[1,2,3]) + csv_view = ListOfFloatsWithCSVEditor(model=list_of_floats) + csv_view.configure_traits() + + # this call will raise an AttributeError in commit + # 4ecb2fa8f0ef385d55a2a4062d821b0415777973 + # This is because the editor does not un-hook the traits notifications + # after its disposal + list_of_floats.data.append(2) + print list_of_floats.data diff --git a/traitsui/tests/test_range_editor_spinner.py b/traitsui/tests/test_range_editor_spinner.py index ab767bc80..961f9ae93 100644 --- a/traitsui/tests/test_range_editor_spinner.py +++ b/traitsui/tests/test_range_editor_spinner.py @@ -1,5 +1,20 @@ +#------------------------------------------------------------------------------ +# +# Copyright (c) 2012, Enthought, Inc. +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/LICENSE.txt and may be redistributed only +# under the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Author: Pietro Berkes +# Date: Jan 2012 +# +#------------------------------------------------------------------------------ + """ -Test case for bug (wx, Max OS X) +Test case for bug (wx, Mac OS X) Editing the text part of a spin control box and pressing the OK button without de-focusing raises an AttributeError @@ -37,8 +52,6 @@ def test_wx_spin_control_editing_should_not_crash(): # Bug: when editing the text part of a spin control box, pressing # the OK button raises an AttributeError on Mac OS X - import wx - try: with store_exceptions_on_all_threads(): num = NumberWithSpinnerEditor() @@ -61,24 +74,18 @@ def test_wx_spin_control_editing_should_not_crash(): spintxt.SetValue('4') # press the OK button and close the dialog - okbutton = ui.control.FindWindowByName('button') - click_event = wx.CommandEvent(wx.wxEVT_COMMAND_BUTTON_CLICKED, - okbutton.GetId()) - okbutton.ProcessEvent(click_event) + press_ok_button(ui) except AttributeError: # if all went well, we should not be here assert False, "AttributeError raised" - @skip_if_not_wx def test_wx_spin_control_editing_does_not_update(): # Bug: when editing the text part of a spin control box, pressing # the OK button does not update the value of the HasTraits class # on Mac OS X - import wx - with store_exceptions_on_all_threads(): num = NumberWithSpinnerEditor() ui = num.edit_traits() @@ -100,10 +107,7 @@ def test_wx_spin_control_editing_does_not_update(): spintxt.SetValue('4') # press the OK button and close the dialog - okbutton = ui.control.FindWindowByName('button') - click_event = wx.CommandEvent(wx.wxEVT_COMMAND_BUTTON_CLICKED, - okbutton.GetId()) - okbutton.ProcessEvent(click_event) + press_ok_button(ui) # if all went well, the number traits has been updated and its value is 4 assert num.number == 4 @@ -129,8 +133,7 @@ def test_qt_spin_control_editing(): lineedit.setText('4') # press the OK button and close the dialog - okb = ui.control.findChild(qt.QtGui.QPushButton) - okb.click() + press_ok_button(ui) # if all went well, the number traits has been updated and its value is 4 assert num.number == 4 diff --git a/traitsui/tests/test_range_editor_text.py b/traitsui/tests/test_range_editor_text.py index c28810323..8f0b51423 100644 --- a/traitsui/tests/test_range_editor_text.py +++ b/traitsui/tests/test_range_editor_text.py @@ -1,5 +1,20 @@ +#------------------------------------------------------------------------------ +# +# Copyright (c) 2012, Enthought, Inc. +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/LICENSE.txt and may be redistributed only +# under the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Author: Pietro Berkes +# Date: Jan 2012 +# +#------------------------------------------------------------------------------ + """ -Test case for bug (wx, Max OS X) +Test case for bug (wx, Mac OS X) A RangeEditor in mode 'text' for an Int allows values out of range. """ @@ -32,8 +47,6 @@ def test_wx_spin_control_editing(): # the OK button should update the value of the HasTraits class # (tests a bug where this fails with an AttributeError) - import wx - with store_exceptions_on_all_threads(): num = NumberWithTextEditor() ui = num.edit_traits() @@ -45,10 +58,7 @@ def test_wx_spin_control_editing(): textctrl.SetValue('1') # press the OK button and close the dialog - okbutton = ui.control.FindWindowByName('button') - click_event = wx.CommandEvent(wx.wxEVT_COMMAND_BUTTON_CLICKED, - okbutton.GetId()) - okbutton.ProcessEvent(click_event) + press_ok_button(ui) # the number traits should be between 3 and 8 assert num.number >= 3 and num.number <=8 diff --git a/traitsui/tests/test_ui.py b/traitsui/tests/test_ui.py index d503a1bf6..f6a863289 100644 --- a/traitsui/tests/test_ui.py +++ b/traitsui/tests/test_ui.py @@ -2,8 +2,6 @@ Test cases for the UI object. """ -import nose.tools - from traits.has_traits import HasTraits from traits.trait_types import Str, Int import traitsui diff --git a/traitsui/wx/csv_list_editor.py b/traitsui/wx/csv_list_editor.py new file mode 100644 index 000000000..def2036b4 --- /dev/null +++ b/traitsui/wx/csv_list_editor.py @@ -0,0 +1,48 @@ +#------------------------------------------------------------------------------ +# +# Copyright (c) 2012, Enthought, Inc. +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in enthought/LICENSE.txt and may be redistributed only +# under the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +# +# Author: Ioannis Tziakos +# Date: 11 Jan 2012 +# +#------------------------------------------------------------------------------ + +""" Defines the various text editors for the wxPython user interface toolkit. + The module is mainly a place-folder for TextEditor factories that have + been augmented to also listen to changes in the items of the list object. + +""" + +#------------------------------------------------------------------------------- +# Imports: +#------------------------------------------------------------------------------ + +from .text_editor import SimpleEditor as WXSimpleEditor +from .text_editor import CustomEditor as WXCustomEditor +from .text_editor import ReadonlyEditor as WXReadonlyEditor +from ..editors.csv_list_editor import _prepare_method, _dispose_method + +class SimpleEditor(WXSimpleEditor): + """ Simple Editor style for CSVListEditor. """ + prepare = _prepare_method + dispose = _dispose_method + +class CustomEditor(WXCustomEditor): + """ Custom Editor style for CSVListEditor. """ + prepare = _prepare_method + dispose = _dispose_method + +class ReadonlyEditor(WXReadonlyEditor): + """ Readonly Editor style for CSVListEditor. """ + prepare = _prepare_method + dispose = _dispose_method + +TextEditor = SimpleEditor