#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""SamSifter helps you create filter workflows for NGS data.
It is primarily used to process SAM files generated by MALT prior to
metagenomic analysis in MEGAN.
.. moduleauthor:: Florian Aldehoff <samsifter@biohazardous.de>
"""
import signal
import sys
if not sys.version_info[0] >= 3:
print("Error, I need python 3.x or newer")
exit(1)
import platform
import argparse
import logging as log
from functools import partial
from os.path import basename, dirname, expanduser
# Qt4
from PyQt4.QtGui import (
QMainWindow, QHBoxLayout, QTreeView, QFrame, QIcon, QAction, QStandardItem,
QDockWidget, QApplication, QLabel, QFileDialog, QVBoxLayout,
QAbstractItemView, QMessageBox, QTextEdit, QColor, QKeySequence,
QDesktopServices
)
from PyQt4.QtCore import (
Qt, QSize, QProcess, QFileInfo, QPoint, QSettings, QTimer, QFile,
PYQT_VERSION_STR, QT_VERSION_STR, QProcessEnvironment, QUrl
)
# custom libraries
from samsifter.gui.widgets import InputWidget, OutputWidget
from samsifter.gui.dialogs import (
BashOptionsDialog, RmaOptionsDialog, RmaOptions
)
# Add your new tools/filter to this import list!
from samsifter.tools import (
filter_read_list, filter_read_pmds, filter_read_identity,
filter_read_conservation, filter_ref_list, filter_ref_identity,
filter_ref_pmds, filter_ref_coverage, filter_taxon_list, filter_taxon_pmds,
calculate_pmds, sort_by_coordinates, sort_by_names, remove_duplicates,
sam_2_bam, bam_2_sam, compress, decompress, count_taxon_reads,
better_remove_duplicates
)
from samsifter.models.filter_model import FilterTreeModel
from samsifter.views.filterviews import FilterListView
from samsifter.models.workflow import Workflow
from samsifter.util.validation import WorkflowValidator
from samsifter.models.filter import FilterItem
from samsifter.resources import resources
assert resources # silence pyflakes
from samsifter.version import VERSION
__version__ = VERSION
""" globals """
FILE_FORMATS = ["*.ssx", "*.SSX"]
TITLE = "SamSifter"
DESC = ("SamSifter helps you create filter workflows for next-generation "
"sequencing data. It is primarily used to process SAM files generated "
"by MALT prior to metagenomic analysis in MEGAN.")
COPYRIGHT = ("This program is free software: you can redistribute it "
"and/or modify it under the terms of the GNU General Public "
"License as published by the Free Software Foundation, either "
"version 3 of the License, or (at your option) any later version."
"<p>This program is distributed in the hope that it will be "
"useful, but WITHOUT ANY WARRANTY; without even the implied "
"warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR "
"PURPOSE. See the GNU General Public License for more details."
"<p>You should have received a copy of the GNU General Public "
"License along with this program. If not, see "
'<a href="http://www.gnu.org/licenses/">'
"http://www.gnu.org/licenses/</a>.")
GREETING = ("<h3>Welcome to %s version %s!</h3><p>%s<p>To start working add "
"filters to the workflow above. You can add filters by "
"doubleclicking available entries in the righthand dock or by "
"selecting an entry from the menu <b>[Edit > Add Filter...]</b>. "
"Similarly, you can doubleclick filters in the workflow to edit "
"their settings and parameters. The lefthand toolbar is used to "
"move and delete filter steps."
"<p>You can save your workflow to a file, execute it right now "
"by clicking the <b>[Run]</b> button and even export it to a Bash "
"script to run it on another workstation. Any errors and "
"messages will be reported to you right here. Feel free to "
"re-arrange these docks to make yourself comfortable - %s will "
"remember your settings. Accidentally closed docks can always "
"be re-opened from the menu <b>[View]<b>."
% (TITLE, __version__, DESC, TITLE))
RIGHT_DOCK_MIN_HEIGHT = 220
RIGHT_DOCK_MIN_WIDTH = 300
BOTTOM_DOCK_MIN_HEIGHT = 200
RECENT_FILES_MAX_LENGTH = 9
STATUS_DELAY = 5000
[docs]class MainWindow(QMainWindow):
"""Main window of SamSifter GUI interface.
Provides a central list view of filter items and freely arrangeable docks
for filter settings, toolbox and log messages.
"""
def __init__(self, verbose=False, debug=False):
"""Initialize main window.
Parameters
----------
verbose : bool, optional
Enable printing of additional information to STDERR, defaults to
False.
debug : bool, optional
Enable debug menu with additional options to analyze errors,
defaults to False.
"""
super(MainWindow, self).__init__()
self.verbose = verbose
self.debug = debug
# data models
self.recent_files = []
self.filters = {}
self.populate_filters()
self.tree_model = FilterTreeModel(self)
root = self.tree_model.invisibleRootItem()
for category in self.filters:
branch = QStandardItem(category)
branch.setSelectable(False)
branch.setEditable(False)
root.appendRow(branch)
for fltr in self.filters[category]:
leaf = fltr
branch.appendRow(leaf)
# increase GUI haptics by making sure elements are always in same spot
self.tree_model.sort(0)
self.wflow = Workflow(self)
self.wflow.validity_changed.connect(self.on_validity_change)
self.wflow.changed.connect(self.update_status)
# QProcess object for external app
self.process = None
self.pid = -1
# viewers and controllers
self.init_ui()
# previous settings
settings = QSettings()
if settings.value("RecentFiles") is not None:
self.recent_files = settings.value("RecentFiles")
self.resize(settings.value("MainWindow/Size", QSize(800, 800)))
self.move(settings.value("MainWindow/Position", QPoint(0, 0)))
if settings.value("MainWindow/State") is not None:
self.restoreState(settings.value("MainWindow/State"))
self.rma_options = RmaOptions()
if settings.value("SAM2RMA/TopPercent") is not None:
self.rma_options.set_top_percent(
float(settings.value("SAM2RMA/TopPercent"))
)
if settings.value("SAM2RMA/MaxExpected") is not None:
self.rma_options.set_max_expected(
float(settings.value("SAM2RMA/MaxExpected"))
)
if settings.value("SAM2RMA/MinScore") is not None:
self.rma_options.set_min_score(
float(settings.value("SAM2RMA/MinScore"))
)
if settings.value("SAM2RMA/MinSupportPercent") is not None:
self.rma_options.set_min_support_percent(
float(settings.value("SAM2RMA/MinSupportPercent"))
)
if settings.value("SAM2RMA/Sam2RmaPath") is not None:
self.rma_options.set_sam2rma_path(
settings.value("SAM2RMA/Sam2RmaPath")
)
self.populate_file_menu()
QTimer.singleShot(0, self.load_initial_file)
self.show()
[docs] def populate_filters(self):
"""Compiles available filters to group them into named categories.
Note
----
To add a new filter or tool to the menu of available items simply
choose a category and append your
:py:class:`samsifter.models.filter.FilterItem`
to the corresponding list. This is usually done by calling the
``item()`` method of your module in package :py:mod:`samsifter.tools`.
Entries will be sorted by category name and item name prior to display
in the GUI so the order within the list does not matter.
In order to call the ``item()`` method of your new module you will
also have to import it in the header of this module.
"""
# analyzers
analyzers = []
analyzers.append(calculate_pmds.item())
analyzers.append(count_taxon_reads.item())
self.filters["Analyzers"] = analyzers
# converters
converters = []
converters.append(sam_2_bam.item())
converters.append(bam_2_sam.item())
converters.append(compress.item())
converters.append(decompress.item())
self.filters["Format Converters"] = converters
# sorters
sorters = []
sorters.append(sort_by_coordinates.item())
sorters.append(sort_by_names.item())
self.filters["File Sorters"] = sorters
# read filters
read_filters = []
read_filters.append(filter_read_list.item())
read_filters.append(filter_read_pmds.item())
read_filters.append(filter_read_identity.item())
read_filters.append(filter_read_conservation.item())
read_filters.append(remove_duplicates.item())
read_filters.append(better_remove_duplicates.item())
self.filters["Read Filters"] = read_filters
# reference filters
reference_filters = []
reference_filters.append(filter_ref_list.item())
reference_filters.append(filter_ref_identity.item())
reference_filters.append(filter_ref_pmds.item())
reference_filters.append(filter_ref_coverage.item())
self.filters["Reference Filters"] = reference_filters
# taxon filters
taxon_filters = []
taxon_filters.append(filter_taxon_list.item())
taxon_filters.append(filter_taxon_pmds.item())
self.filters["Taxon Filters"] = taxon_filters
[docs] def init_ui(self):
"""Initialize GUI components (widgets, actions, menus and toolbars)."""
self.setWindowTitle(TITLE)
self.setDockOptions(QMainWindow.AnimatedDocks
| QMainWindow.AllowNestedDocks)
# central workflow widget
self.input_widget = InputWidget(self)
self.input_widget.file_entry.setText(self.wflow.get_in_filename())
self.wflow.input_changed.connect(self.input_widget.set_filename)
self.input_widget.file_entry.textChanged.connect(
self.wflow.set_in_filename
)
self.output_widget = OutputWidget(self)
self.output_widget.file_entry.setText(self.wflow.get_out_filename())
self.wflow.output_changed.connect(self.output_widget.set_filename)
self.output_widget.file_entry.textChanged.connect(
self.wflow.set_out_filename
)
self.output_widget.compile_box.stateChanged.connect(
self.wflow.set_run_compile_stats
)
self.output_widget.sam2rma_box.stateChanged.connect(
self.wflow.set_run_sam2rma
)
self.output_widget.sam2rma_btn.clicked.connect(self.configure_sam2rma)
self.list_view = FilterListView(self)
self.list_view.setModel(self.wflow.get_model())
self.list_view.selectionModel().currentRowChanged.connect(
self.update_settings_view)
self.list_view.doubleClicked.connect(self.on_list_item_doubleclick)
list_layout = QVBoxLayout()
list_layout.setMargin(0)
list_layout.addWidget(self.input_widget)
list_layout.addWidget(self.list_view)
list_layout.addWidget(self.output_widget)
list_frame = QFrame(self)
list_frame.setObjectName('ListFrame')
list_frame.setFrameShape(QFrame.StyledPanel)
list_frame.setLayout(list_layout)
# available filters dock
tree_view = QTreeView(self)
tree_view.setDragEnabled(False)
tree_view.setHeaderHidden(True)
tree_view.setSelectionMode(QAbstractItemView.SingleSelection)
tree_view.setModel(self.tree_model)
tree_view.expandAll()
tree_view.doubleClicked.connect(self.on_tree_item_doubleclick)
tree_layout = QHBoxLayout()
tree_layout.setMargin(0)
tree_layout.addWidget(tree_view)
tree_frame = QFrame(self)
tree_frame.setFrameShape(QFrame.StyledPanel)
tree_frame.setLayout(tree_layout)
self.tree_dock = QDockWidget("Filters and Tools")
self.tree_dock.setObjectName('TreeDock')
self.tree_dock.setMinimumWidth(RIGHT_DOCK_MIN_WIDTH)
self.tree_dock.setFeatures(QDockWidget.DockWidgetMovable
| QDockWidget.DockWidgetFloatable
| QDockWidget.DockWidgetClosable)
self.tree_dock.setAllowedAreas(Qt.LeftDockWidgetArea
| Qt.RightDockWidgetArea)
self.tree_dock.setWidget(tree_frame)
self.tree_dock.setVisible(True)
# settings dock
self.placeholder = QLabel(self)
self.placeholder.setAlignment(Qt.AlignCenter)
self.placeholder.setText("filter settings shown here")
self.placeholder.setPixmap(
QIcon.fromTheme('view-filter').pixmap(QSize(128, 128),
QIcon.Disabled,
QIcon.Off))
self.current_editor = self.placeholder
self.fset_layout = QVBoxLayout()
self.fset_layout.setMargin(0)
self.fset_layout.addWidget(self.current_editor)
fset_frame = QFrame(self)
fset_frame.setLayout(self.fset_layout)
self.fset_dock = QDockWidget("Filter Settings")
self.fset_dock.setObjectName('FilterSettingsDock')
self.fset_dock.setMinimumWidth(RIGHT_DOCK_MIN_WIDTH)
self.fset_dock.setMinimumHeight(RIGHT_DOCK_MIN_HEIGHT)
self.fset_dock.setFeatures(
QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
| QDockWidget.DockWidgetClosable
)
self.fset_dock.setAllowedAreas(
Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea
| Qt.RightDockWidgetArea
)
self.fset_dock.setWidget(fset_frame)
self.fset_dock.setVisible(False)
# progress dock
self.output = QTextEdit(self)
self.output.setReadOnly(True)
self.output.setFontPointSize(8)
self.output.setFontFamily('Monospace')
progress_layout = QVBoxLayout()
progress_layout.setMargin(0)
progress_layout.addWidget(self.output)
progress_frame = QFrame(self)
progress_frame.setLayout(progress_layout)
self.progress_dock = QDockWidget("Messages")
self.progress_dock.setObjectName('ProgressDock')
self.progress_dock.setMinimumWidth(BOTTOM_DOCK_MIN_HEIGHT)
self.progress_dock.setFeatures(
QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
| QDockWidget.DockWidgetClosable
)
self.progress_dock.setAllowedAreas(
Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea
| Qt.RightDockWidgetArea
)
self.progress_dock.setWidget(progress_frame)
self.progress_dock.setVisible(False)
# main frame
self.addDockWidget(Qt.RightDockWidgetArea, self.tree_dock)
self.addDockWidget(Qt.RightDockWidgetArea, self.fset_dock)
self.addDockWidget(Qt.BottomDockWidgetArea, self.progress_dock)
self.setCentralWidget(list_frame)
# actions
new_action_help = "Create a new workflow"
new_action = QAction(QIcon.fromTheme('document-new'), "&New...", self)
new_action.setShortcut(QKeySequence.New)
new_action.setToolTip(new_action_help)
new_action.setStatusTip(new_action_help)
new_action.triggered.connect(self.new_file)
open_action_help = "Open workflow from file"
open_action = QAction(QIcon.fromTheme('document-open'), "&Open...",
self)
open_action.setShortcut(QKeySequence.Open)
open_action.setToolTip(open_action_help)
open_action.setStatusTip(open_action_help)
open_action.triggered.connect(self.open_file)
save_action_help = "Save workflow to file"
save_action = QAction(QIcon.fromTheme('document-save'), "&Save", self)
save_action.setShortcut(QKeySequence.Save)
save_action.setToolTip(save_action_help)
save_action.setStatusTip(save_action_help)
save_action.triggered.connect(self.save_file)
save_as_action = QAction(QIcon.fromTheme('document-save-as'),
'&Save as...', self)
save_as_action.setShortcut('Ctrl+Shift+S')
save_as_action.setStatusTip('Save workflow to new file')
save_as_action.triggered.connect(self.save_file_as)
quit_action = QAction(QIcon.fromTheme('application-exit'),
'&Quit', self)
quit_action.setShortcut(QKeySequence.Quit)
quit_action.setStatusTip('Exit application')
# quit_action.triggered.connect(qApp.quit)
quit_action.triggered.connect(self.close)
self.run_action = QAction(QIcon.fromTheme('system-run'), '&Run', self)
self.run_action.setShortcut('Ctrl+R')
self.run_action.setStatusTip('Run workflow')
self.run_action.triggered.connect(self.run_workflow_command)
self.stop_action = QAction(QIcon.fromTheme('process-stop'), '&Stop',
self)
self.stop_action.setShortcut('Ctrl+K')
self.stop_action.setStatusTip('Stop workflow')
self.stop_action.setEnabled(False)
self.stop_action.triggered.connect(self.stop_command)
export_action = QAction(QIcon.fromTheme('utilities-terminal'),
'&Export to Bash', self)
export_action.setShortcut('Ctrl+E')
export_action.setStatusTip('Export workflow to Bash script')
export_action.triggered.connect(self.configure_bash)
move_up_action = QAction(QIcon.fromTheme('go-up'), 'Move up', self)
move_up_action.setShortcut('Ctrl+Up')
move_up_action.setStatusTip('Move filter up')
move_up_action.triggered.connect(self.move_current_up)
move_down_action = QAction(QIcon.fromTheme('go-down'), 'Move down',
self)
move_down_action.setShortcut('Ctrl+Down')
move_down_action.setStatusTip('Move filter down')
move_down_action.triggered.connect(self.move_current_down)
remove_action = QAction(QIcon.fromTheme('edit-delete'), 'Remove', self)
remove_action.setShortcut('Del')
remove_action.setStatusTip('Remove filter')
remove_action.triggered.connect(self.delete_item)
# debug actions BEGIN
validate_action = QAction(QIcon.fromTheme('dialog-information'),
'Validate workflow',
self)
validate_action.setShortcut('F8')
validate_action.setStatusTip('Validate workflow')
validate_action.triggered.connect(self.validate_wf)
print_cmd_action = QAction(QIcon.fromTheme('dialog-information'),
'Print bash command',
self)
print_cmd_action.setShortcut('F9')
print_cmd_action.setStatusTip('Print bash command')
print_cmd_action.triggered.connect(self.print_command)
print_xml_action = QAction(QIcon.fromTheme('dialog-information'),
'Print XML',
self)
print_xml_action.setShortcut('F10')
print_xml_action.setStatusTip('Print XML')
print_xml_action.triggered.connect(self.print_xml)
print_wf_action = QAction(QIcon.fromTheme('dialog-information'),
'Print workflow',
self)
print_wf_action.setShortcut('F11')
print_wf_action.setStatusTip('Print workflow')
print_wf_action.triggered.connect(self.print_wf)
# debug actions END
self.show_tree_action = QAction('Available Filters', self)
self.show_tree_action.setShortcut('F2')
self.show_tree_action.setCheckable(True)
self.show_tree_action.setChecked(self.tree_dock.isVisible())
self.show_tree_action.setStatusTip('Toggle available filters dock')
# self.show_tree_action.triggered.connect(self.toggle_tree_dock)
self.show_tree_action.toggled.connect(self.tree_dock.setVisible)
self.tree_dock.visibilityChanged.connect(
self.show_tree_action.setChecked)
self.show_progress_action = QAction('Workflow Progress', self)
self.show_progress_action.setShortcut('F3')
self.show_progress_action.setCheckable(True)
self.show_progress_action.setChecked(self.fset_dock.isVisible())
self.show_progress_action.setStatusTip('Toggle progress dock')
# self.show_progress_action.triggered.connect(self.toggle_progress_dock)
self.show_progress_action.toggled.connect(
self.progress_dock.setVisible)
self.progress_dock.visibilityChanged.connect(
self.show_progress_action.setChecked)
self.show_settings_action = QAction('Filter Settings', self)
self.show_settings_action.setShortcut('F4')
self.show_settings_action.setCheckable(True)
self.show_settings_action.setChecked(self.fset_dock.isVisible())
self.show_settings_action.setStatusTip('Toggle filter settings dock')
# self.show_settings_action.triggered.connect(self.toggle_settings_dock)
self.show_settings_action.toggled.connect(self.fset_dock.setVisible)
self.fset_dock.visibilityChanged.connect(
self.show_settings_action.setChecked)
about_action = QAction(QIcon.fromTheme('help-about'),
'About %s' % TITLE, self)
about_action.setShortcut('Ctrl+?')
about_action.setStatusTip('Show information about this program.')
about_action.triggered.connect(self.show_about)
help_action = QAction(QIcon.fromTheme('help-contents'),
'%s Help' % TITLE, self)
help_action.setShortcut('F1')
help_action.setStatusTip('Launch browser to view the help pages.')
help_action.triggered.connect(self.show_help)
# menus
menubar = self.menuBar()
self.file_menu = menubar.addMenu('&File')
self.file_menu_actions = (new_action,
None,
open_action, save_action, save_as_action,
None,
quit_action)
self.file_menu.aboutToShow.connect(self.populate_file_menu)
run_menu = menubar.addMenu('&Run')
run_menu.addAction(self.run_action)
run_menu.addAction(self.stop_action)
run_menu.addAction(export_action)
edit_menu = menubar.addMenu('&Edit')
self.filter_menu = edit_menu.addMenu(QIcon.fromTheme('list-add'),
'&Add Filter...')
self.populate_filter_menu()
edit_menu.addSeparator()
edit_menu.addAction(move_up_action)
edit_menu.addAction(remove_action)
edit_menu.addAction(move_down_action)
view_menu = menubar.addMenu('&View')
view_menu.addAction(self.show_tree_action)
view_menu.addAction(self.show_progress_action)
view_menu.addAction(self.show_settings_action)
if self.debug:
debug_menu = menubar.addMenu('&Debug')
debug_menu.addAction(validate_action)
debug_menu.addAction(print_cmd_action)
debug_menu.addAction(print_xml_action)
debug_menu.addAction(print_wf_action)
help_menu = menubar.addMenu('&Help')
help_menu.addAction(help_action)
help_menu.addAction(about_action)
# toolbars
wf_tools = self.addToolBar('Workflow Toolbar')
wf_tools.setObjectName('WorkflowTools')
wf_tools.addAction(new_action)
wf_tools.addAction(open_action)
wf_tools.addAction(save_action)
# wf_tools.addAction(save_as_action)
wf_tools.addSeparator()
wf_tools.addAction(export_action)
wf_tools.addAction(self.run_action)
wf_tools.addAction(self.stop_action)
f_tools = self.addToolBar('Filter Toolbar')
f_tools.setObjectName('FilterTools')
f_tools.addAction(move_up_action)
f_tools.addAction(remove_action)
f_tools.addAction(move_down_action)
self.addToolBar(Qt.LeftToolBarArea, f_tools)
# status bar
self.steps_lbl = QLabel("%i steps" % self.wflow.get_model().rowCount())
self.steps_lbl.setFrameStyle(QFrame.StyledPanel | QFrame.Sunken)
self.statusBar().setSizeGripEnabled(False)
self.statusBar().addPermanentWidget(self.steps_lbl)
self.statusBar().showMessage('Ready', STATUS_DELAY)
[docs] def addActions(self, target, actions):
"""Adds actions in list to target menu.
Overrides Qt4 base method to also insert separators for 'None'
entries.
Parameters
----------
target : QMenu
Target menu.
actions : list of QAction
List of actions to be inserted into menu.
"""
for action in actions:
if action is None:
target.addSeparator()
else:
target.addAction(action)
[docs] def populate_file_menu(self):
"""Fills the 'File' menu with actions and recently used files."""
self.file_menu.clear()
self.addActions(self.file_menu, self.file_menu_actions[:-1])
# filter out non-existing files and the current file
filtered_recent = []
if self.recent_files is not None:
for fname in self.recent_files:
if fname != self.wflow.get_filename() and QFile.exists(fname):
filtered_recent.append(fname)
# generate entries for the menu
if len(filtered_recent) > 0:
for idx, fname in enumerate(filtered_recent, 1):
action = QAction(QIcon(':/icon.png'), "&%d %s"
% (idx, QFileInfo(fname).fileName()),
self)
action.setData(fname)
action.triggered.connect(self.load_file)
self.file_menu.addAction(action)
self.file_menu.addSeparator()
# add last remaining action (Quit)
self.file_menu.addAction(self.file_menu_actions[-1])
[docs] def populate_filter_menu(self):
"""Creates entries in the 'Edit > Add filter...' menu."""
for item in self.tree_model.iterate_items():
if item.hasChildren:
for i in range(item.rowCount()):
child = item.child(i)
action = QAction(child.icon(), child.text(), self)
action.setStatusTip(child.get_description())
# using functools.partial to provide extra argument to slot
action.triggered.connect(partial(self.add_filter, child))
# action.triggered.connect(self.on_edit)
self.filter_menu.addAction(action)
self.filter_menu.addSeparator()
# view event handlers
[docs] def update_settings_view(self, current_idx, previous_idx):
"""Updates filter settings view upon change of focus in workflow.
Parameters
----------
current_idx : QModelIndex
Model index of currently focussed filter item.
previous_idx: QModelIndex
Model index of previously focussed filter item.
"""
current_item = self.wflow.get_model().data(current_idx,
role=Qt.UserRole)
self.current_editor.setParent(None)
if current_item is None:
self.fset_layout.addWidget(self.placeholder)
self.placeholder.show()
else:
self.placeholder.hide()
self.current_editor = current_item.make_widget()
self.current_editor.value_changed.connect(self.on_settings_change)
self.fset_layout.addWidget(self.current_editor)
[docs] def on_settings_change(self):
"""Handle changes in the settings dialog.
Indicates unsaved settings and re-validates the workflow.
"""
self.wflow.validator.validate()
self.wflow.set_dirty()
[docs] def closeEvent(self, event):
"""Confirm before exiting and save settings for next launch.
Overrides parent QT class method to ask for confirmation and save
window settings, unsaved files and preferences.
Parameters
----------
event : QEvent
Close event triggered by closing the main window or kernel signals.
"""
if self.ok_to_continue():
settings = QSettings()
# save window settings
settings.setValue("LastFile", self.wflow.get_filename())
settings.setValue("RecentFiles", self.recent_files)
settings.setValue("MainWindow/Size", self.size())
settings.setValue("MainWindow/Position", self.pos())
settings.setValue("MainWindow/State", self.saveState())
# save SAM2RMA settings
settings.setValue(
"SAM2RMA/TopPercent", self.rma_options.get_top_percent()
)
settings.setValue(
"SAM2RMA/MaxExpected", self.rma_options.get_max_expected()
)
settings.setValue(
"SAM2RMA/MinScore", self.rma_options.get_min_score()
)
settings.setValue(
"SAM2RMA/MinSupportPercent",
self.rma_options.get_min_support_percent()
)
settings.setValue(
"SAM2RMA/Sam2RmaPath", self.rma_options.get_sam2rma_path()
)
else:
event.ignore()
[docs] def new_file(self):
"""Handles pressing of the 'New' button."""
if not self.ok_to_continue():
return
self.add_recent_file(self.wflow.get_filename())
self.wflow.clear()
[docs] def open_file(self):
"""Handles pressing of the 'Open' button."""
if not self.ok_to_continue():
return
current_name = self.wflow.get_filename()
if current_name is None:
filedir = expanduser("~")
else:
filedir = dirname(current_name)
next_name = QFileDialog.getOpenFileName(
self,
"Choose workflow",
filedir,
"SamSifter XML files (%s);;All files (*)" % " ".join(FILE_FORMATS)
)
if next_name:
self.load_file(next_name)
[docs] def show_about(self):
"""Show an informative About dialog."""
QMessageBox.about(
self,
"About %s" % TITLE,
"""<b>%s</b> v%s
<p>Copyright © 2015 Florian Aldehoff.
<p>%s
<p>%s
<p>Python %s - Qt %s - PyQt %s on %s"""
% (TITLE, __version__, DESC, COPYRIGHT, platform.python_version(),
QT_VERSION_STR, PYQT_VERSION_STR, platform.system())
)
[docs] def show_help(self):
"""Open browser to show the online help."""
QDesktopServices.openUrl(QUrl(
"http://www.biohazardous.de/samsifter/docs/help.html"
))
[docs] def print_command(self):
"""Print current workflow command to console (debugging)."""
self.output.clear()
self.output.append(
self.wflow.commandline(hyphenated=False, multiline=True)
)
log.info(self.wflow)
[docs] def print_xml(self):
"""Print current workflow XML to console (debugging)."""
xml = self.wflow.to_xml_string()
self.output.clear()
self.output.append(xml)
log.info(xml)
[docs] def print_wf(self):
"""Print current workflow to console (debugging)."""
self.output.clear()
self.output.append(repr(self.wflow))
log.info(repr(self.wflow))
[docs] def validate_wf(self):
"""Validate current workflow (debugging)."""
self.output.clear()
validator = WorkflowValidator(self.wflow)
validator.validate()
[docs] def run_workflow_command(self):
"""Runs the workflow."""
commandline = self.wflow.commandline(hyphenated=False, multiline=False)
self.progress_dock.setVisible(True)
self.output.clear()
self.process = QProcess()
# QProcess emits `readyRead` when there is data to be read
self.process.readyReadStandardError.connect(self.on_stderr)
self.process.readyReadStandardOutput.connect(self.on_stdout)
self.process.finished.connect(self.on_workflow_finished)
# prevent accidentally running multiple processes
self.process.started.connect(lambda: self.run_action.setEnabled(False))
self.process.finished.connect(lambda: self.run_action.setEnabled(True))
self.process.started.connect(lambda: self.stop_action.setEnabled(True))
self.process.finished.connect(
lambda: self.stop_action.setEnabled(False))
workdir = dirname(self.wflow.get_out_filename())
self.process.setWorkingDirectory(workdir)
log.info("setting working directory: %s", workdir)
# environment variable is used to set prefix of temporary stats files
prefix = basename(self.wflow.get_in_filename())
prefix = 'reads_per_taxon'
env = QProcessEnvironment.systemEnvironment()
env.insert('filename', prefix)
self.process.setProcessEnvironment(env)
log.info("setting filename variable: %s", prefix)
self.process.start("bash", ['-e', '-c', commandline])
self.pid = self.process.pid()
self.output.setTextColor(QColor("black"))
self.output.append("PROCESS %i STARTED" % self.pid)
log.info("started PID %i", self.pid)
[docs] def run_compile_stats_command(self):
"""Runs command to compile temp statistics files into one CSV file."""
self.process = QProcess(self)
# QProcess emits `readyRead` when there is data to be read
self.process.readyReadStandardError.connect(self.on_stderr)
self.process.readyReadStandardOutput.connect(self.on_stdout_stats)
self.process.finished.connect(self.on_compilation_finished)
# prevent accidentally running multiple processes
self.process.started.connect(lambda: self.run_action.setEnabled(False))
self.process.finished.connect(lambda: self.run_action.setEnabled(True))
self.process.started.connect(lambda: self.stop_action.setEnabled(True))
self.process.finished.connect(
lambda: self.stop_action.setEnabled(False))
# all statistics should be located with output file
workdir = dirname(self.wflow.get_out_filename())
self.process.setWorkingDirectory(workdir)
log.info("setting working directory: %s", workdir)
self.process.start('compile_stats', ['--remove', '--verbose'])
self.pid = self.process.pid()
[docs] def run_sam2rma_command(self):
"""Runs command to create RMA file from (zipped) SAM file."""
self.process = QProcess(self)
self.process.readyReadStandardError.connect(self.on_stderr)
self.process.readyReadStandardOutput.connect(self.on_stdout)
self.process.finished.connect(self.on_postprocessing_finished)
# prevent accidentally running multiple processes
self.process.started.connect(lambda: self.run_action.setEnabled(False))
self.process.finished.connect(lambda: self.run_action.setEnabled(True))
self.process.started.connect(lambda: self.stop_action.setEnabled(True))
self.process.finished.connect(
lambda: self.stop_action.setEnabled(False))
# RMA should be located with output file
workdir = dirname(self.wflow.get_out_filename())
self.process.setWorkingDirectory(workdir)
log.info("setting working directory: %s", workdir)
log.info(self.rma_options.get_sam2rma_path())
self.process.start(
self.rma_options.get_sam2rma_path(),
[
'--minScore %.1f' % self.rma_options.get_min_score(),
'--maxExpected %.2f' % self.rma_options.get_max_expected(),
'--topPercent %.1f' % self.rma_options.get_top_percent(),
'--minSupportPercent %.3f'
% self.rma_options.get_min_support_percent(),
'--in %s' % self.wflow.get_out_filename(),
'--out %s' % workdir
]
)
self.pid = self.process.pid()
[docs] def stop_command(self):
"""Terminates current process and all child processes.
Default termination of QProcess won't work here because bash spawns
independent children that will continue to run.
TODO: Use POSIX command 'kill $(ps -o pid= --ppid $PID)' where pkill
is not available
"""
killer = QProcess(self)
killer.start('bash', ['-c', 'pkill -TERM -P ' + str(self.pid)])
# killer.readyReadStandardError.connect(self.on_stderr)
# killer.readyReadStandardOutput.connect(self.on_stdout)
self.output.setTextColor(QColor("darkred"))
self.output.append("PROCESS %i TERMINATING" % (self.pid, ))
log.info("terminating PID %i", self.pid)
self.pid = -1
[docs] def add_filter(self, item):
"""Insert cloned copy of filter item into the list model.
Note
----
C++ QWidgets do NOT support pythonic copy/deepcopy/pickle! Thus they
can not be easily encoded to/decoded from MIME for standard Qt4
drag&drop support and have to be cloned instead.
"""
if isinstance(item, FilterItem):
current_idx = self.list_view.currentIndex()
if current_idx.row() == -1:
# insert at end of list
target_row = self.wflow.get_model().rowCount()
else:
# insert after current item
target_row = current_idx.row() + 1
self.wflow.get_model().insertItem(item.clone(), target_row)
# put focus on new item
new_idx = self.wflow.get_model().index(target_row)
self.list_view.setCurrentIndex(new_idx)
self.update_status("Added filter step [%s] to workflow"
% item.text())
else:
log.error("not a FilterItem but a %s", type(item))
[docs] def on_tree_item_doubleclick(self):
"""Clone doubleclicked item in tree view into the list."""
cur_tree_idx = self.sender().currentIndex()
item = self.sender().model().itemFromIndex(cur_tree_idx)
self.add_filter(item)
[docs] def on_list_item_doubleclick(self):
"""Toggle settings dock to inspect settings of doubleclicked item."""
if not self.fset_dock.isVisible():
self.fset_dock.setVisible(True)
[docs] def move_current(self, positions):
"""Move item in model.
Parameters
----------
positions : int
number of positions to move currently selected item (negative = up,
positive = down)
Returns
-------
bool
Success of moving operation.
"""
current_idx = self.list_view.currentIndex()
if not current_idx.isValid():
return False
model = self.wflow.get_model()
row = current_idx.row()
if row + positions >= 0 and row + positions < model.rowCount():
item = model.takeItem(row)
model.insertItem(item, row + positions)
mdlidx = model.index(row + positions)
self.list_view.setCurrentIndex(mdlidx)
return True
else:
return False
[docs] def move_current_up(self):
"""Move currently selected item up by one position.
Returns
-------
bool
Success of moving operation.
"""
return self.move_current(-1)
[docs] def move_current_down(self):
"""Move currently selected item down by one position.
Returns
-------
bool
Success of moving operation.
"""
return self.move_current(1)
[docs] def delete_item(self):
"""Delete currently selected item from model.
Returns
-------
bool
Success of delete operation.
"""
current_idx = self.list_view.currentIndex()
if not current_idx.isValid():
return False
row = current_idx.row()
return self.wflow.get_model().removeRow(row)
# process event handlers
[docs] def on_stdout(self):
"""Handles STDOUT output from subprocess."""
self.output.setTextColor(Qt.black)
self.output.append(str(self.process.readAllStandardOutput(),
encoding='utf-8'))
[docs] def on_stdout_stats(self):
"""Handles STDOUT output from statistics file compilation."""
with open('%s.csv' % self.wflow.get_out_filename(), 'a') as stats:
print(
str(self.process.readAllStandardOutput(), encoding='utf-8'),
file=stats
)
[docs] def on_stderr(self):
"""Handles STDERR output from subprocess."""
# convert QT4 binary array back into string
message = str(self.process.readAllStandardError(), encoding='utf-8')
message = message.rstrip()
if message.startswith("INFO:"):
self.output.setTextColor(QColor('darkgreen'))
elif message.startswith("WARNING:"):
self.output.setTextColor(QColor('orange'))
elif message.startswith("ERROR:"):
self.output.setTextColor(QColor('darkred'))
elif "error:" in message:
self.output.setTextColor(QColor('darkred'))
self.stop_command()
else:
self.output.setTextColor(Qt.black)
self.output.append(message)
[docs] def on_workflow_finished(self):
"""Starts post-processing after end of workflow process."""
self.output.setTextColor(QColor('black'))
self.output.append("PROCESS %i FINISHED" % self.pid)
log.info("finished PID %i", self.pid)
self.process = None
self.pid = -1
if self.wflow.get_run_compile_stats() or self.wflow.get_run_sam2rma():
self.output.append("POST-PROCESSING")
# always compile any temporary statistics files
self.run_compile_stats_command()
[docs] def on_compilation_finished(self):
"""Handles signal when statistics compilation has finished."""
self.pid = -1
if not self.wflow.get_run_sam2rma():
self.on_postprocessing_finished()
else:
# generate RMA file
self.run_sam2rma_command()
[docs] def on_postprocessing_finished(self):
"""Handles signal when post-processing has finished."""
self.output.setTextColor(Qt.black)
self.output.append("DONE")
# reset command buttons
self.stop_action.setEnabled(False)
self.run_action.setEnabled(True)
self.process = None
self.pid = -1
# model event handlers
[docs] def update_status(self, message=None):
"""Updates window title and status bar.
Parameters
----------
message : str, optional
Message to be displayed in status bar.
"""
if message is not None:
log.info(message)
self.statusBar().showMessage(message, STATUS_DELAY)
self.steps_lbl.setText(
"%i steps" % (self.wflow.get_model().rowCount()))
filename = self.wflow.get_filename()
if filename is None:
self.setWindowTitle("untitled[*] - %s" % TITLE)
else:
self.setWindowTitle("%s[*] - %s" % (basename(filename), TITLE))
self.setWindowModified(self.wflow.is_dirty())
[docs] def on_validity_change(self, message=None):
"""Show hints to resolve errors and prevent running invalid workflows.
Parameters
----------
message : str, optional
Message to be displayed in message dock.
"""
if message is not None:
if message != "":
log.info(message)
self.output.clear()
self.output.append(message)
# prevent execution of invalid workflow
self.run_action.setEnabled(self.wflow.is_valid() and self.pid == -1)
# set highlighting and tooltips of input/output widgets
self.input_widget.highlight(not self.wflow.infile_is_valid())
self.input_widget.setToolTip(self.wflow.validator.get_input_errors())
self.output_widget.highlight(not self.wflow.outfile_is_valid())
self.output_widget.setToolTip(self.wflow.validator.get_output_errors())
# file operations
[docs] def load_initial_file(self):
"""Restore workflow from previous session, greet user on first use."""
settings = QSettings()
fname = settings.value("LastFile")
if fname and QFile.exists(fname):
self.load_file(fname)
else:
self.update_status("Welcome to SamSifter!")
self.progress_dock.setVisible(True)
self.output.append(GREETING)
[docs] def load_file(self, filename=None):
"""Open workflow data from file.
Retrieves filename from user data of QAction when called from list of
recently used files (signal emits a boolean).
Parameters
----------
filename : str, optional
Path of file to be opened.
"""
if filename is None or filename is False:
action = self.sender()
if isinstance(action, QAction):
filename = action.data()
if not self.ok_to_continue():
return
else:
return
if filename:
self.wflow.set_filename(None)
# actual deserialization is handled by workflow container
(loaded, message) = self.wflow.load(filename)
if loaded:
self.add_recent_file(filename)
# self.update_status(message)
[docs] def save_file(self):
"""Save workflow under current filename."""
# save only if there are changes (commented out to always save)
# if not self.wflow.is_dirty():
# return
filename = self.wflow.get_filename()
if filename is None:
self.save_file_as()
else:
# actual serialization is handled by workflow container
(saved, message) = self.wflow.save()
self.update_status(message)
[docs] def save_file_as(self):
"""Saves workflow under a new filename."""
current_name = self.wflow.get_filename()
if current_name is not None:
filedir = dirname(current_name)
else:
filedir = expanduser("~")
new_name = QFileDialog.getSaveFileName(self, "Save workflow",
filedir,
"SamSifter XML files (%s);;"
"All files (*)"
% (" ".join(FILE_FORMATS), ))
if new_name:
# append missing extension
if "." not in new_name:
new_name += FILE_FORMATS[0].lstrip('*')
self.add_recent_file(new_name)
self.wflow.set_filename(new_name)
self.save_file()
[docs] def add_recent_file(self, filename):
"""Adds a filename to the list of recently used files.
Parameters
----------
filename : str
Path of file to be added to list.
"""
if filename is None:
return
if filename not in self.recent_files:
self.recent_files.insert(0, filename)
while len(self.recent_files) > RECENT_FILES_MAX_LENGTH:
self.recent_files.pop()
[docs] def configure_bash(self):
"""Opens modal dialog to set options for exported bash script."""
dialog = BashOptionsDialog()
options = dialog.get_options()
if options:
self.export_bash(options)
[docs] def configure_sam2rma(self):
"""Opens modal dialog to set options for sam2rma conversion."""
dialog = RmaOptionsDialog(self.rma_options)
options = dialog.get_options()
if options:
self.rma_options = options
[docs] def export_bash(self, options):
"""Opens 'Save as...' dialogue and exports workflow to bash script."""
dialog = QFileDialog()
dialog.setDefaultSuffix('Bash script (*.sh *.SH)')
fname = dialog.getSaveFileName(
self, 'Export as', expanduser("~"),
"Bash script (*.sh *.SH);;All files (*)")
if fname:
# force script extension also on Unity/Gnome systems
if not fname.endswith(".sh"):
fname += ".sh"
self.wflow.to_bash(fname, options, self.rma_options)
[docs] def ok_to_continue(self):
"""Asks user for confirmation to save any unsaved changes.
Returns
-------
bool
True if ok to continue, False if unsaved changes still exist.
"""
if self.wflow.is_dirty():
reply = QMessageBox.question(
self, "Unsaved Changes",
("There are unsaved changes to the current workflow that will "
"be lost. Would you like to save them?"),
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel
)
if reply == QMessageBox.Cancel:
return False
elif reply == QMessageBox.Yes:
self.save_file()
return True
[docs]def sigint_handler(*args):
"""Handler for the SIGINT signal (Ctrl+C)."""
sys.stderr.write('\r')
if QMessageBox.question(
None, '', "Are you sure you want to quit?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No
) == QMessageBox.Yes:
QApplication.quit()
[docs]def main():
"""Executable for SamSifter GUI.
See ``--help`` for details on expected arguments. Takes no input from STDIN
but logs errors and messages to STDERR.
"""
# parse arguments
parser = argparse.ArgumentParser(description=DESC)
parser.add_argument('-v', '--verbose',
required=False,
action='store_true',
help='print additional information to stderr')
parser.add_argument('-d', '--debug',
required=False,
action='store_true',
help='show debug options in menu')
(args, remain_args) = parser.parse_known_args()
# configure logging
if args.verbose:
log.basicConfig(format="%(levelname)s: %(message)s", level=log.DEBUG)
else:
log.basicConfig(format="%(levelname)s: %(message)s")
signal.signal(signal.SIGINT, sigint_handler)
app = QApplication(remain_args)
timer = QTimer()
timer.start(500) # You may change this if you wish.
timer.timeout.connect(lambda: None) # Let the interpreter run each 500 ms.
app.setOrganizationName("Biohazardous")
app.setOrganizationDomain("biohazardous.de")
app.setApplicationVersion(__version__)
app.setApplicationName(TITLE)
app.setWindowIcon(QIcon(":/icon.png"))
main_window = MainWindow(args.verbose, args.debug)
main_window.show()
sys.exit(app.exec_())
if __name__ == '__main__':
main()