Source code for samsifter.samsifter

#!/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 &copy; 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()