Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Do Not Merge] New GUI Test Tools #447

Draft
wants to merge 18 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ cache:
before_install:
- mkdir -p "${HOME}/.cache/download"
- if [[ ${TRAVIS_OS_NAME} == 'linux' ]]; then ./install-edm-linux.sh; export PATH="${HOME}/edm/bin:${PATH}"; fi
- if [[ ${TRAVIS_OS_NAME} == 'linux' ]]; then sudo apt-get install -y libdbus-1-3 libxkbcommon-x11-0; fi
- if [[ ${TRAVIS_OS_NAME} == 'osx' ]]; then ./install-edm-osx.sh; export PATH="${PATH}:/usr/local/bin"; fi
- edm install -y wheel click coverage
install:
Expand Down
2 changes: 1 addition & 1 deletion etstool.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ def install(runtime, toolkit, environment):
commands.append("edm run -e {environment} -- pip install pyqt5==5.9.2")
elif toolkit == 'pyside2':
commands.append(
"edm run -e {environment} -- pip install pyside2==5.11.1"
"edm run -e {environment} -- pip install pyside2 shiboken2"
)

click.echo("Creating environment '{environment}'".format(**parameters))
Expand Down
9 changes: 5 additions & 4 deletions pyface/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@

__requires__ = ['traits']
__extras_require__ = {
'wx': ['wxpython>=2.8.10', 'numpy'],
'pyqt': ['pyqt>=4.10', 'pygments'],
'pyqt5': ['pyqt>=5', 'pygments'],
'pyside': ['pyside>=1.2', 'pygments'],
'wx': ['wxpython>=2.8.10,<4.0.0', 'numpy'],
'pyqt': ['pyqt4>=4.10', 'pygments'],
'pyqt5': ['pyqt5', 'pygments'],
'pyside': ['pyside>=1.2', 'pygments', 'shiboken'],
'pyside2': ['pyside2', 'pygments', 'shiboken2'],
}
23 changes: 21 additions & 2 deletions pyface/i_gui.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#------------------------------------------------------------------------------
# Copyright (c) 2005, Enthought, Inc.
# Copyright (c) 2005-19, Enthought, Inc.
# All rights reserved.
#
# This software is provided without warranty under the terms of the BSD
Expand All @@ -20,7 +20,7 @@

# Enthought library imports.
from traits.etsconfig.api import ETSConfig
from traits.api import Bool, Interface, Unicode
from traits.api import Any, Bool, Interface, Property, Unicode


# Logging.
Expand All @@ -32,13 +32,19 @@ class IGUI(Interface):

#### 'GUI' interface ######################################################

#: A reference to the toolkit application singleton.
app = Property

#: Is the GUI busy (i.e. should the busy cursor, often an hourglass, be
#: displayed)?
busy = Bool(False)

#: Has the GUI's event loop been started?
started = Bool(False)

#: Whether the GUI quits on last window close.
quit_on_last_window_close = Property(Bool)

#: A directory on the local file system that we can read and write to at
#: will. This is used to persist layout information etc. Note that
#: individual toolkits will have their own directory.
Expand Down Expand Up @@ -162,6 +168,19 @@ def start_event_loop(self):
def stop_event_loop(self):
""" Stop the GUI event loop. """

def top_level_windows(self):
""" Return all top-level windows.

This does not include windows which are children of other
windows.
"""

def close_all(self):
""" Close all top-level windows.

This may or may not exit the application, depending on other settings.
"""


class MGUI(object):
""" The mixin class that contains common code for toolkit specific
Expand Down
10 changes: 10 additions & 0 deletions pyface/i_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,16 @@ def close(self, force=False):
Whether or not the window is closed.
"""

def activate(self, should_raise=True):
""" Activate the Window

Parameters
----------
should_raise : bool
Whether or not the window should be raised to the front
of the z-order as well as being given user focus.
"""

def confirm(self, message, title=None, cancel=False, default=NO):
""" Convenience method to show a confirmation dialog.

Expand Down
2 changes: 2 additions & 0 deletions pyface/qt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,5 @@ def prepare_pyqt4():
# useful constants
is_qt4 = (qt_api in {'pyqt', 'pyside'})
is_qt5 = (qt_api in {'pyqt5', 'pyside2'})
is_pyqt = (qt_api in {'pyqt', 'pyqt5'})
is_pyside = (qt_api in {'pyside', 'pyside2'})
Empty file added pyface/testing/__init__.py
Empty file.
127 changes: 127 additions & 0 deletions pyface/testing/event_loop_helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# (C) Copyright 2019 Enthought, Inc., Austin, TX
# All right 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!


import contextlib

from traits.api import HasStrictTraits, Instance

from pyface.gui import GUI
from pyface.i_gui import IGUI
from pyface.timer.api import CallbackTimer, EventTimer


class ConditionTimeoutError(RuntimeError):
pass


class EventLoopHelper(HasStrictTraits):
""" Toolkit-independent methods for running event loops in tests.
"""

#: A reference to the GUI object
gui = Instance(IGUI, factory=GUI)

@contextlib.contextmanager
def dont_quit_when_last_window_closed(self):
""" Suppress exit of the application when the last window is closed.
"""
flag = self.gui.quit_on_last_window_close
self.gui.quit_on_last_window_close = False
try:
yield
finally:
self.gui.quit_on_last_window_close = flag

def event_loop(self, repeat=1, allow_user_events=True):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd really like to include some advice in the docstring that if you find yourself needing to use a repeat of greater than 1, you should consider whether you can use event_loop_until_condition instead.

""" Emulate an event loop running ``repeat`` times.

Parameters
----------
repeat : positive int
The number of times to call process events. Default is 1.
allow_user_events : bool
Whether to process user-generated events.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps add "for example keyboard and mouse events" to clarify what's meant by "user-generated events" in this context?

"""
for i in range(repeat):
self.gui.process_events(allow_user_events)

def event_loop_until_condition(self, condition, timeout=10.0):
""" Run the event loop until condition returns true, or timeout.

This runs the real event loop, rather than emulating it with
:meth:`GUI.process_events`. Conditions and timeouts are tracked
using timers.

Parameters
----------
condition : callable
A callable to determine if the stop criteria have been met. This
should accept no arguments.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should also be explicit about the expected return type of the callable.


timeout : float
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this also be documented as "keyword only", to match event_loop_with_timeout?

Number of seconds to run the event loop in the case that the trait
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copypasta here: "the trait change does not occur".

change does not occur.

Raises
------
ConditionTimeoutError
If the timeout occurs before the condition is True.
"""

with self.dont_quit_when_last_window_closed():
condition_timer = CallbackTimer.timer(
stop_condition=condition,
interval=0.05,
expire=timeout,
)
condition_timer.on_trait_change(self._on_stop, 'active')

try:
self.gui.start_event_loop()
if not condition():
raise ConditionTimeoutError(
'Timed out waiting for condition')
finally:
condition_timer.on_trait_change(
self._on_stop, 'active', remove=True)
condition_timer.stop()

def event_loop_with_timeout(self, repeat=2, timeout=10):
""" Run the event loop for timeout seconds.

Parameters
----------
timeout: float, optional, keyword only
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"keyword only" doesn't seem to actually be true here. Presumably the intent is to add that extra "*" to the signature after Python 2 support has been dropped? (If so, should there be an issue open for that?)

Number of seconds to run the event loop. Default value is 10.0.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The repeat parameter isn't documented.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also document the conditions under which ConditionTimeoutError is raised.

I'm not really sure I understand when this method would be used, and when it should be considered that an error has occurred.

"""
with self.dont_quit_when_last_window_closed():
repeat_timer = EventTimer.timer(
repeat=repeat,
interval=0.05,
expire=timeout,
)
repeat_timer.on_trait_change(self._on_stop, 'active')

try:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to engineer a test for the corner case where the timer timeout expires before the event loop is started? (Perhaps by patching start_event_loop to sleep for some time first.) I'm reasonably convinced that the current logic is fine (_on_stop must be called from the main thread, so it can't possibly be called until the event loop starts), but it would be good to have a test to prevent future regressions.

What happens if the timeout is zero? Is that a use-case we want to support, or should the docstring specify that timeout should be positive?

self.gui.start_event_loop()
if repeat_timer.repeat > 0:
msg = 'Timed out waiting for repetition, {} remaining'
raise ConditionTimeoutError(
msg.format(repeat_timer.repeat)
)
finally:
repeat_timer.on_trait_change(
self._on_stop, 'active', remove=True)
repeat_timer.stop()

def _on_stop(self, active):
""" Trait handler that stops event loop. """
if not active:
self.gui.stop_event_loop()
Loading