diff --git a/MDANSE/Src/MDANSE/Framework/Jobs/IJob.py b/MDANSE/Src/MDANSE/Framework/Jobs/IJob.py index 8b9603f367..a7bc1aa2bd 100644 --- a/MDANSE/Src/MDANSE/Framework/Jobs/IJob.py +++ b/MDANSE/Src/MDANSE/Framework/Jobs/IJob.py @@ -208,13 +208,24 @@ def combine(self): self._status.update() def _run_monoprocessor(self): + print(f"Monoprocessor run: expects {self.numberOfSteps} steps") for index in range(self.numberOfSteps): + if self._status is not None: + if hasattr(self._status, "_pause_event"): + self._status._pause_event.wait() idx, result = self.run_step(index) + if self._status is not None: + self._status.update() self.combine(idx, result) def _run_threadpool(self): def helper(self, index): + if self._status is not None: + if hasattr(self._status, "_pause_event"): + self._status._pause_event.wait() idx, result = self.run_step(index) + if self._status is not None: + self._status.update() self.combine(idx, result) pool = PoolExecutor(max_workers=self.configuration["running_mode"]["slots"]) @@ -302,7 +313,7 @@ def run(self, parameters, status=False): self.initialize() if self._status is not None: - self._status.start(self.numberOfSteps, rate=0.1) + self._status.start(self.numberOfSteps) self._status.state["info"] = str(self) if getattr(self, "numberOfSteps", 0) <= 0: diff --git a/MDANSE/Src/MDANSE/Framework/Status.py b/MDANSE/Src/MDANSE/Framework/Status.py index d4b8864940..ca5ee19604 100644 --- a/MDANSE/Src/MDANSE/Framework/Status.py +++ b/MDANSE/Src/MDANSE/Framework/Status.py @@ -134,11 +134,7 @@ def update(self, force=False): self._deltas.append(lastUpdate) - if ( - force - or (not self._currentStep % self._updateStep) - or (total_seconds(lastUpdate - self._lastRefresh) > 10) - ): + if force or (total_seconds(lastUpdate - self._lastRefresh) > 5): self._lastRefresh = lastUpdate if self._nSteps is not None: @@ -153,4 +149,5 @@ def update(self, force=False): duration = datetime.timedelta(seconds=round(duration)) duration = convert_duration(total_seconds(duration)) self._eta = "%02dd:%02dh:%02dm:%02ds" % duration - self.update_status() + + self.update_status() diff --git a/MDANSE_GUI/Src/MDANSE_GUI/Subprocess/JobState.py b/MDANSE_GUI/Src/MDANSE_GUI/Subprocess/JobState.py new file mode 100644 index 0000000000..bb1d10bfb0 --- /dev/null +++ b/MDANSE_GUI/Src/MDANSE_GUI/Subprocess/JobState.py @@ -0,0 +1,227 @@ +from abc import ABC, abstractmethod + + +class JobState(ABC): + + _label = "JobState" + _allowed_actions = [] + + def __init__(self, parent): + self._parent = parent + + @abstractmethod + def pause(self): + """Pauses the process""" + + @abstractmethod + def unpause(self): + """Resumes the process execution""" + + @abstractmethod + def start(self): + """Starts the process""" + + @abstractmethod + def terminate(self): + """Instructs the process to stop""" + + @abstractmethod + def kill(self): + """Stops the process the hard way""" + + @abstractmethod + def finish(self): + """Reach the end of the run successfully""" + + @abstractmethod + def fail(self): + """Break down on before finishing""" + + +class Running(JobState): + + _label = "Running" + _allowed_actions = [ + "Pause", + "Terminate", + "Kill", + ] + + def pause(self): + """Pauses the process""" + self._parent._pause_event.clear() + self._parent._current_state = self._parent._Paused + + def unpause(self): + """Resumes the process execution""" + + def start(self): + """Starts the process""" + + def terminate(self): + """Instructs the process to stop""" + self._parent._current_state = self._parent._Aborted + + def kill(self): + """Stops the process the hard way""" + self._parent._current_state = self._parent._Aborted + + def finish(self): + """Reach the end of the run successfully""" + self._parent.percent_complete = 100 + self._parent._current_state = self._parent._Finished + + def fail(self): + """Break down on before finishing""" + self._parent._current_state = self._parent._Failed + + +class Aborted(JobState): + + _label = "Aborted" + _allowed_actions = ["Delete"] + + def pause(self): + """Pauses the process""" + + def unpause(self): + """Resumes the process execution""" + + def start(self): + """Starts the process""" + + def terminate(self): + """Instructs the process to stop""" + + def kill(self): + """Stops the process the hard way""" + + def finish(self): + """Reach the end of the run successfully""" + + def fail(self): + """Break down on before finishing""" + + +class Failed(JobState): + + _label = "Failed" + _allowed_actions = ["Delete"] + + def pause(self): + """Pauses the process""" + + def unpause(self): + """Resumes the process execution""" + + def start(self): + """Starts the process""" + + def terminate(self): + """Instructs the process to stop""" + + def kill(self): + """Stops the process the hard way""" + + def finish(self): + """Reach the end of the run successfully""" + + def fail(self): + """Break down on before finishing""" + + +class Finished(JobState): + + _label = "Finished" + _allowed_actions = ["Delete"] + + def pause(self): + """Pauses the process""" + + def unpause(self): + """Resumes the process execution""" + + def start(self): + """Starts the process""" + + def terminate(self): + """Instructs the process to stop""" + + def kill(self): + """Stops the process the hard way""" + + def finish(self): + """Reach the end of the run successfully""" + + def fail(self): + """Break down on before finishing""" + + +class Starting(JobState): + + _label = "Starting" + _allowed_actions = [ + "Pause", + "Terminate", + "Kill", + ] + + def pause(self): + """Pauses the process""" + + def unpause(self): + """Resumes the process execution""" + + def start(self): + """Starts the process""" + self._parent._current_state = self._parent._Running + + def terminate(self): + """Instructs the process to stop""" + + def kill(self): + """Stops the process the hard way""" + + def finish(self): + """Reach the end of the run successfully""" + + def fail(self): + """Break down on before finishing""" + self._parent._current_state = self._parent._Failed + + +class Paused(JobState): + + _label = "Paused" + _allowed_actions = [ + "Resume", + "Terminate", + "Kill", + ] + + def pause(self): + """Pauses the process""" + + def unpause(self): + """Resumes the process execution""" + self._parent._pause_event.set() + self._parent._current_state = self._parent._Running + + def start(self): + """Starts the process""" + + def terminate(self): + """Instructs the process to stop""" + self._parent._current_state = self._parent._Aborted + + def kill(self): + """Stops the process the hard way""" + self._parent._current_state = self._parent._Aborted + + def finish(self): + """Reach the end of the run successfully""" + self._parent.percent_complete = 100 + self._parent._current_state = self._parent._Finished + + def fail(self): + """Break down on before finishing""" diff --git a/MDANSE_GUI/Src/MDANSE_GUI/Subprocess/JobStatusProcess.py b/MDANSE_GUI/Src/MDANSE_GUI/Subprocess/JobStatusProcess.py new file mode 100644 index 0000000000..dd522d24e2 --- /dev/null +++ b/MDANSE_GUI/Src/MDANSE_GUI/Subprocess/JobStatusProcess.py @@ -0,0 +1,85 @@ +# ************************************************************************** +# +# MDANSE: Molecular Dynamics Analysis for Neutron Scattering Experiments +# +# @file Src/PyQtGUI/DataViewModel/JobHolder.py +# @brief Subclass of QStandardItemModel for MDANSE jobs +# +# @homepage https://mdanse.org +# @license GNU General Public License v3 or higher (see LICENSE) +# @copyright Institut Laue Langevin 2013-now +# @copyright ISIS Neutron and Muon Source, STFC, UKRI 2021-now +# @authors Research Software Group at ISIS (see AUTHORS) +# +# ************************************************************************** + +from typing import Tuple +from multiprocessing import Pipe, Queue, Process, Event +from multiprocessing.connection import Connection + +from icecream import ic +from qtpy.QtCore import QObject, Slot, Signal, QProcess, QThread, QMutex + +from MDANSE.Framework.Status import Status + + +class JobCommunicator(QObject): + target = Signal(int) + progress = Signal(int) + finished = Signal(bool) + oscillate = Signal() + + def status_update(self, input: Tuple): + key, value = input + if key == "FINISHED": + self.finished.emit(value) + elif key == "STEP": + self.progress.emit(value) + elif key == "STARTED": + if value is not None: + self.target.emit(value) + else: + self.oscillate.emit() + elif key == "COMMUNICATION": + print(f"Communication with the subprocess is now {value}") + self.finished.emit(value) + + +class JobStatusProcess(Status): + def __init__( + self, pipe: "Connection", queue: Queue, pause_event: "Event", **kwargs + ): + super().__init__() + self._pipe = pipe + self._queue = queue + self._state = {} # for compatibility with JobStatus + self._progress_meter = 0 + self._pause_event = pause_event + self._pause_event.set() + + @property + def state(self): + return self._state + + def finish_status(self): + ic() + self._pipe.send(("FINISHED", True)) + + def start_status(self): + ic() + try: + temp = int(self._nSteps) + except: + self._pipe.send(("STARTED", None)) + else: + self._pipe.send(("STARTED", temp)) + # self._updateStep = 1 + + def stop_status(self): + ic() + self._pipe.send(("FINISHED", False)) + + def update_status(self): + self._progress_meter += 1 + temp = int(self._progress_meter) * self._updateStep + self._pipe.send(("STEP", temp)) diff --git a/MDANSE_GUI/Src/MDANSE_GUI/Subprocess/Subprocess.py b/MDANSE_GUI/Src/MDANSE_GUI/Subprocess/Subprocess.py new file mode 100644 index 0000000000..e6fc7e36bd --- /dev/null +++ b/MDANSE_GUI/Src/MDANSE_GUI/Subprocess/Subprocess.py @@ -0,0 +1,46 @@ +# ************************************************************************** +# +# MDANSE: Molecular Dynamics Analysis for Neutron Scattering Experiments +# +# @file Src/PyQtGUI/DataViewModel/JobHolder.py +# @brief Subclass of QStandardItemModel for MDANSE jobs +# +# @homepage https://mdanse.org +# @license GNU General Public License v3 or higher (see LICENSE) +# @copyright Institut Laue Langevin 2013-now +# @copyright ISIS Neutron and Muon Source, STFC, UKRI 2021-now +# @authors Research Software Group at ISIS (see AUTHORS) +# +# ************************************************************************** + +from multiprocessing import Pipe, Queue, Process, Event +from multiprocessing.connection import Connection + +from icecream import ic + +from MDANSE.Framework.Jobs.IJob import IJob + +from MDANSE_GUI.Subprocess.JobStatusProcess import JobStatusProcess + + +class Subprocess(Process): + def __init__(self, *args, **kwargs): + super().__init__() + job_name = kwargs.get("job_name") + self._job_parameters = kwargs.get("job_parameters") + sending_pipe = kwargs.get("pipe") + receiving_queue = kwargs.get("queue") + pause_event = kwargs.get("pause_event") + self.construct_job(job_name, sending_pipe, receiving_queue, pause_event) + + def construct_job( + self, job: str, pipe: Connection, queue: "Queue", pause_event: "Event" + ): + job_instance = IJob.create(job) + job_instance.build_configuration() + status = JobStatusProcess(pipe, queue, pause_event) + job_instance._status = status + self._job_instance = job_instance + + def run(self): + self._job_instance.run(self._job_parameters) diff --git a/MDANSE_GUI/Src/MDANSE_GUI/Subprocess/__init__.py b/MDANSE_GUI/Src/MDANSE_GUI/Subprocess/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/ConverterTab.py b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/ConverterTab.py index 0d687aa40c..218c09c49f 100644 --- a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/ConverterTab.py +++ b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/ConverterTab.py @@ -44,7 +44,7 @@ def __init__(self, *args, **kwargs): def set_job_starter(self, job_starter): self._job_starter = job_starter - self.action.new_thread_objects.connect(self._job_starter.startThread) + self.action.new_thread_objects.connect(self._job_starter.startProcess) @Slot(str) def set_current_trajectory(self, new_name: str): diff --git a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/JobTab.py b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/JobTab.py index 0b28fa59f6..a0bca53e2d 100644 --- a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/JobTab.py +++ b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/JobTab.py @@ -49,7 +49,7 @@ def __init__(self, *args, **kwargs): def set_job_starter(self, job_starter): self._job_starter = job_starter - self.action.new_thread_objects.connect(self._job_starter.startThread) + self.action.new_thread_objects.connect(self._job_starter.startProcess) @Slot(str) def set_current_trajectory(self, new_name: str) -> None: diff --git a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Models/JobHolder.py b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Models/JobHolder.py index e779aa28d6..54f7f62b27 100644 --- a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Models/JobHolder.py +++ b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Models/JobHolder.py @@ -13,79 +13,96 @@ # # ************************************************************************** +from multiprocessing import Pipe, Queue, Event + from icecream import ic from qtpy.QtGui import QStandardItemModel, QStandardItem -from qtpy.QtCore import QObject, Slot, Signal, QProcess, QThread, QMutex +from qtpy.QtCore import QObject, Slot, Signal, QTimer, QThread, QMutex, Qt from MDANSE.Framework.Jobs.IJob import IJob -from MDANSE_GUI.DataViewModel.JobStatusQt import JobStatusQt +from MDANSE_GUI.Subprocess.Subprocess import Subprocess, Connection +from MDANSE_GUI.Subprocess.JobState import ( + Starting, + Finished, + Running, + Failed, + Paused, + Aborted, +) +from MDANSE_GUI.Subprocess.JobStatusProcess import JobStatusProcess, JobCommunicator class JobThread(QThread): - """A wrapper object for a single MDANSE analysis job. - It is created to run a single instance of the job, - and exits once the job has completed processing.""" - job_failure = Signal(str) + def __init__( + self, + job_comm: "JobCommunicator", + receiving_end: "Connection", + subprocess_reference: "Subprocess", + ): + super().__init__() + self._job_comm = job_comm + self._pipe_end = receiving_end + self._subprocess = subprocess_reference + self._keep_running = True + self._timer = QTimer() + self._timer.timeout.connect(self.check_if_alive) + self._timer.setInterval(2000) + + def start(self, *args, **kwargs) -> None: + retval = super().start(*args, **kwargs) + self._timer.start() + return retval - def __init__(self, *args, command=None, parameters={}): - super().__init__(*args) - ic("JobThread starts init") - self._command = command - self._parameters = parameters - ic("JobThread.run will create a job instance") - ic(f"command is {command}") - if isinstance(self._command, str): - self._job = IJob.create(self._command) - elif isinstance(self._command, type): - self._job = self._command() - else: - self._job = self._command - self._job.build_configuration() - ic(f"JobThread._parameters: {self._parameters}") - # here we try to create and connect a JobStatusQt - status = JobStatusQt(parent=self) - self._job._status = status - self._status = status - ic("JobThread finished init") + def fail(self): + self._job_comm.status_update(("COMMUNICATION", False)) + self._keep_running = False + self._timer.stop() + self.terminate() + + @Slot() + def check_if_alive(self): + if not self._subprocess.is_alive(): + self.fail() def run(self): - try: - self._job.run(self._parameters) - except Exception as inst: - ic("JobThread has entered exception handling!") - error_message = "" - error_message += str(type(inst)) - error_message += str(inst.args) # arguments stored in .args - error_message += str(inst) # __str__ allows args to be printed directly, - ic("JobThread is about to emit the failure message") - self.job_failure.emit(error_message) - else: - ic("JobThread.run did not raise an exception. JobThread.run will exit now") - self.exec() # this starts event handling - will it help? + while self._keep_running: + try: + status_update = self._pipe_end.recv() + except: + self.fail() + else: + self._job_comm.status_update(status_update) class JobEntry(QObject): """This coordinates all the objects that make up one line on the list of current jobs. It is used for reporting the task progress to the GUI.""" - def __init__(self, *args, command=None, entry_number=0): + def __init__(self, *args, command=None, entry_number=0, pause_event=None): super().__init__(*args) self._command = command self._parameters = {} - self.has_started = False - self.has_finished = False - self.success = None + self._pause_event = pause_event + # state pattern + self._current_state = Starting(self) + self._Starting = Starting(self) + self._Finished = Finished(self) + self._Aborted = Aborted(self) + self._Running = Running(self) + self._Failed = Failed(self) + self._Paused = Paused(self) + # other variables self.percent_complete = 0 self._entry_number = entry_number - self._output = None - self.reference = None self.total_steps = 99 self._prog_item = QStandardItem() self._stat_item = QStandardItem() - for item in [self._prog_item, self._stat_item]: + for item in [self._stat_item]: item.setData(entry_number) + self._prog_item.setData(0, role=Qt.ItemDataRole.UserRole) + self._prog_item.setData("progress", role=Qt.ItemDataRole.DisplayRole) def text_summary(self) -> str: result = "" @@ -94,7 +111,7 @@ def text_summary(self) -> str: for key, value in self._parameters.items(): result += f" - {key} = {value}\n" result += "Status:\n" - result += f"Success: {self.success}\n" + result += f"Current state: {self._current_state._label}\n" result += f"Percent complete: {self.percent_complete}\n" return result @@ -108,37 +125,51 @@ def parameters(self, input: dict): def update_fields(self): self._prog_item.setText(f"{self.percent_complete} percent complete") + self._prog_item.setData(self.percent_complete, role=Qt.ItemDataRole.UserRole) + self._stat_item.setText(self._current_state._label) @Slot(bool) def on_finished(self, success: bool): - print("Item received on_finished!") - self.success = success - self.has_finished = True - self._stat_item.setText("Stopped") if success: - self.percent_complete = 100 - self._stat_item.setText("Completed!") + self._current_state.finish() + else: + self._current_state.fail() self.update_fields() @Slot(int) def on_started(self, target_steps: int): - print("Item received on_started!") + print(f"Item received on_started: {target_steps} total steps") self.total_steps = target_steps - self.has_started = True - self._stat_item.setText("Starting") + self._current_state.start() self.update_fields() @Slot(int) def on_update(self, completed_steps: int): - print("Item received on_update!") - self.percent_complete = completed_steps / self.total_steps * 99 - self._stat_item.setText("Running") + # print(f"completed {completed_steps} out of {self.total_steps} steps") + self.percent_complete = round(99 * completed_steps / self.total_steps, 1) self.update_fields() + self._prog_item.emitDataChanged() @Slot() def on_oscillate(self): """For jobs with unknown duration, the progress bar will bounce.""" + def pause_job(self): + self._current_state.pause() + self.update_fields() + + def unpause_job(self): + self._current_state.unpause() + self.update_fields() + + def terminate_job(self): + self._current_state.terminate() + self.update_fields() + + def kill_job(self): + self._current_state.kill() + self.update_fields() + class JobHolder(QStandardItemModel): """All the job INSTANCES that are started by the GUI @@ -147,7 +178,8 @@ class JobHolder(QStandardItemModel): def __init__(self, parent: QObject = None): super().__init__(parent=parent) self.lock = QMutex() - self.existing_threads = [] + self.existing_threads = {} + self.existing_processes = {} self.existing_jobs = {} self._next_number = 0 @@ -161,33 +193,42 @@ def next_number(self): self._next_number += 1 return retval - @Slot(object) - def addItem(self, new_entry: QProcess): - traj = JobEntry(new_entry.basename, trajectory=new_entry) - self.appendRow([traj]) - @Slot(list) - def startThread(self, job_vars: list): + def startProcess(self, job_vars: list): + main_pipe, child_pipe = Pipe() + main_queue = Queue() + pause_event = Event() try: - th_ref = JobThread(command=job_vars[0], parameters=job_vars[1]) + subprocess_ref = Subprocess( + job_name=job_vars[0], + job_parameters=job_vars[1], + pipe=child_pipe, + queue=main_queue, + pause_event=pause_event, + ) except: - ic(f"Failed to create JobThread using {job_vars}") + ic(f"Failed to create Subprocess using {job_vars}") return - th_ref.job_failure.connect(self.reportError) + communicator = JobCommunicator() + watcher_thread = JobThread(communicator, main_pipe, subprocess_ref) + communicator.moveToThread(watcher_thread) entry_number = self.next_number - item_th = JobEntry(command=job_vars[0], entry_number=entry_number) + item_th = JobEntry( + command=job_vars[0], entry_number=entry_number, pause_event=pause_event + ) item_th.parameters = job_vars[1] - th_ref._status._communicator.target.connect(item_th.on_started) # int - th_ref._status._communicator.progress.connect(item_th.on_update) # int - th_ref._status._communicator.finished.connect(item_th.on_finished) # bool - th_ref._status._communicator.oscillate.connect(item_th.on_oscillate) # nothing - ic("Thread ready to start!") + communicator.target.connect(item_th.on_started) # int + communicator.progress.connect(item_th.on_update) # int + communicator.finished.connect(item_th.on_finished) # bool + communicator.oscillate.connect(item_th.on_oscillate) # nothing + ic("Watcher thread ready to start!") + watcher_thread.start() try: task_name = str(job_vars[0]) except: task_name = str("This should have been a job name") name_item = QStandardItem(task_name) - name_item.setData(entry_number) + name_item.setData(entry_number, role=Qt.ItemDataRole.UserRole) self.appendRow( [ name_item, @@ -198,6 +239,8 @@ def startThread(self, job_vars: list): # nrows = self.rowCount() # index = self.indexFromItem(item_th._item) # print(f"Index: {index}") - th_ref.start() - self.existing_threads.append(th_ref) + self.existing_processes[entry_number] = subprocess_ref + self.existing_threads[entry_number] = watcher_thread self.existing_jobs[entry_number] = item_th + ic("Subprocess ready to start!") + subprocess_ref.start() diff --git a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Views/RunTable.py b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Views/RunTable.py index 152efe16b6..f351109596 100644 --- a/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Views/RunTable.py +++ b/MDANSE_GUI/Src/MDANSE_GUI/Tabs/Views/RunTable.py @@ -12,8 +12,10 @@ # @authors Scientific Computing Group at ILL (see AUTHORS) # # ************************************************************************** -from qtpy.QtCore import Slot, Signal, QModelIndex -from qtpy.QtWidgets import QMenu, QTableView, QAbstractItemView + +from PyQt6.QtCore import QAbstractItemModel, QObject +from qtpy.QtCore import Slot, Signal, QModelIndex, Qt +from qtpy.QtWidgets import QMenu, QTableView, QAbstractItemView, QMessageBox from qtpy.QtGui import QStandardItem, QContextMenuEvent from MDANSE_GUI.Tabs.Visualisers.TextInfo import TextInfo @@ -27,6 +29,13 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.clicked.connect(self.item_picked) + vh = self.verticalHeader() + vh.setVisible(False) + + def setModel(self, model: QAbstractItemModel) -> None: + result = super().setModel(model) + self.model().dataChanged.connect(self.resizeColumnsToContents) + return result def contextMenuEvent(self, event: QContextMenuEvent) -> None: index = self.indexAt(event.pos()) @@ -40,24 +49,91 @@ def contextMenuEvent(self, event: QContextMenuEvent) -> None: menu.exec_(event.globalPos()) def populateMenu(self, menu: QMenu, item: QStandardItem): - for action, method in [("Delete", self.deleteNode)]: + entry, _, _ = self.getJobObjects() + job_state = entry._current_state + for action, method in [ + ("Delete", self.deleteNode), + ("Pause", self.pauseJob), + ("Resume", self.unpauseJob), + ("Terminate", self.terminateJob), + # ("Kill", self.killJob), + ]: temp_action = menu.addAction(action) temp_action.triggered.connect(method) + if action not in job_state._allowed_actions: + temp_action.setEnabled(False) - @Slot() - def deleteNode(self): + def getJobObjects(self): model = self.model() index = self.currentIndex() - model.removeRow(index.row()) - if model.rowCount() == 0: - for i in reversed(range(model.columnCount())): - model.removeColumn(i) - self.item_details.emit("") + item_row = index.row() + entry_number = model.index(item_row, 0).data(role=Qt.ItemDataRole.UserRole) + try: + entry_number = int(entry_number) + except ValueError: + print(f"Could not use {entry_number} as int") + return + job_entry, job_watcher_thread, job_process = ( + model.existing_jobs[entry_number], + model.existing_threads[entry_number], + model.existing_processes[entry_number], + ) + return job_entry, job_watcher_thread, job_process + + @Slot() + def deleteNode(self): + entry, watcher, process = self.getJobObjects() + try: + process.close() + except ValueError: + print("The process is still running!") + else: + model = self.model() + index = self.currentIndex() + model.removeRow(index.row()) + if model.rowCount() == 0: + for i in reversed(range(model.columnCount())): + model.removeColumn(i) + self.item_details.emit("") + + @Slot() + def pauseJob(self): + entry, _, _ = self.getJobObjects() + entry.pause_job() + + @Slot() + def unpauseJob(self): + entry, _, _ = self.getJobObjects() + entry.unpause_job() + + @Slot() + def killJob(self): + entry, _, process = self.getJobObjects() + process.kill() + entry.kill_job() + + @Slot() + def terminateJob(self): + confirmation_box = QMessageBox( + QMessageBox.Icon.Question, + "You are about to terminate a job", + "All progress will be lost if you terminate your job. Do you really want to teminate?", + buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + parent=self, + ) + result = confirmation_box.exec() + print(f"QMessageBox result = {result}") + if result == QMessageBox.StandardButton.Yes.value: + entry, _, process = self.getJobObjects() + process.terminate() + entry.terminate_job() @Slot(QModelIndex) def item_picked(self, index: QModelIndex): model = self.model() - node_number = model.itemFromIndex(index).data() + index = self.currentIndex() + item_row = index.row() + node_number = model.index(item_row, 0).data(role=Qt.ItemDataRole.UserRole) job_entry = model.existing_jobs[node_number] self.item_details.emit(job_entry.text_summary()) diff --git a/MDANSE_GUI/Tests/UnitTests/PyQtGUI/test_JobState.py b/MDANSE_GUI/Tests/UnitTests/PyQtGUI/test_JobState.py new file mode 100644 index 0000000000..5dd8aca8d5 --- /dev/null +++ b/MDANSE_GUI/Tests/UnitTests/PyQtGUI/test_JobState.py @@ -0,0 +1,17 @@ +import pytest +import tempfile + +from MDANSE_GUI.Tabs.Models.JobHolder import JobEntry + + +@pytest.fixture(scope="module") +def temporary_jobentry() -> JobEntry: + return JobEntry() + +def test_start(temporary_jobentry: JobEntry): + temporary_jobentry._current_state.start() + assert temporary_jobentry._current_state._label == "Running" + +def test_fail(temporary_jobentry: JobEntry): + temporary_jobentry._current_state.fail() + assert temporary_jobentry._current_state._label == "Failed" \ No newline at end of file