%PDF- %PDF-
| Direktori : /lib/calibre/calibre/gui2/ |
| Current File : //lib/calibre/calibre/gui2/jobs.py |
#!/usr/bin/env python3
__license__ = 'GPL v3'
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
__docformat__ = 'restructuredtext en'
'''
Job management.
'''
import time
from qt.core import (QAbstractTableModel, QModelIndex, Qt, QStylePainter,
QTimer, pyqtSignal, QIcon, QDialog, QAbstractItemDelegate, QApplication, QEvent,
QSize, QStyleOptionProgressBar, QStyle, QToolTip, QWidget, QStyleOption,
QHBoxLayout, QVBoxLayout, QSizePolicy, QLabel, QCoreApplication, QAction, QItemSelectionModel,
QByteArray, QSortFilterProxyModel, QTextBrowser, QPlainTextEdit, QDialogButtonBox)
from calibre import strftime
from calibre.constants import islinux, isbsd
from calibre.utils.ipc.server import Server
from calibre.utils.ipc.job import ParallelJob
from calibre.gui2 import (Dispatcher, error_dialog, question_dialog,
config, gprefs)
from calibre.gui2.device import DeviceJob
from calibre.gui2.dialogs.jobs_ui import Ui_JobsDialog
from calibre import __appname__, as_unicode
from calibre.gui2.progress_indicator import ProgressIndicator
from calibre.gui2.threaded_jobs import ThreadedJobServer, ThreadedJob
from calibre.gui2.widgets2 import Dialog
from calibre.utils.search_query_parser import SearchQueryParser, ParseException
from calibre.utils.icu import lower
from polyglot.queue import Empty, Queue
class AdaptSQP(SearchQueryParser):
def __init__(self, *args, **kwargs):
pass
def human_readable_interval(secs):
secs = int(secs)
days = secs // 86400
hours = secs // 3600 % 24
minutes = secs // 60 % 60
seconds = secs % 60
parts = []
if days > 0:
parts.append('%dd' % days)
if hours > 0:
parts.append('%dh' % hours)
if minutes > 0:
parts.append('%dm' % minutes)
if secs > 0:
parts.append('%ds' % seconds)
return ' '.join(parts)
class JobManager(QAbstractTableModel, AdaptSQP): # {{{
job_added = pyqtSignal(int)
job_done = pyqtSignal(int)
def __init__(self):
QAbstractTableModel.__init__(self)
SearchQueryParser.__init__(self, ['all'])
self.wait_icon = (QIcon(I('jobs.png')))
self.running_icon = (QIcon(I('exec.png')))
self.error_icon = (QIcon(I('dialog_error.png')))
self.done_icon = (QIcon(I('ok.png')))
self.jobs = []
self.add_job = Dispatcher(self._add_job)
self.server = Server(limit=config['worker_limit']//2,
enforce_cpu_limit=config['enforce_cpu_limit'])
self.threaded_server = ThreadedJobServer()
self.changed_queue = Queue()
self.timer = QTimer(self)
self.timer.timeout.connect(self.update, type=Qt.ConnectionType.QueuedConnection)
self.timer.start(1000)
def columnCount(self, parent=QModelIndex()):
return 5
def rowCount(self, parent=QModelIndex()):
return len(self.jobs)
def headerData(self, section, orientation, role):
if role != Qt.ItemDataRole.DisplayRole:
return None
if orientation == Qt.Orientation.Horizontal:
return ({
0: _('Job'),
1: _('Status'),
2: _('Progress'),
3: _('Running time'),
4: _('Start time'),
}.get(section, ''))
else:
return (section+1)
def show_tooltip(self, arg):
widget, pos = arg
QToolTip.showText(pos, self.get_tooltip())
def get_tooltip(self):
running_jobs = [j for j in self.jobs if j.run_state == j.RUNNING]
waiting_jobs = [j for j in self.jobs if j.run_state == j.WAITING]
lines = [ngettext('There is a running job:', 'There are {} running jobs:', len(running_jobs)).format(len(running_jobs))]
for job in running_jobs:
desc = job.description
if not desc:
desc = _('Unknown job')
p = 100. if job.is_finished else job.percent
lines.append('%s: %.0f%% done'%(desc, p))
l = ngettext('There is a waiting job', 'There are {} waiting jobs', len(waiting_jobs)).format(len(waiting_jobs))
lines.extend(['', l])
for job in waiting_jobs:
desc = job.description
if not desc:
desc = _('Unknown job')
lines.append(desc)
return '\n'.join(['calibre', '']+ lines)
def data(self, index, role):
try:
if role not in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.DecorationRole):
return None
row, col = index.row(), index.column()
job = self.jobs[row]
if role == Qt.ItemDataRole.DisplayRole:
if col == 0:
desc = job.description
if not desc:
desc = _('Unknown job')
return (desc)
if col == 1:
return (job.status_text)
if col == 2:
p = 100. if job.is_finished else job.percent
return (p)
if col == 3:
rtime = job.running_time
if rtime is None:
return None
return human_readable_interval(rtime)
if col == 4 and job.start_time is not None:
return (strftime('%H:%M -- %d %b', time.localtime(job.start_time)))
if role == Qt.ItemDataRole.DecorationRole and col == 0:
state = job.run_state
if state == job.WAITING:
return self.wait_icon
if state == job.RUNNING:
return self.running_icon
if job.killed or job.failed:
return self.error_icon
return self.done_icon
except:
import traceback
traceback.print_exc()
return None
def update(self):
try:
self._update()
except BaseException:
import traceback
traceback.print_exc()
def _update(self):
# Update running time
for i, j in enumerate(self.jobs):
if j.run_state == j.RUNNING:
idx = self.index(i, 3)
self.dataChanged.emit(idx, idx)
# Update parallel jobs
jobs = set()
while True:
try:
jobs.add(self.server.changed_jobs_queue.get_nowait())
except Empty:
break
# Update device jobs
while True:
try:
jobs.add(self.changed_queue.get_nowait())
except Empty:
break
# Update threaded jobs
while True:
try:
jobs.add(self.threaded_server.changed_jobs.get_nowait())
except Empty:
break
if jobs:
needs_reset = False
for job in jobs:
orig_state = job.run_state
job.update()
if orig_state != job.run_state:
needs_reset = True
if job.is_finished:
self.job_done.emit(len(self.unfinished_jobs()))
if needs_reset:
self.modelAboutToBeReset.emit()
self.jobs.sort()
self.modelReset.emit()
else:
for job in jobs:
idx = self.jobs.index(job)
self.dataChanged.emit(
self.index(idx, 0), self.index(idx, 3))
# Kill parallel jobs that have gone on too long
try:
wmax_time = gprefs['worker_max_time'] * 60
except:
wmax_time = 0
if wmax_time > 0:
for job in self.jobs:
if isinstance(job, ParallelJob):
rtime = job.running_time
if (rtime is not None and rtime > wmax_time and
job.duration is None):
job.timed_out = True
self.server.kill_job(job)
def _add_job(self, job):
self.modelAboutToBeReset.emit()
self.jobs.append(job)
self.jobs.sort()
self.job_added.emit(len(self.unfinished_jobs()))
self.modelReset.emit()
def done_jobs(self):
return [j for j in self.jobs if j.is_finished]
def unfinished_jobs(self):
return [j for j in self.jobs if not j.is_finished]
def row_to_job(self, row):
return self.jobs[row]
def rows_to_jobs(self, rows):
return [self.jobs[row] for row in rows]
def has_device_jobs(self, queued_also=False):
for job in self.jobs:
if isinstance(job, DeviceJob):
if job.duration is None: # Running or waiting
if (job.is_running or queued_also):
return True
return False
def has_jobs(self):
for job in self.jobs:
if job.is_running:
return True
return False
def run_job(self, done, name, args=[], kwargs={},
description='', core_usage=1):
job = ParallelJob(name, description, done, args=args, kwargs=kwargs)
job.core_usage = core_usage
self.add_job(job)
self.server.add_job(job)
return job
def run_threaded_job(self, job):
self.add_job(job)
self.threaded_server.add_job(job)
def launch_gui_app(self, name, args=(), kwargs=None, description=''):
job = ParallelJob(name, description, lambda x: x,
args=list(args), kwargs=kwargs or {})
self.server.run_job(job, gui=True, redirect_output=False)
def _kill_job(self, job):
if isinstance(job, ParallelJob):
self.server.kill_job(job)
elif isinstance(job, ThreadedJob):
self.threaded_server.kill_job(job)
else:
job.kill_on_start = True
def hide_jobs(self, rows):
for r in rows:
self.jobs[r].hidden_in_gui = True
for r in rows:
self.dataChanged.emit(self.index(r, 0), self.index(r, 0))
def show_hidden_jobs(self):
for j in self.jobs:
j.hidden_in_gui = False
for r in range(len(self.jobs)):
self.dataChanged.emit(self.index(r, 0), self.index(r, 0))
def kill_job(self, job, view):
if isinstance(job, DeviceJob):
return error_dialog(view, _('Cannot kill job'),
_('Cannot kill jobs that communicate with the device')).exec()
if job.duration is not None:
return error_dialog(view, _('Cannot kill job'),
_('Job has already run')).exec()
if not getattr(job, 'killable', True):
return error_dialog(view, _('Cannot kill job'),
_('This job cannot be stopped'), show=True)
self._kill_job(job)
def kill_multiple_jobs(self, jobs, view):
devjobs = [j for j in jobs if isinstance(j, DeviceJob)]
if devjobs:
error_dialog(view, _('Cannot kill job'),
_('Cannot kill jobs that communicate with the device')).exec()
jobs = [j for j in jobs if not isinstance(j, DeviceJob)]
jobs = [j for j in jobs if j.duration is None]
unkillable = [j for j in jobs if not getattr(j, 'killable', True)]
if unkillable:
names = '\n'.join(as_unicode(j.description) for j in unkillable)
error_dialog(view, _('Cannot kill job'),
_('Some of the jobs cannot be stopped. Click "Show details"'
' to see the list of unstoppable jobs.'), det_msg=names,
show=True)
jobs = [j for j in jobs if getattr(j, 'killable', True)]
jobs = [j for j in jobs if j.duration is None]
for j in jobs:
self._kill_job(j)
def kill_all_jobs(self):
for job in self.jobs:
if (isinstance(job, DeviceJob) or job.duration is not None or
not getattr(job, 'killable', True)):
continue
self._kill_job(job)
def terminate_all_jobs(self):
self.server.killall()
for job in self.jobs:
if (isinstance(job, DeviceJob) or job.duration is not None or
not getattr(job, 'killable', True)):
continue
if not isinstance(job, ParallelJob):
self._kill_job(job)
def universal_set(self):
return {i for i, j in enumerate(self.jobs) if not getattr(j,
'hidden_in_gui', False)}
def get_matches(self, location, query, candidates=None):
if candidates is None:
candidates = self.universal_set()
ans = set()
if not query:
return ans
query = lower(query)
for j in candidates:
job = self.jobs[j]
if job.description and query in lower(job.description):
ans.add(j)
return ans
def find(self, query):
query = query.strip()
rows = self.parse(query)
return rows
# }}}
class FilterModel(QSortFilterProxyModel): # {{{
search_done = pyqtSignal(object)
def __init__(self, parent):
QSortFilterProxyModel.__init__(self, parent)
self.search_filter = None
def filterAcceptsRow(self, source_row, source_parent):
if (self.search_filter is not None and source_row not in
self.search_filter):
return False
m = self.sourceModel()
try:
job = m.row_to_job(source_row)
except:
return False
return not getattr(job, 'hidden_in_gui', False)
def find(self, query):
ok = True
val = None
if query:
try:
val = self.sourceModel().parse(query)
except ParseException:
ok = False
self.search_filter = val
self.beginResetModel()
self.search_done.emit(ok)
self.endResetModel()
# }}}
# Jobs UI {{{
class ProgressBarDelegate(QAbstractItemDelegate): # {{{
def sizeHint(self, option, index):
return QSize(120, 30)
def paint(self, painter, option, index):
opts = QStyleOptionProgressBar()
opts.rect = option.rect
opts.minimum = 1
opts.maximum = 100
opts.textVisible = True
try:
percent = int(index.model().data(index, Qt.ItemDataRole.DisplayRole))
except (TypeError, ValueError):
percent = 0
opts.progress = percent
opts.text = (_('Unavailable') if percent == 0 else '%d%%'%percent)
QApplication.style().drawControl(QStyle.ControlElement.CE_ProgressBar, opts, painter)
# }}}
class DetailView(Dialog): # {{{
def __init__(self, parent, job):
self.job = job
self.html_view = hasattr(job, 'html_details') and not getattr(job, 'ignore_html_details', False)
Dialog.__init__(self, job.description, 'job-detail-view-dialog', parent)
def sizeHint(self):
return QSize(700, 500)
@property
def plain_text(self):
if self.html_view:
return self.tb.toPlainText()
return self.log.toPlainText()
def copy_to_clipboard(self):
QApplication.instance().clipboard().setText(self.plain_text)
def setup_ui(self):
self.l = l = QVBoxLayout(self)
if self.html_view:
self.tb = w = QTextBrowser(self)
else:
self.log = w = QPlainTextEdit(self)
w.setReadOnly(True), w.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
l.addWidget(w)
l.addWidget(self.bb)
self.bb.clear(), self.bb.setStandardButtons(QDialogButtonBox.StandardButton.Close)
self.copy_button = b = self.bb.addButton(_('&Copy to clipboard'), QDialogButtonBox.ButtonRole.ActionRole)
b.setIcon(QIcon(I('edit-copy.png')))
b.clicked.connect(self.copy_to_clipboard)
self.next_pos = 0
self.update()
self.timer = QTimer(self)
self.timer.timeout.connect(self.update)
self.timer.start(1000)
if not self.html_view:
v = self.log.verticalScrollBar()
v.setValue(v.maximum())
def update(self):
if self.html_view:
html = self.job.html_details
if len(html) > self.next_pos:
self.next_pos = len(html)
self.tb.setHtml(
'<pre style="font-family:monospace">%s</pre>'%html)
else:
f = self.job.log_file
f.seek(self.next_pos)
more = f.read()
self.next_pos = f.tell()
if more:
self.log.appendPlainText(more.decode('utf-8', 'replace'))
# }}}
class JobsButton(QWidget): # {{{
tray_tooltip_updated = pyqtSignal(object)
def __init__(self, parent=None):
QWidget.__init__(self, parent)
self.num_jobs = 0
self.mouse_over = False
self.pi = ProgressIndicator(self, self.style().pixelMetric(QStyle.PixelMetric.PM_ToolBarIconSize))
self.pi.setVisible(False)
self._jobs = QLabel('')
self._jobs.mouseReleaseEvent = self.mouseReleaseEvent
self.update_label()
self.shortcut = 'Alt+Shift+J'
self.l = l = QHBoxLayout(self)
l.setSpacing(3)
l.addWidget(self.pi)
l.addWidget(self._jobs)
m = self.style().pixelMetric(QStyle.PixelMetric.PM_DefaultFrameWidth)
self.layout().setContentsMargins(m, m, m, m)
self._jobs.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
self.setCursor(Qt.CursorShape.PointingHandCursor)
b = _('Click to see list of jobs')
self.setToolTip(b + _(' [Alt+Shift+J]'))
self.action_toggle = QAction(b, parent)
parent.addAction(self.action_toggle)
self.action_toggle.triggered.connect(self.toggle)
if hasattr(parent, 'keyboard'):
parent.keyboard.register_shortcut('toggle jobs list', _('Show/hide the Jobs List'), default_keys=(self.shortcut,), action=self.action_toggle)
def update_label(self):
n = self.jobs()
prefix = '<b>' if n > 0 else ''
self._jobs.setText(prefix + _('Jobs:') + f' {n} ')
def event(self, ev):
m = None
et = ev.type()
if et == QEvent.Type.Enter:
m = True
elif et == QEvent.Type.Leave:
m = False
if m is not None and m != self.mouse_over:
self.mouse_over = m
self.update()
return QWidget.event(self, ev)
def initialize(self, jobs_dialog, job_manager):
self.jobs_dialog = jobs_dialog
job_manager.job_added.connect(self.job_added)
job_manager.job_done.connect(self.job_done)
self.jobs_dialog.addAction(self.action_toggle)
def mouseReleaseEvent(self, event):
self.toggle()
def toggle(self, *args):
if self.jobs_dialog.isVisible():
self.jobs_dialog.hide()
else:
self.jobs_dialog.show()
@property
def is_running(self):
return self.pi.isAnimated()
def start(self):
self.pi.startAnimation()
self.pi.setVisible(True)
def stop(self):
self.pi.stopAnimation()
self.pi.setVisible(False)
def jobs(self):
return self.num_jobs
def tray_tooltip(self, num=0):
if num == 0:
text = _('No running jobs')
elif num == 1:
text = _('One running job')
else:
text = _('%d running jobs') % num
if not (islinux or isbsd):
text = 'calibre: ' + text
return text
def job_added(self, nnum):
self.num_jobs = nnum
self.update_label()
self.start()
self.tray_tooltip_updated.emit(self.tray_tooltip(nnum))
def job_done(self, nnum):
self.num_jobs = nnum
self.update_label()
if nnum == 0:
self.no_more_jobs()
self.tray_tooltip_updated.emit(self.tray_tooltip(nnum))
def no_more_jobs(self):
if self.is_running:
self.stop()
QCoreApplication.instance().alert(self, 5000)
def paintEvent(self, ev):
if self.mouse_over:
p = QStylePainter(self)
tool = QStyleOption()
tool.initFrom(self)
tool.rect = self.rect()
tool.state = QStyle.StateFlag.State_Raised | QStyle.StateFlag.State_Active | QStyle.StateFlag.State_MouseOver
p.drawPrimitive(QStyle.PrimitiveElement.PE_PanelButtonTool, tool)
p.end()
QWidget.paintEvent(self, ev)
# }}}
class JobsDialog(QDialog, Ui_JobsDialog):
def __init__(self, window, model):
QDialog.__init__(self, window)
Ui_JobsDialog.__init__(self)
self.setupUi(self)
self.model = model
self.proxy_model = FilterModel(self)
self.proxy_model.setSourceModel(self.model)
self.proxy_model.search_done.connect(self.search.search_done)
self.jobs_view.setModel(self.proxy_model)
self.setWindowModality(Qt.WindowModality.NonModal)
self.setWindowTitle(__appname__ + _(' - Jobs'))
self.details_button.clicked.connect(self.show_details)
self.kill_button.clicked.connect(self.kill_job)
self.stop_all_jobs_button.clicked.connect(self.kill_all_jobs)
self.pb_delegate = ProgressBarDelegate(self)
self.jobs_view.setItemDelegateForColumn(2, self.pb_delegate)
self.jobs_view.doubleClicked.connect(self.show_job_details)
self.jobs_view.horizontalHeader().setSectionsMovable(True)
self.hide_button.clicked.connect(self.hide_selected)
self.hide_all_button.clicked.connect(self.hide_all)
self.show_button.clicked.connect(self.show_hidden)
self.search.initialize('jobs_search_history',
help_text=_('Search for a job by name'))
self.search.search.connect(self.find)
connect_lambda(self.search_button.clicked, self, lambda self: self.find(self.search.current_text))
self.restore_state()
def restore_state(self):
try:
geom = gprefs.get('jobs_dialog_geometry', None)
if geom:
QApplication.instance().safe_restore_geometry(self, QByteArray(geom))
state = gprefs.get('jobs view column layout3', None)
if state is not None:
self.jobs_view.horizontalHeader().restoreState(QByteArray(state))
except:
pass
idx = self.jobs_view.model().index(0, 0)
if idx.isValid():
sm = self.jobs_view.selectionModel()
sm.select(idx, QItemSelectionModel.SelectionFlag.ClearAndSelect|QItemSelectionModel.SelectionFlag.Rows)
def save_state(self):
try:
state = bytearray(self.jobs_view.horizontalHeader().saveState())
gprefs['jobs view column layout3'] = state
geom = bytearray(self.saveGeometry())
gprefs['jobs_dialog_geometry'] = geom
except:
pass
def show_job_details(self, index):
index = self.proxy_model.mapToSource(index)
if index.isValid():
row = index.row()
job = self.model.row_to_job(row)
d = DetailView(self, job)
d.exec()
d.timer.stop()
def show_details(self, *args):
index = self.jobs_view.currentIndex()
if index.isValid():
self.show_job_details(index)
def kill_job(self, *args):
indices = [self.proxy_model.mapToSource(index) for index in
self.jobs_view.selectionModel().selectedRows()]
indices = [i for i in indices if i.isValid()]
jobs = self.model.rows_to_jobs([index.row() for index in indices])
if not jobs:
return error_dialog(self, _('No job'),
_('No job selected'), show=True)
if question_dialog(self, _('Are you sure?'),
ngettext('Do you really want to stop the selected job?',
'Do you really want to stop all the selected jobs?',
len(jobs))):
if len(jobs) > 1:
self.model.kill_multiple_jobs(jobs, self)
else:
self.model.kill_job(jobs[0], self)
def kill_all_jobs(self, *args):
if question_dialog(self, _('Are you sure?'),
_('Do you really want to stop all non-device jobs?')):
self.model.kill_all_jobs()
def hide_selected(self, *args):
indices = [self.proxy_model.mapToSource(index) for index in
self.jobs_view.selectionModel().selectedRows()]
indices = [i for i in indices if i.isValid()]
rows = [index.row() for index in indices]
if not rows:
return error_dialog(self, _('No job'),
_('No job selected'), show=True)
self.model.hide_jobs(rows)
self.proxy_model.beginResetModel(), self.proxy_model.endResetModel()
def hide_all(self, *args):
self.model.hide_jobs(list(range(0,
self.model.rowCount(QModelIndex()))))
self.proxy_model.beginResetModel(), self.proxy_model.endResetModel()
def show_hidden(self, *args):
self.model.show_hidden_jobs()
self.find(self.search.current_text)
def closeEvent(self, e):
self.save_state()
return QDialog.closeEvent(self, e)
def show(self, *args):
self.restore_state()
return QDialog.show(self, *args)
def hide(self, *args):
self.save_state()
return QDialog.hide(self, *args)
def reject(self):
self.save_state()
QDialog.reject(self)
def find(self, query):
self.proxy_model.find(query)
# }}}