savant.plugin.Tool.java Source code

Java tutorial

Introduction

Here is the source code for savant.plugin.Tool.java

Source

/**
 * See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 * This is free software; you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation; either version 2.1 of
 * the License, or (at your option) any later version.
 *
 * This software 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
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */
package savant.plugin;

import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.io.*;
import java.net.URI;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.swing.*;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamConstants;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;

import net.sf.samtools.SAMSequenceDictionary;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import savant.api.adapter.BAMDataSourceAdapter;
import savant.api.adapter.RangeAdapter;
import savant.api.util.DialogUtils;
import savant.api.util.Listener;
import savant.api.util.TrackUtils;
import savant.controller.FrameController;
import savant.file.FileType;
import savant.format.SavantFileFormatter;
import savant.format.SavantFileFormatterUtils;
import savant.settings.DirectorySettings;
import savant.util.*;
import savant.util.export.FastaExporter;
import savant.util.export.TrackExporter;
import savant.view.dialog.FormatProgressDialog;

/**
 * 
 * @author tarkvara
 */
public class Tool extends SavantPanelPlugin {
    static final Log LOG = LogFactory.getLog(Tool.class);

    /** Portion of tool execution which is devoted to preparing files. */
    private static final double PREP_PORTION = 0.25;

    /** Portion of tool execution which is devoted to actual execution. */
    private static final double WORK_PORTION = 0.75;

    private String baseCommand;
    private Pattern progressRegex;
    private Pattern errorRegex;
    private JTextArea console;

    List<ToolArgument> arguments = new ArrayList<ToolArgument>();

    private JPanel mainPanel;

    // The wait panel
    private JProgressBar progressBar;
    private JLabel progressInfo;
    private JButton cancelButton;

    private String workingRef;
    private RangeAdapter workingRange;
    boolean useHomoRefs;
    boolean loadUponCompletion = true;
    private Process toolProc;

    @Override
    public void init(JPanel panel) {
        mainPanel = panel;
        panel.setLayout(new CardLayout());

        JPanel settingsPanel = new ToolSettingsPanel(this);
        panel.add(new JScrollPane(settingsPanel), "Settings");

        JPanel waitCard = new JPanel();
        waitCard.setLayout(new GridBagLayout());

        // Left side filler.
        GridBagConstraints gbc = new GridBagConstraints();
        gbc.gridwidth = GridBagConstraints.REMAINDER;
        gbc.fill = GridBagConstraints.HORIZONTAL;
        gbc.insets = new Insets(5, 100, 0, 100);
        waitCard.add(new JLabel(getDescriptor().getName()), gbc);

        progressBar = new JProgressBar();
        progressBar.setPreferredSize(new Dimension(240, progressBar.getPreferredSize().height));
        waitCard.add(progressBar, gbc);

        progressInfo = new JLabel();
        progressInfo.setAlignmentX(1.0f);
        Font f = progressInfo.getFont();
        f = f.deriveFont(f.getSize() - 2.0f);
        progressInfo.setFont(f);
        waitCard.add(progressInfo, gbc);

        cancelButton = new JButton("Cancel");
        cancelButton.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent ae) {
                if (toolProc != null) {
                    Process p = toolProc;
                    toolProc = null;
                    p.destroy();
                }
                showCard("Settings");
            }
        });
        gbc.fill = GridBagConstraints.NONE;
        waitCard.add(cancelButton, gbc);

        // Console output at the bottom.
        console = new JTextArea();
        console.setFont(f);
        console.setLineWrap(false);
        console.setEditable(false);

        JScrollPane consolePane = new JScrollPane(console);
        consolePane.setPreferredSize(new Dimension(800, 200));
        gbc.weighty = 1.0;
        gbc.insets = new Insets(30, 5, 5, 5);
        gbc.fill = GridBagConstraints.BOTH;
        waitCard.add(consolePane, gbc);

        panel.add(waitCard, "Progress");
    }

    /**
     * The tool's arguments are contained in the associated plugin.xml file.
     */
    void parseDescriptor() throws XMLStreamException, FileNotFoundException {
        XMLStreamReader reader = XMLInputFactory.newInstance()
                .createXMLStreamReader(new FileInputStream(getDescriptor().getFile()));
        do {
            switch (reader.next()) {
            case XMLStreamConstants.START_ELEMENT:
                String elemName = reader.getLocalName().toLowerCase();
                if (elemName.equals("tool")) {
                    baseCommand = reader.getElementText();
                } else if (elemName.equals("arg")) {
                    // There's lots of crud in the XML file; we're just interested in the <arg> elements.
                    arguments.add(new ToolArgument(reader));
                } else if (elemName.equals("progress")) {
                    progressRegex = Pattern.compile(reader.getElementText());
                } else if (elemName.equals("error")) {
                    errorRegex = Pattern.compile(reader.getElementText());
                }
                break;
            case XMLStreamConstants.END_DOCUMENT:
                reader.close();
                reader = null;
                break;
            }
        } while (reader != null);
    }

    /**
     * Displays the command line in the given label.  Similar to, but somewhat prettier than
     * the command line generated by <code>buildCommandLine</code>.
     */
    void displayCommandLine(JLabel l) {
        String command = "<html>";
        command += baseCommand;
        for (ToolArgument a : arguments) {
            if (a.value == null) {
                if (a.required) {
                    command += "<font color=\"red\"> " + a.flag + "</font>";
                }
            } else if (a.enabled) {
                if (a.type == ToolArgument.Type.MULTI) {
                    String[] values = a.value.split(",");
                    for (String val : values) {
                        command += " " + a.flag + " " + val;
                    }
                } else {
                    try {
                        command += " " + a.flag + " " + getStringValue(a);
                    } catch (ParseException px) {
                        // An invalid range specification.
                        command += "<font color=\"red\"> " + a.flag + " " + a.value + "</font>";
                    }
                }
            }
        }
        command += "</html>";
        l.setText(command);
    }

    void checkCommandLine() {
        for (ToolArgument a : arguments) {
            if (a.value == null) {
                if (a.required) {
                    throw new IllegalArgumentException(
                            String.format("Required argument %s (%s) does not have a value.", a.flag, a.name));
                }
            } else if (a.enabled) {
                switch (a.type) {
                case RANGE:
                    // We assume that a tool will only have a single RANGE argument.
                    try {
                        // This sets workingRef and workingRange, so buildCommandLine can count on those being set.
                        getStringValue(a);
                    } catch (ParseException px) {
                        throw new IllegalArgumentException(
                                String.format("Unable to parse \"%s\" as a valid range.", a.value));
                    }
                    break;
                }
            }
        }
    }

    List<String> buildCommandLine() throws IOException {
        List<String> commandLine = new ArrayList<String>();
        commandLine.addAll(Arrays.asList(baseCommand.split("\\s")));

        // If we're launching a .jar, we may want to look for it in the plugins directory.
        if (commandLine.get(0).equals("java")) {
            for (int i = 2; i < commandLine.size(); i++) {
                String arg = commandLine.get(i);
                if (arg.endsWith(".jar")) {
                    // If it's just a .jar name (i.e. no path slashes), assume it's in the plugins directory.
                    if (!arg.contains("/")) {
                        commandLine.set(i,
                                new File(DirectorySettings.getPluginsDirectory(), arg).getAbsolutePath());
                    }
                    break;
                }
            }
        }

        for (ToolArgument a : arguments) {
            if (a.value == null) {
                if (a.required) {
                    throw new IllegalArgumentException(
                            String.format("Required argument %s (%s) does not have a value.", a.flag, a.name));
                }
            } else if (a.enabled) {
                if (a.type == ToolArgument.Type.MULTI) {
                    String[] values = a.value.split(",");
                    for (String val : values) {
                        commandLine.add(a.flag);
                        commandLine.add(val);
                    }
                } else {
                    commandLine.add(a.flag);
                    try {
                        switch (a.type) {
                        case BAM_INPUT_FILE:
                            commandLine.add(getLocalFile(a.value, true).getAbsolutePath());
                            break;
                        case FASTA_INPUT_FILE:
                            commandLine.add(getLocalFile(a.value, false).getAbsolutePath());
                            break;
                        default:
                            commandLine.add(getStringValue(a));
                            break;
                        }
                    } catch (ParseException ignored) {
                        // Shouldn't happen because we've already successfully passed checkCommandLine.
                    }
                }
            }
        }
        return commandLine;
    }

    /**
     * Interpret this argument's value in a form suitable for appearing on a command line.
     */
    public String getStringValue(ToolArgument a) throws ParseException {
        switch (a.type) {
        case BAM_INPUT_FILE:
            return a.value;
        case RANGE:
            parseWorkingRange(a.value);
            if (workingRef != null) {
                String ref = useHomoRefs ? MiscUtils.homogenizeSequence(workingRef) : workingRef;
                if (workingRange != null) {
                    return String.format("%s:%d-%d", ref, workingRange.getFrom(), workingRange.getTo());
                }
                return ref;
            }
            break;
        }
        return a.value;
    }

    private void parseWorkingRange(String val) throws ParseException {
        if (val == null || val.length() == 0) {
            // Empty string means no range restriction.
            workingRef = null;
            workingRange = null;
        } else {
            int colonPos = val.indexOf(':');
            if (colonPos > 0) {
                Bookmark b = new Bookmark(val);
                workingRef = b.getReference();
                workingRange = b.getRange();
            } else {
                // Just a chromosome name.
                workingRef = val;
                workingRange = null;
            }
        }
    }

    /**
     * Get the local file which contains the data for the given argument.
     *
     * @param t URI of the track which is providing the data, or a local file
     * @param loc string specifying ref and range to be processed
     * @param canUseDirectly for bam files, Savant uses them natively, so we may be able to use a local file directly
     */
    private File getLocalFile(String fileOrURI, boolean canUseDirectly) throws IOException {

        URI uri = NetworkUtils.getURIFromPath(fileOrURI);

        // If the data source is a local bam file, we can just use it.
        if (canUseDirectly) {
            if (uri.getScheme().equals("file")) {
                return new File(uri);
            }
        }

        // Track is remote.  We'll need to download it.
        StringBuilder source = new StringBuilder(uri.toString());
        int savantExt = source.lastIndexOf(".savant");
        if (savantExt > 0) {
            source.setLength(savantExt);
        }

        // File may represent only a partial track.  We may need to fetch it afresh,
        // or it may already be in our cache.
        if (RemoteFileCache.findCacheEntry(source.toString()) == null) {
            // Couldn't find exported file for full genome.  Perhaps just for the current chromosome?
            if (workingRef != null) {
                int lastDot = source.lastIndexOf(".");
                source.insert(lastDot, "-" + workingRef);
                if (RemoteFileCache.findCacheEntry(source.toString()) == null) {
                    if (workingRange != null) {
                        // No existing chromosome file, so just request the subrange of interest.
                        lastDot = source.lastIndexOf(".");
                        source.insert(lastDot,
                                String.format(":%d-%d", workingRange.getFrom(), workingRange.getTo()));
                    }
                }
            }
        }

        return RemoteFileCache.getCacheFile(uri.toURL(), source.toString(), 0, 0);
    }

    void execute() {
        // Before we do anything else, make sure all the required parameters have been specified.
        try {
            checkCommandLine();
            showCard("Progress");
            new ToolWorker().execute();
        } catch (IllegalArgumentException x) {
            DialogUtils.displayMessage(x.getMessage());
        }
    }

    private void showCard(String card) {
        ((CardLayout) mainPanel.getLayout()).show(mainPanel, card);
    }

    private class ToolWorker extends BackgroundWorker<File> {
        List<ToolArgument> missingFiles = new ArrayList<ToolArgument>();
        int inputIndex;
        private String errorMessage;
        private File destFile;

        @Override
        protected void showProgress(double fraction) {
            progressBar.setIndeterminate(fraction < 0.0);
            progressBar.setValue((int) (fraction * 100.0));
        }

        @Override
        protected File doInBackground() throws Exception {
            showProgress(0.0);
            cancelButton.setText("Cancel");
            console.setText("");

            progressInfo.setText("Preparing input files\u2026");
            prepareInputs();

            progressInfo.setText("Running tool\u2026");
            runTool();

            if (loadUponCompletion) {
                String destPath = destFile.getAbsolutePath();
                FileType guess = SavantFileFormatterUtils.guessFileTypeFromPath(destPath);
                if (guess == FileType.INTERVAL_BAM) {
                    // BAM files we open directly, without having to format.
                    FrameController.getInstance().addTrackFromPath(destPath, null, null);
                } else {
                    File formattedFile = SavantFileFormatterUtils.getFormattedFile(destPath, guess);

                    SavantFileFormatter sff = SavantFileFormatter.getFormatter(destFile, formattedFile, guess);
                    if (sff != null) {
                        FormatProgressDialog fpd = new FormatProgressDialog(DialogUtils.getMainWindow(), sff, true);
                        fpd.setLocationRelativeTo(DialogUtils.getMainWindow());
                        fpd.setVisible(true);
                    }
                }
            }

            progressInfo.setText("");
            return destFile;
        }

        @Override
        protected void showSuccess(File result) {
            cancelButton.setText("Done");
        }

        /**
         * The first stage of the process may involve copying the track data into
         * local files so that the tool can operate on it.
         * 
         * Once the files have been set up, we have an extra step of bullshit, which
         * involves generating fake .fai and .dict files for our sequence.
         */
        private void prepareInputs() throws IOException, InterruptedException {
            ToolArgument bamArg = null;
            for (ToolArgument a : arguments) {
                if (a.enabled) {
                    switch (a.type) {
                    case BAM_INPUT_FILE:
                        // Remote URLs will need to be downloaded.
                        if (!NetworkUtils.getURIFromPath(a.value).getScheme().equals("file")) {
                            missingFiles.add(a);
                        }
                        bamArg = a;
                        break;
                    case FASTA_INPUT_FILE:
                        // Remote URLs and formatted FASTA files will need to be downloaded.
                        // Actually, all Fasta files will need to be downloaded, since GATK is so finicky about sequence dictionaries.
                        missingFiles.add(a);
                        break;
                    case OUTPUT_FILE:
                        destFile = new File(a.value);
                        break;
                    }
                }
            }

            inputIndex = 0;

            FastaExporter fastaExp = null;
            for (ToolArgument a : missingFiles) {
                File f = getLocalFile(a.value, false);
                if (!f.exists()) {
                    LOG.info(f + " not found, exporting.");
                    TrackExporter exp = TrackExporter.getExporter(a.value, f);
                    exp.addListener(new Listener<DownloadEvent>() {
                        @Override
                        public void handleEvent(DownloadEvent event) {
                            switch (event.getType()) {
                            case PROGRESS:
                                double prog = event.getProgress();
                                if (prog >= 0.0) {
                                    showProgress(PREP_PORTION * (inputIndex + event.getProgress())
                                            / missingFiles.size());
                                } else {
                                    showProgress(-1.0);
                                }
                                break;
                            case COMPLETED:
                                try {
                                    RemoteFileCache.updateCacheEntry(event.getFile());
                                } catch (Exception x) {
                                    LOG.error("Unable to update cache entry for " + event.getFile(), x);
                                }
                                break;
                            }
                        }
                    });
                    exp.export(workingRef, workingRange);
                    if (exp instanceof FastaExporter) {
                        fastaExp = (FastaExporter) exp;
                    }
                }
                showProgress(++inputIndex * PREP_PORTION / missingFiles.size());
            }

            // If we did a fasta export, we have to create the index and dictionary based, not on the contents of the
            // FASTA file, but on the sequence dictionary from the header of the BAM file.
            if (bamArg != null && fastaExp != null) {
                SAMSequenceDictionary samDict = ((BAMDataSourceAdapter) TrackUtils.getTrackDataSource(bamArg.value))
                        .getHeader().getSequenceDictionary();
                fastaExp.createFakeIndex(samDict, workingRef == null);
                fastaExp.createFakeSequenceDictionary(samDict);
            }
        }

        private void runTool() throws IOException {
            List<String> commandLine = buildCommandLine();
            ProcessBuilder builder = new ProcessBuilder(commandLine);
            builder.redirectErrorStream(true);
            toolProc = builder.start();
            BufferedReader reader = new BufferedReader(new InputStreamReader(toolProc.getInputStream()));
            try {
                String line;
                while ((line = reader.readLine()) != null) {
                    line += "\n";
                    console.append(line);
                    if (errorRegex != null) {
                        Matcher m = errorRegex.matcher(line);
                        if (m.find()) {
                            errorMessage = m.group(1);
                            LOG.info("Retrieved error message \"" + errorMessage + "\".");
                            continue;
                        }
                    }

                    if (progressRegex != null) {
                        Matcher m = progressRegex.matcher(line);
                        if (m.find()) {
                            String progress = m.group(1);
                            try {
                                showProgress(PREP_PORTION + Double.valueOf(progress) * WORK_PORTION * 0.01);
                            } catch (NumberFormatException ignored) {
                                // So it's not a valid number.  Unfortunate, but no disaster.
                                LOG.info("Unable to interpret \"" + progress + "\" as a percentage.");
                            }
                        }
                    }
                }
                toolProc = null;
            } catch (IOException x) {
                // If user cancelled the process, we'll get a harmless IOException trying to read its output.
                if (toolProc != null) {
                    throw x;
                }
            }

            // We're done.  We may have picked up an error message along the way.
            if (errorMessage != null) {
                throw new IOException(errorMessage);
            }
        }
    }
}