diff --git a/gui/mozregui/bisection.py b/gui/mozregui/bisection.py index 4c877c2d..e27634bf 100644 --- a/gui/mozregui/bisection.py +++ b/gui/mozregui/bisection.py @@ -1,100 +1,20 @@ import sys from PyQt4.QtCore import QObject, pyqtSignal as Signal, pyqtSlot as Slot, \ - QThread, QTimer + QTimer from PyQt4.QtGui import QMessageBox, QDialog, QRadioButton from mozregression.bisector import Bisector, Bisection, NightlyHandler, \ InboundHandler -from mozregression.download_manager import BuildDownloadManager -from mozregression.test_runner import TestRunner -from mozregression.errors import MozRegressionError, LauncherError -from mozregression.network import get_http_session -from mozregression.persist_limit import PersistLimit +from mozregression.errors import MozRegressionError from mozregression.dates import is_date_or_datetime +from mozregui.build_runner import AbstractBuildRunner from mozregui.ui.verdict import Ui_Verdict -from mozregui.global_prefs import get_prefs, apply_prefs from mozregui.skip_chooser import SkipDialog Bisection.EXCEPTION = -1 # new possible value of bisection end -class GuiBuildDownloadManager(QObject, BuildDownloadManager): - download_progress = Signal(object, int, int) - download_started = Signal(object) - download_finished = Signal(object, str) - - def __init__(self, destdir, persist_limit, **kwargs): - QObject.__init__(self) - persist_limit = PersistLimit(persist_limit) - BuildDownloadManager.__init__(self, destdir, - session=get_http_session(), - persist_limit=persist_limit, - **kwargs) - - def _download_started(self, task): - self.download_started.emit(task) - BuildDownloadManager._download_started(self, task) - - def _download_finished(self, task): - try: - self.download_finished.emit(task, task.get_dest()) - except RuntimeError: - # in some cases, closing the application may destroy the - # underlying c++ QObject, causing this signal to fail. - # Skip this silently. - pass - BuildDownloadManager._download_finished(self, task) - - def focus_download(self, build_info): - build_url, fname = self._extract_download_info(build_info) - dest = self.get_dest(fname) - build_info.build_file = dest - # first, stop all downloads in background (except the one for this - # build if any) - self.cancel(cancel_if=lambda dl: dest != dl.get_dest()) - - dl = self.download(build_url, fname) - if dl: - dl.set_progress(self.download_progress.emit) - else: - # file already downloaded. - # emit the finished signal so bisection goes on - self.download_finished.emit(None, dest) - - -class GuiTestRunner(QObject, TestRunner): - evaluate_started = Signal(str) - evaluate_finished = Signal() - - def __init__(self): - QObject.__init__(self) - TestRunner.__init__(self) - self.verdict = None - self.launcher = None - self.launcher_kwargs = {} - - def evaluate(self, build_info, allow_back=False): - try: - self.launcher = self.create_launcher(build_info) - self.launcher.start(**self.launcher_kwargs) - build_info.update_from_app_info(self.launcher.get_app_info()) - except LauncherError, exc: - self.evaluate_started.emit(str(exc)) - else: - self.evaluate_started.emit('') - - def finish(self, verdict): - if self.launcher: - try: - self.launcher.stop() - except LauncherError: - pass # silently pass stop process error - self.launcher.cleanup() - self.verdict = verdict - self.evaluate_finished.emit() - - class GuiBisector(QObject, Bisector): started = Signal() finished = Signal(object, int) @@ -105,10 +25,9 @@ class GuiBisector(QObject, Bisector): step_finished = Signal(object, str) handle_merge = Signal(object, str, str, str) - def __init__(self, fetch_config, download_dir, persist_limit): + def __init__(self, fetch_config, test_runner, download_manager): QObject.__init__(self) - Bisector.__init__(self, fetch_config, GuiTestRunner(), - GuiBuildDownloadManager(download_dir, persist_limit)) + Bisector.__init__(self, fetch_config, test_runner, download_manager) self.bisection = None self.mid = None self.build_infos = None @@ -246,44 +165,16 @@ def get_verdict(parent=None): return radiobox.objectName() -class BisectRunner(QObject): - bisector_created = Signal(object) - running_state_changed = Signal(bool) +class BisectRunner(AbstractBuildRunner): + worker_class = GuiBisector - def __init__(self, mainwindow): - QObject.__init__(self) - self.mainwindow = mainwindow - self.bisector = None - self.thread = None - self.pending_threads = [] + def init_worker(self, fetch_config, options): + AbstractBuildRunner.init_worker(self, fetch_config, options) - def bisect(self, fetch_config, options): - self.stop() - - # global preferences - global_prefs = get_prefs() - # apply the global prefs now - apply_prefs(global_prefs) - - download_dir = global_prefs['persist'] - persist_limit = int(abs(global_prefs['persist_size_limit']) - * 1073741824) - if not download_dir: - download_dir = self.mainwindow.persist - - self.bisector = GuiBisector(fetch_config, - download_dir, persist_limit) - # create a QThread, and move self.bisector in it. This will - # allow to the self.bisector slots (connected after the move) - # to be automatically called in the thread. - self.thread = QThread() - self.bisector.moveToThread(self.thread) - self.bisector.test_runner.evaluate_started.connect( - self.evaluate) - self.bisector.finished.connect(self.bisection_finished) - self.bisector.handle_merge.connect(self.handle_merge) - self.bisector.choose_next_build.connect(self.choose_next_build) - self.bisector_created.emit(self.bisector) + self.worker.test_runner.evaluate_started.connect(self.evaluate) + self.worker.finished.connect(self.bisection_finished) + self.worker.handle_merge.connect(self.handle_merge) + self.worker.choose_next_build.connect(self.choose_next_build) good, bad = options.pop('good'), options.pop('bad') if is_date_or_datetime(good) and is_date_or_datetime(bad) \ @@ -292,52 +183,14 @@ class BisectRunner(QObject): else: handler = InboundHandler(find_fix=options['find_fix']) - # options for the app launcher - launcher_kwargs = {} - for name in ('profile', 'preferences'): - if name in options: - value = options[name] - if value: - launcher_kwargs[name] = value - - # add add-ons paths to the app launcher - launcher_kwargs['addons'] = options['addons'] - self.bisector.test_runner.launcher_kwargs = launcher_kwargs - - self.thread.start() - self.bisector._bisect_args = (handler, good, bad) - # this will be called in the worker thread. - QTimer.singleShot(0, self.bisector.bisect) - - self.running_state_changed.emit(True) + self.worker._bisect_args = (handler, good, bad) + return self.worker.bisect @Slot() def stop(self, wait=True): - if self.bisector: - self.bisector.finished.disconnect(self.bisection_finished) - self.bisector.download_manager.cancel() - self.bisector = None - if self.thread: - self.thread.quit() - if wait: - # wait for thread(s) completion - this is the case when - # user close the application - self.thread.wait() - for thread in self.pending_threads: - thread.wait() - else: - # do not block, just keep track of the thread - we got here - # when user cancel the bisection with the button. - self.pending_threads.append(self.thread) - self.thread.finished.connect(self._remove_pending_thread) - self.thread = None - self.running_state_changed.emit(False) - - @Slot() - def _remove_pending_thread(self): - for thread in self.pending_threads[:]: - if thread.isFinished(): - self.pending_threads.remove(thread) + if self.worker: + self.worker.finished.disconnect(self.bisection_finished) + AbstractBuildRunner.stop(self, wait=wait) @Slot(str) def evaluate(self, err_message): @@ -352,13 +205,13 @@ class BisectRunner(QObject): % err_message) ) verdict = 's' - self.bisector.test_runner.finish(verdict) + self.worker.test_runner.finish(verdict) @Slot() def choose_next_build(self): - dlg = SkipDialog(self.bisector.bisection.build_range) - self.bisector._next_build_index = dlg.choose_next_build() - QTimer.singleShot(0, self.bisector._bisect_next) + dlg = SkipDialog(self.worker.bisection.build_range) + self.worker._next_build_index = dlg.choose_next_build() + QTimer.singleShot(0, self.worker._bisect_next) @Slot(object, int) def bisection_finished(self, bisection, resultcode): @@ -369,20 +222,20 @@ class BisectRunner(QObject): msg = "Unable to find enough data to bisect." dialog = QMessageBox.warning elif resultcode == Bisection.EXCEPTION: - msg = "Error: %s" % self.bisector.error[1] + msg = "Error: %s" % self.worker.error[1] dialog = QMessageBox.critical else: - fetch_config = self.bisector.fetch_config + fetch_config = self.worker.fetch_config if fetch_config.can_go_inbound() and not \ getattr(bisection, 'no_more_merge', False): if isinstance(bisection.handler, NightlyHandler): handler = bisection.handler fetch_config.set_repo( fetch_config.get_nightly_repo(handler.bad_date)) - QTimer.singleShot(0, self.bisector.bisect_further) + QTimer.singleShot(0, self.worker.bisect_further) else: # check merge, try to bisect further - QTimer.singleShot(0, self.bisector.check_merge) + QTimer.singleShot(0, self.worker.check_merge) return else: # no inbound, bisection is done. @@ -401,9 +254,9 @@ class BisectRunner(QObject): % branch, QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes): - self.bisector.fetch_config.set_repo(str(branch)) + self.worker.fetch_config.set_repo(str(branch)) bisection.handler.good_revision = str(good_rev) bisection.handler.bad_revision = str(bad_rev) - QTimer.singleShot(0, self.bisector.bisect_further) + QTimer.singleShot(0, self.worker.bisect_further) else: self.stop() diff --git a/gui/mozregui/build_runner.py b/gui/mozregui/build_runner.py new file mode 100644 index 00000000..bc7c7fd5 --- /dev/null +++ b/gui/mozregui/build_runner.py @@ -0,0 +1,184 @@ +from PyQt4.QtCore import QObject, QThread, pyqtSignal as Signal, \ + pyqtSlot as Slot, QTimer + +from mozregui.global_prefs import get_prefs, apply_prefs +from mozregression.download_manager import BuildDownloadManager +from mozregression.test_runner import TestRunner +from mozregression.network import get_http_session +from mozregression.persist_limit import PersistLimit +from mozregression.errors import LauncherError + + +class GuiBuildDownloadManager(QObject, BuildDownloadManager): + download_progress = Signal(object, int, int) + download_started = Signal(object) + download_finished = Signal(object, str) + + def __init__(self, destdir, persist_limit, **kwargs): + QObject.__init__(self) + persist_limit = PersistLimit(persist_limit) + BuildDownloadManager.__init__(self, destdir, + session=get_http_session(), + persist_limit=persist_limit, + **kwargs) + + def _download_started(self, task): + self.download_started.emit(task) + BuildDownloadManager._download_started(self, task) + + def _download_finished(self, task): + try: + self.download_finished.emit(task, task.get_dest()) + except RuntimeError: + # in some cases, closing the application may destroy the + # underlying c++ QObject, causing this signal to fail. + # Skip this silently. + pass + BuildDownloadManager._download_finished(self, task) + + def focus_download(self, build_info): + build_url, fname = self._extract_download_info(build_info) + dest = self.get_dest(fname) + build_info.build_file = dest + # first, stop all downloads in background (except the one for this + # build if any) + self.cancel(cancel_if=lambda dl: dest != dl.get_dest()) + + dl = self.download(build_url, fname) + if dl: + dl.set_progress(self.download_progress.emit) + else: + # file already downloaded. + # emit the finished signal so bisection goes on + self.download_finished.emit(None, dest) + + +class GuiTestRunner(QObject, TestRunner): + evaluate_started = Signal(str) + evaluate_finished = Signal() + + def __init__(self): + QObject.__init__(self) + TestRunner.__init__(self) + self.verdict = None + self.launcher = None + self.launcher_kwargs = {} + + def evaluate(self, build_info, allow_back=False): + try: + self.launcher = self.create_launcher(build_info) + self.launcher.start(**self.launcher_kwargs) + build_info.update_from_app_info(self.launcher.get_app_info()) + except LauncherError, exc: + self.evaluate_started.emit(str(exc)) + else: + self.evaluate_started.emit('') + + def finish(self, verdict): + if self.launcher: + try: + self.launcher.stop() + except LauncherError: + pass # silently pass stop process error + self.launcher.cleanup() + self.verdict = verdict + self.evaluate_finished.emit() + + +class AbstractBuildRunner(QObject): + """ + Base class to run a build. + + Create the required test runner and build manager, along with a thread + that should be used for blocking tasks. + """ + running_state_changed = Signal(bool) + worker_created = Signal(object) + worker_class = None + + def __init__(self, mainwindow): + QObject.__init__(self) + self.mainwindow = mainwindow + self.thread = None + self.worker = None + self.pending_threads = [] + self.test_runner = GuiTestRunner() + self.download_manager = None + + def init_worker(self, fetch_config, options): + """ + Create and initialize the worker. + + Should be subclassed to configure the worker, and should return the + worker method that should start the work. + """ + self.stop() + + # global preferences + global_prefs = get_prefs() + # apply the global prefs now + apply_prefs(global_prefs) + + download_dir = global_prefs['persist'] + if not download_dir: + download_dir = self.mainwindow.persist + persist_limit = int(abs(global_prefs['persist_size_limit']) + * 1073741824) + self.download_manager = GuiBuildDownloadManager(download_dir, + persist_limit) + self.thread = QThread() + + # options for the app launcher + launcher_kwargs = {} + for name in ('profile', 'preferences'): + if name in options: + value = options[name] + if value: + launcher_kwargs[name] = value + + # add add-ons paths to the app launcher + launcher_kwargs['addons'] = options['addons'] + self.test_runner.launcher_kwargs = launcher_kwargs + + self.worker = self.worker_class(fetch_config, self.test_runner, + self.download_manager) + # Move self.bisector in the thread. This will + # allow to the self.bisector slots (connected after the move) + # to be automatically called in the thread. + self.worker.moveToThread(self.thread) + self.worker_created.emit(self.worker) + + def start(self, fetch_config, options): + action = self.init_worker(fetch_config, options) + assert callable(action), "%s should be callable" % action + self.thread.start() + # this will be called in the worker thread. + QTimer.singleShot(0, action) + self.running_state_changed.emit(True) + + @Slot() + def stop(self, wait=True): + self.test_runner.finish(None) + if self.download_manager: + self.download_manager.cancel() + if self.thread: + self.thread.quit() + if wait: + # wait for thread(s) completion - this is the case when + # user close the application + self.thread.wait() + for thread in self.pending_threads: + thread.wait() + else: + # do not block, just keep track of the thread - we got here + # when user uses the stop button. + self.pending_threads.append(self.thread) + self.thread.finished.connect(self._remove_pending_thread) + self.thread = None + self.running_state_changed.emit(False) + + @Slot() + def _remove_pending_thread(self): + for thread in self.pending_threads[:]: + if thread.isFinished(): + self.pending_threads.remove(thread) diff --git a/gui/mozregui/main.py b/gui/mozregui/main.py index 042b60ab..c4ae4359 100644 --- a/gui/mozregui/main.py +++ b/gui/mozregui/main.py @@ -55,7 +55,7 @@ class MainWindow(QMainWindow): self.bisect_runner = BisectRunner(self) - self.bisect_runner.bisector_created.connect( + self.bisect_runner.worker_created.connect( self.ui.report_view.model().attach_bisector) self.ui.report_view.step_report_changed.connect( @@ -94,7 +94,7 @@ class MainWindow(QMainWindow): wizard = BisectionWizard(self) if wizard.exec_() == wizard.Accepted: self.ui.report_view.model().clear() - self.bisect_runner.bisect(*wizard.options()) + self.bisect_runner.start(*wizard.options()) @Slot() def stop_bisection(self): diff --git a/gui/tests/test_bisection.py b/gui/tests/test_build_runner.py similarity index 91% rename from gui/tests/test_bisection.py rename to gui/tests/test_build_runner.py index e5eb83db..d6d6ffe0 100644 --- a/gui/tests/test_bisection.py +++ b/gui/tests/test_build_runner.py @@ -6,7 +6,7 @@ import os from mock import Mock, patch from . import wait_signal -from mozregui import bisection +from mozregui import build_runner from mozregression.persist_limit import PersistLimit @@ -36,7 +36,7 @@ class TestGuiBuildDownloadManager(unittest.TestCase): tpersist_size = PersistLimit(10 * 1073741824) self.addCleanup(shutil.rmtree, tmpdir) self.dl_manager = \ - bisection.GuiBuildDownloadManager(tmpdir, tpersist_size) + build_runner.GuiBuildDownloadManager(tmpdir, tpersist_size) self.dl_manager.session = self.session self.signals = {} for sig in ('download_progress', 'download_started', @@ -44,7 +44,8 @@ class TestGuiBuildDownloadManager(unittest.TestCase): self.signals[sig] = Mock() getattr(self.dl_manager, sig).connect(self.signals[sig]) - @patch('mozregui.bisection.GuiBuildDownloadManager._extract_download_info') + @patch( + 'mozregui.build_runner.GuiBuildDownloadManager._extract_download_info') def test_focus_download(self, extract_info): extract_info.return_value = ('http://foo', 'foo') mock_response(self.session_response, 'this is some data' * 10000, 0.01) @@ -70,11 +71,11 @@ class TestGuiTestRunner(unittest.TestCase): def setUp(self): self.evaluate_started = Mock() self.evaluate_finished = Mock() - self.test_runner = bisection.GuiTestRunner() + self.test_runner = build_runner.GuiTestRunner() self.test_runner.evaluate_started.connect(self.evaluate_started) self.test_runner.evaluate_finished.connect(self.evaluate_finished) - @patch('mozregui.bisection.GuiTestRunner.create_launcher') + @patch('mozregui.build_runner.GuiTestRunner.create_launcher') def test_basic(self, create_launcher): launcher = Mock(get_app_info=lambda: 'app_info') create_launcher.return_value = launcher