From 9da2a6cddbc0d6fbaf7222075e3b145f9ff049d4 Mon Sep 17 00:00:00 2001 From: Mateusz Warowny Date: Tue, 8 Feb 2022 20:59:02 +0100 Subject: [PATCH] JAL-3954 Prepare a skeleton for PHMMER web service. --- src/jalview/ws2/gui/PhmmerMenuBuilder.java | 77 +++++++++ src/jalview/ws2/hmmer/ParamSetFactory.java | 139 ++++++++++++++++ src/jalview/ws2/hmmer/PhmmerParamDatastore.java | 79 +++++++++ src/jalview/ws2/hmmer/PhmmerWSDiscoverer.java | 185 +++++++++++++++++++++ src/jalview/ws2/hmmer/PhmmerWebService.java | 203 +++++++++++++++++++++++ src/jalview/ws2/operations/PhmmerOperation.java | 42 +++++ src/jalview/ws2/operations/PhmmerWorker.java | 91 ++++++++++ 7 files changed, 816 insertions(+) create mode 100644 src/jalview/ws2/gui/PhmmerMenuBuilder.java create mode 100644 src/jalview/ws2/hmmer/ParamSetFactory.java create mode 100644 src/jalview/ws2/hmmer/PhmmerParamDatastore.java create mode 100644 src/jalview/ws2/hmmer/PhmmerWSDiscoverer.java create mode 100644 src/jalview/ws2/hmmer/PhmmerWebService.java create mode 100644 src/jalview/ws2/operations/PhmmerOperation.java create mode 100644 src/jalview/ws2/operations/PhmmerWorker.java diff --git a/src/jalview/ws2/gui/PhmmerMenuBuilder.java b/src/jalview/ws2/gui/PhmmerMenuBuilder.java new file mode 100644 index 0000000..09e549e --- /dev/null +++ b/src/jalview/ws2/gui/PhmmerMenuBuilder.java @@ -0,0 +1,77 @@ +package jalview.ws2.gui; + +import java.util.Collections; +import java.util.List; + +import javax.swing.JMenu; +import javax.swing.JMenuItem; + +import jalview.datamodel.AlignmentView; +import jalview.datamodel.SequenceI; +import jalview.gui.AlignFrame; +import jalview.gui.AlignViewport; +import jalview.util.MessageManager; +import jalview.ws.params.ArgumentI; +import jalview.ws2.PollingTaskExecutor; +import jalview.ws2.operations.PhmmerOperation; +import jalview.ws2.operations.PhmmerWorker; + +public class PhmmerMenuBuilder implements MenuEntryProviderI +{ + PhmmerOperation operation; + + public PhmmerMenuBuilder(PhmmerOperation operation) + { + this.operation = operation; + } + + @Override + public void buildMenu(JMenu parent, AlignFrame frame) + { + { + var item = new JMenuItem(MessageManager.formatMessage( + "label.calcname_with_default_settings", operation.getName())); + item.addActionListener((event) -> { + // TODO: not sure if the right way to get sequences for phmmer + final AlignmentView msa = frame.gatherSequencesForAlignment(); + if (msa != null) + { + startWorker(frame, msa, Collections.emptyList()); + } + }); + parent.add(item); + } + if (operation.hasParameters()) + { + var item = new JMenuItem(MessageManager.getString("label.edit_settings_and_run")); + item.setToolTipText(MessageManager.getString("label.view_and_change_parameters_before_running_calculation")); + item.addActionListener((event) -> { + final AlignmentView msa = frame.gatherSequencesForAlignment(); + if (msa != null) + { + MenuEntryProviderI.openEditParamsDialog(operation.getParamStore(), + null, null) + .thenAcceptAsync((arguments) -> { + if (arguments != null) + { + startWorker(frame, msa, arguments); + } + }); + } + }); + } + SequenceI[] sequences = frame.getViewport().getAlignment().getSequencesArray(); + } + + private void startWorker(AlignFrame frame, AlignmentView msa, + List arguments) + { + final AlignViewport viewport = frame.getViewport(); + final PollingTaskExecutor executor = viewport.getWSExecutor(); + if (msa != null) + { + PhmmerWorker worker = new PhmmerWorker(operation, msa, arguments); + executor.submit(worker); + } + } +} diff --git a/src/jalview/ws2/hmmer/ParamSetFactory.java b/src/jalview/ws2/hmmer/ParamSetFactory.java new file mode 100644 index 0000000..211b9de --- /dev/null +++ b/src/jalview/ws2/hmmer/ParamSetFactory.java @@ -0,0 +1,139 @@ +package jalview.ws2.hmmer; + +import java.util.ArrayList; +import java.util.List; + +import jalview.hmmer.rest.PhmmerClient; +import jalview.ws.params.ArgumentI; +import jalview.ws.params.WsParamSetI; +import jalview.ws.params.simple.*; + +class ParamSetFactory +{ + private static class SimpleParamSet implements WsParamSetI + { + private String name; + private String description; + private String[] applicableUrls; + private String sourceFile = null; + private List arguments; + + + @Override + public String getName() + { + return name; + } + + @Override + public String getDescription() + { + return description; + } + + @Override + public String[] getApplicableUrls() + { + return applicableUrls; + } + + @Override + public String getSourceFile() + { + return sourceFile; + } + + @Override + public void setSourceFile(String newfile) + { + this.sourceFile = newfile; + } + + @Override + public boolean isModifiable() + { + return true; + } + + @Override + public List getArguments() + { + return arguments; + } + + @Override + public void setArguments(List args) + { + throw new UnsupportedOperationException(); + } + } + + public static WsParamSetI newDefaultParamSet(PhmmerClient client) + { + SimpleParamSet paramSet = new SimpleParamSet(); + paramSet.name = "Default"; + paramSet.description = "Default set of parameters"; + paramSet.applicableUrls = new String[]{ PhmmerClient.getDefaultURL() }; + List arguments = new ArrayList<>(); + var reqBuilder = client.newRequestBuilder(); + + arguments.add(new StringParameter( + "Sequence Database", "Sequence Database Selection.", true, + reqBuilder.getDefaultDatabase(), reqBuilder.getDefaultDatabase(), + reqBuilder.getAllowedDatabaseValues(), + reqBuilder.getAllowedDatabaseValues())); + arguments.add(new BooleanOption("Output Alignment", "Output alignment in result.", + false, reqBuilder.getDefaultAlignView(), reqBuilder.getDefaultAlignView(), null)); + arguments.add(new IntegerParameter( + "Number of Hits Displayed", "Number of hits to be displayed.", false, + reqBuilder.getDefaultNhits(), 0, 100000)); + + arguments.add(new RadioChoiceParameter( + "Cut-offs", "Cut-off type to be used.", List.of("E-value", "Bit score"), + "E-value")); + + arguments.add(new LogarithmicParameter( + "Significance E-values[Sequence]", "Significance E-values[Sequence]", + false, (double) reqBuilder.getDefaultIncE(), Double.MIN_VALUE, 10.0)); + arguments.add(new LogarithmicParameter( + "Significance E-values[Hit]", "Significance E-values[Hit]", false, + (double) reqBuilder.getDefaultIncdomE(), Double.MIN_VALUE, 10.0)); + arguments.add(new LogarithmicParameter( + "Report E-values[Sequence]", "Report E-values[Sequence]", false, + (double) reqBuilder.getDefaultE(), Double.MIN_VALUE, 10.0)); + arguments.add(new LogarithmicParameter( + "Report E-values[Hit]", "Report E-values[Hit]", false, + (double) reqBuilder.getDefaultDomE(), Double.MIN_VALUE, 10.0)); + + arguments.add(new DoubleParameter( + "Significance bit scores[Sequence]", "Significance bit scores[Sequence]", + false, (double) reqBuilder.getDefaultIncT(), 0.0, 10000.0)); + arguments.add(new DoubleParameter( + "Significance bit scores[Hit]", "Significance bit scores[Hit]", + false, (double) reqBuilder.getDefaultIncdomT(), 0.0, 10000.0)); + arguments.add(new DoubleParameter( + "Report bit scores[Sequence]", "Report bit scores[Sequence]", + false, (double) reqBuilder.getDefaultT(), 0.0, 10000.0)); + arguments.add(new DoubleParameter( + "Report bit scores[Hit]", "Report bit scores[Hit]", false, + (double) reqBuilder.getDefaultDomT(), 0.0, 10000.0)); + + arguments.add(new DoubleParameter( + "Gap Penalties[open]", "Gap Penalties[open]", false, + (double) reqBuilder.getDefaultPopen(), 0.0, 0.5)); + arguments.add(new DoubleParameter( + "Gap Penalties[extend]", "Gap Penalties[extend]", false, + (double) reqBuilder.getDefaultPextend(), 0.0, 1.0)); + arguments.add(new StringParameter( + "Gap Penalties[Substitution scoring matrix]", + "Gap Penalties[Substitution scoring matrix]", + false, reqBuilder.getDefaultMx(), reqBuilder.getDefaultMx(), + reqBuilder.getAllowedMxValues(), reqBuilder.getAllowedMxValues())); + arguments.add(new BooleanOption( + "No bias filter", "Disable bias composition filter which is on by default", + false, reqBuilder.getDefaultNoBias(), reqBuilder.getDefaultNoBias(), null)); + + paramSet.arguments = arguments; + return paramSet; + } +} diff --git a/src/jalview/ws2/hmmer/PhmmerParamDatastore.java b/src/jalview/ws2/hmmer/PhmmerParamDatastore.java new file mode 100644 index 0000000..63b7df8 --- /dev/null +++ b/src/jalview/ws2/hmmer/PhmmerParamDatastore.java @@ -0,0 +1,79 @@ +package jalview.ws2.hmmer; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +import jalview.hmmer.rest.PhmmerClient; +import jalview.ws.params.ArgumentI; +import jalview.ws.params.ParamDatastoreI; +import jalview.ws.params.WsParamSetI; + +public class PhmmerParamDatastore implements ParamDatastoreI +{ + private WsParamSetI defaultPreset; + private List presets = List.of(); + + public PhmmerParamDatastore(PhmmerClient client) { + defaultPreset = ParamSetFactory.newDefaultParamSet(client); + } + + @Override + public List getPresets() + { + return presets; + } + + @Override + public WsParamSetI getPreset(String name) + { + for (WsParamSetI preset : presets) + if (preset.getName().equals(name)) + return preset; + return null; + } + + @Override + public List getServiceParameters() + { + return Collections.unmodifiableList(defaultPreset.getArguments()); + } + + @Override + public boolean presetExists(String name) + { + for (WsParamSetI preset : presets) + if (preset.getName().equals(name)) + return true; + return false; + } + + @Override + public void deletePreset(String name) + { + } + + @Override + public void storePreset(String presetName, String text, List jobParams) + { + } + + @Override + public void updatePreset(String oldName, String presetName, String text, List jobParams) + { + } + + @Override + public WsParamSetI parseServiceParameterFile(String name, String description, String[] serviceURL, String parameters) + throws IOException + { + return null; + } + + @Override + public String generateServiceParameterFile(WsParamSetI pset) throws IOException + { + return null; + } + +} diff --git a/src/jalview/ws2/hmmer/PhmmerWSDiscoverer.java b/src/jalview/ws2/hmmer/PhmmerWSDiscoverer.java new file mode 100644 index 0000000..a030fa2 --- /dev/null +++ b/src/jalview/ws2/hmmer/PhmmerWSDiscoverer.java @@ -0,0 +1,185 @@ +package jalview.ws2.hmmer; + +import java.io.IOException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; + +import jalview.bin.Cache; +import jalview.hmmer.rest.PhmmerClient; +import jalview.ws2.OperationsChangeListenerList; +import jalview.ws2.WebServiceDiscovererI; +import jalview.ws2.operations.Operation; +import jalview.ws2.operations.PhmmerOperation; + +import static java.lang.String.format; + + +public class PhmmerWSDiscoverer implements WebServiceDiscovererI +{ + private static final String DEFAULT_PHMMER_URL = PhmmerClient.getDefaultURL(); + + private static PhmmerWSDiscoverer instance = null; + + private List operations = List.of(); + + private PhmmerWSDiscoverer() + { + } + + public static PhmmerWSDiscoverer getInstance() + { + if (instance == null) + instance = new PhmmerWSDiscoverer(); + return instance; + } + + @Override + public List getUrls() + { + return List.of(DEFAULT_PHMMER_URL); + } + + @Override + public void setUrls(List wsUrls) + { + throw new UnsupportedOperationException("setting url is not supported"); + } + + @Override + public boolean testUrl(URL url) + { + return getStatusForUrl(url.toString()) == STATUS_OK; + } + + @Override + public int getStatusForUrl(String url) + { + try { + PhmmerClient.create(url); + return STATUS_OK; + } + catch (IOException e) { + return STATUS_INVALID; + } + } + + @Override + public List getOperations() + { + return operations; + } + + @Override + public boolean hasServices() + { + return !isRunning() && operations.size() > 0; + } + + private static final int END = 0x01; + private static final int BEGIN = 0x02; + private static final int AGAIN = 0x04; + private final AtomicInteger state = new AtomicInteger(END); + private CompletableFuture discoveryTask = new CompletableFuture<>(); + + @Override + public boolean isRunning() + { + return (state.get() & (BEGIN | AGAIN)) != 0; + } + + @Override + public boolean isDone() + { + return state.get() == END && discoveryTask != null && discoveryTask.isDone(); + } + + @Override + public synchronized CompletableFuture startDiscoverer() + { + while (true) + { + if (state.get() == AGAIN) + { + break; + } + else if (state.compareAndSet(END, BEGIN) || state.compareAndSet(BEGIN, AGAIN)) + { + final var oldTask = discoveryTask; + CompletableFuture task = oldTask + .handleAsync((r, e) -> { + reloadServices(); + return PhmmerWSDiscoverer.this; + }); + task.thenRun(() -> { + while (true) + { + if (state.compareAndSet(BEGIN, END) || state.compareAndSet(AGAIN, BEGIN)) + { + break; + } + } + fireOperationsChanged(getOperations()); + }); + oldTask.cancel(false); + discoveryTask = task; + break; + } + } + return discoveryTask; + } + + private List reloadServices() + { + fireOperationsChanged(Collections.emptyList()); + ArrayList allOperations = new ArrayList<>(); + for (String url : getUrls()) + { + PhmmerClient client; + try + { + client = PhmmerClient.create(url); + } catch (IOException e) + { + Cache.log.error(format("Unable to create phmmer client for url %s", url), e); + continue; + } + PhmmerWebService webService = new PhmmerWebService(client); + PhmmerOperation operation = new PhmmerOperation(webService, webService::getPhmmerResult); + allOperations.add(operation); + } + this.operations = Collections.unmodifiableList(allOperations); + Cache.log.info("Reloading phmmer services finished"); + return this.operations; + } + + @Override + public String getErrorMessages() + { + return ""; + } + + private OperationsChangeListenerList operationsChangeListeners = + new OperationsChangeListenerList(this); + + @Override + public void addOperationsChangeListener(OperationsChangeListener listener) + { + operationsChangeListeners.addListener(listener); + } + + @Override + public void removeOperationsChangeListener(OperationsChangeListener listener) + { + operationsChangeListeners.removeListener(listener); + } + + private void fireOperationsChanged(List list) + { + operationsChangeListeners.fireOperationsChanged(list); + } + +} diff --git a/src/jalview/ws2/hmmer/PhmmerWebService.java b/src/jalview/ws2/hmmer/PhmmerWebService.java new file mode 100644 index 0000000..297906c --- /dev/null +++ b/src/jalview/ws2/hmmer/PhmmerWebService.java @@ -0,0 +1,203 @@ +package jalview.ws2.hmmer; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import jalview.bin.Cache; +import jalview.datamodel.SequenceI; +import jalview.hmmer.rest.PhmmerClient; +import jalview.hmmer.rest.PhmmerRequest; +import jalview.hmmer.rest.PhmmerRequestBuilder; +import jalview.io.FileFormat; +import jalview.ws.params.ArgumentI; +import jalview.ws.params.ParamDatastoreI; +import jalview.ws2.WSJob; +import jalview.ws2.WSJobStatus; +import jalview.ws2.WebServiceI; + +import static java.lang.String.format; + +public class PhmmerWebService implements WebServiceI +{ + protected final PhmmerClient client; + protected ParamDatastoreI store; + + public PhmmerWebService(PhmmerClient client) { + this.client = client; + } + + @Override + public String getHostName() + { + // TODO Auto-generated method stub + return null; + } + + @Override + public String getProviderName() + { + return "EBI HMMER"; + } + + @Override + public String getName() + { + return "phmmer"; + } + + @Override + public String getDescription() + { + return "HMMER phmmer searches a database of protein sequence with " + + "a protein sequence (protein sequence vs protein sequence database)."; + } + + @Override + public boolean hasParameters() + { + return getParamStore().getServiceParameters().size() > 0; + } + + @Override + public ParamDatastoreI getParamStore() + { + if (store == null) + store = new PhmmerParamDatastore(client); + return store; + } + + @Override + public String submit(List sequences, List args) throws IOException + { + PhmmerRequestBuilder reqBuilder = client.newRequestBuilder(); + String sequence = FileFormat.Fasta.getWriter(null) + .print(new SequenceI[]{ sequences.get(0) }, false); + reqBuilder.sequenceString(sequence); + boolean useBitScore = false; + for (ArgumentI arg : args) { + String value = arg.getValue(); + if (value == null) + continue; + switch(arg.getName()) { + case "Sequence Database": + reqBuilder.database(value); + break; + case "Output Alignment": + reqBuilder.alignView(!value.isBlank() ? true : false); + break; + case "Number of Hits Displayed": + reqBuilder.nhits(Integer.parseInt(value)); + break; + case "Cut-offs": + if (value.equals("E-value")) + useBitScore = false; + else if (value.equals("Bit score")) + useBitScore = true; + else + throw new IllegalArgumentException(format( + "Illegal cut off value \"%s\".", value)); + break; + case "Gap Penalties[open]": + reqBuilder.popen(Float.parseFloat(value)); + break; + case "Gap Penalties[extend]": + reqBuilder.popen(Float.parseFloat(value)); + break; + case "Gap Penalties[Substitution scoring matrix]": + reqBuilder.mx(value); + break; + case "No bias filter": + reqBuilder.noBias(!value.isBlank() ? true : false); + break; + } + } + for (ArgumentI arg : args) { + String value = arg.getValue(); + if (value == null) + continue; + switch (arg.getName()) { + case "Significance E-values[Sequence]": + if (useBitScore) break; + reqBuilder.incE(Float.parseFloat(value)); + break; + case "Significance E-values[Hit]": + if (useBitScore) break; + reqBuilder.incdomE(Float.parseFloat(value)); + break; + case "Report E-values[Sequence]": + if (useBitScore) break; + reqBuilder.E(Float.parseFloat(value)); + break; + case "Report E-values[Hit]": + if (useBitScore) break; + reqBuilder.domE(Float.parseFloat(value)); + break; + case "Significance bit scores[Sequence]": + if (!useBitScore) break; + reqBuilder.incT(Float.parseFloat(value)); + break; + case "Significance bit scores[Hit]": + if (!useBitScore) break; + reqBuilder.incdomT(Float.parseFloat(value)); + break; + case "Report bit scores[Sequence]": + if (!useBitScore) break; + reqBuilder.T(Float.parseFloat(value)); + break; + case "Report bit scores[Hit]": + if (!useBitScore) break; + reqBuilder.domT(Float.parseFloat(value)); + break; + } + } + PhmmerRequest request = reqBuilder.build(); + return client.submitRequest(request, "user@example.org"); + } + + @Override + public void updateProgress(WSJob job) throws IOException + { + var status = client.pollStatus(job.getJobId()); + switch(status) { + case PENDING: + job.setStatus(WSJobStatus.QUEUED); break; + case RUNNING: + job.setStatus(WSJobStatus.RUNNING); break; + case FINISHED: + job.setStatus(WSJobStatus.FINISHED); break; + case FAILURE: + job.setStatus(WSJobStatus.FAILED); break; + case NOT_FOUND: + job.setStatus(WSJobStatus.UNKNOWN); break; + case UNDEFINED: + job.setStatus(WSJobStatus.UNKNOWN); break; + } + } + + @Override + public void cancel(WSJob job) throws IOException + { + job.setStatus(WSJobStatus.CANCELLED); + Cache.log.warn("phmmer does not implement job cancellation."); + } + + @Override + public boolean handleSubmissionError(WSJob job, Exception ex) + { + return false; + } + + @Override + public boolean handleCollectionError(WSJob job, Exception ex) + { + return false; + } + + public Object getPhmmerResult(WSJob job) throws IOException + { + InputStream is = client.getFileStream(job.getJobId(), "out"); + return new String(is.readAllBytes(), "UTF-8"); + } + +} diff --git a/src/jalview/ws2/operations/PhmmerOperation.java b/src/jalview/ws2/operations/PhmmerOperation.java new file mode 100644 index 0000000..4c769f5 --- /dev/null +++ b/src/jalview/ws2/operations/PhmmerOperation.java @@ -0,0 +1,42 @@ +package jalview.ws2.operations; + +import java.io.IOException; + +import jalview.ws2.WSJob; +import jalview.ws2.WebServiceI; +import jalview.ws2.gui.PhmmerMenuBuilder; +import jalview.ws2.gui.MenuEntryProviderI; + +public class PhmmerOperation extends AbstractOperation +{ + /** + * Interface that the web service client must implement in order to + * support phmmer operations. + * + * TODO: Change the name of the interface and its method. + * TODO: Change the returned type. + */ + public static interface PhmmerResultSupplier { + public Object getPhmmerResult(WSJob job) throws IOException; + } + + private PhmmerResultSupplier resultSupplier; + + public PhmmerOperation(WebServiceI service, PhmmerResultSupplier resultSupplier) + { + super(service, "HMMER"); + this.resultSupplier = resultSupplier; + } + + @Override + public MenuEntryProviderI getMenuBuilder() + { + return new PhmmerMenuBuilder(this); + } + + public PhmmerResultSupplier getResultSupplier() + { + return resultSupplier; + } + +} diff --git a/src/jalview/ws2/operations/PhmmerWorker.java b/src/jalview/ws2/operations/PhmmerWorker.java new file mode 100644 index 0000000..dc61b84 --- /dev/null +++ b/src/jalview/ws2/operations/PhmmerWorker.java @@ -0,0 +1,91 @@ +package jalview.ws2.operations; + +import static java.lang.String.format; + +import java.io.IOException; +import java.util.List; + +import jalview.bin.Cache; +import jalview.datamodel.AlignmentView; +import jalview.datamodel.SequenceI; +import jalview.gui.AlignViewport; +import jalview.ws.params.ArgumentI; +import jalview.ws2.WSJob; +import jalview.ws2.WebServiceI; + + +public class PhmmerWorker extends AbstractPollableWorker +{ + + // TODO store additional job information in the job object + private static class PhmmerJob extends WSJob + { + private PhmmerJob(String serviceProvider, String serviceName, String hostName) + { + super(serviceProvider, serviceName, hostName); + } + } + + private final PhmmerOperation operation; + private final AlignmentView msa; + private final List args; + private WSJobList jobs = new WSJobList<>(); + private PhmmerJob job = null; + + // TODO add constructor arguments which are necessary to run the job. + public PhmmerWorker(PhmmerOperation operation, AlignmentView msa, List args) + { + this.operation = operation; + this.msa = msa; + this.args = args; + } + + @Override + public void start() throws IOException + { + Cache.log.info(format("Starting new %s job", operation.getName())); + /* TODO prepare input data and submit the job + * this is currently a placeholder which fetches all sequences + * the client submits only the first sequence */ + List sequences = msa.getVisibleAlignment('-').getSequences(); + WebServiceI client = operation.getWebService(); + job = new PhmmerJob(client.getProviderName(), client.getName(), client.getHostName()); + jobs.add(job); + listeners.fireJobCreated(job); + String jobId = client.submit(sequences, args); + job.setJobId(jobId); + Cache.log.debug(format("Service %s: submitted job id %s", operation.getHostName(), jobId)); + listeners.fireWorkerStarted(); + } + + @Override + public void done() + { + /* TODO when the job is finished, get the result from the supplier + * and do whatever is needed to display the result. For now, the result + * is printed to the logger */ + Object result; + try + { + result = operation.getResultSupplier().getPhmmerResult(job); + } + catch (IOException e) { + Cache.log.debug(format("Failed to get results for job %s.", job.toString())); + return; + } + Cache.log.debug(result.toString()); + } + + @Override + public Operation getOperation() + { + return operation; + } + + @Override + public WSJobList getJobs() + { + return jobs; + } + +} -- 1.7.10.2