diff --git a/client/galaxy/scripts/apps/panels/tool-panel.js b/client/galaxy/scripts/apps/panels/tool-panel.js index 0a2cead12b9..c7ba4027c95 100644 --- a/client/galaxy/scripts/apps/panels/tool-panel.js +++ b/client/galaxy/scripts/apps/panels/tool-panel.js @@ -2,6 +2,7 @@ import Tools from "mvc/tool/tools"; import Upload from "mvc/upload/upload-view"; import _l from "utils/localization"; import ToolForm from "mvc/tool/tool-form-composite"; +import _ from "libs/underscore"; var ToolPanel = Backbone.View.extend({ initialize: function(page, options) { @@ -28,7 +29,8 @@ var ToolPanel = Backbone.View.extend({ // add upload modal this.upload_button = new Upload({ - nginx_upload_path: config.nginx_upload_path, + upload_path: config.nginx_upload_path || `${Galaxy.root}api/tools`, + chunk_upload_size: config.chunk_upload_size, ftp_upload_site: config.ftp_upload_site, default_genome: config.default_genome, default_extension: config.default_extension @@ -74,70 +76,54 @@ var ToolPanel = Backbone.View.extend({ /** build a link to one tool */ _templateTool: function(tool) { - return [ - '
', - '', - tool.title, - "", - "
" - ].join(""); + return `
+ + ${tool.title} + +
`; }, /** build a link to 'All Workflows' */ _templateAllWorkflow: function(tool) { - return [ - '
', - // global - '', - tool.title, - "", - "
" - ].join(""); + return `
+ + ${tool.title} + +
`; }, /** build links to workflows in toolpanel */ _templateWorkflowLink: function(wf) { - return [ - '
', - `', - wf.title, - "", - "
" - ].join(""); + return `
+ + ${_.escape(wf.title)} + +
`; }, /** override to include inital menu dom and workflow section */ _template: function() { - return [ - '
', - '", - '
', - '
', - '
', - "", - _l("Workflows"), - "", - "
", - '
', - '
', - "
", - "
" - ].join(""); + return `
+ +
+
+
+ + ${_l("Workflows")} + +
+
+
+
+
`; }, toString: function() { diff --git a/client/galaxy/scripts/mvc/form/form-data.js b/client/galaxy/scripts/mvc/form/form-data.js index e393af66943..7ea27dda0ff 100644 --- a/client/galaxy/scripts/mvc/form/form-data.js +++ b/client/galaxy/scripts/mvc/form/form-data.js @@ -200,7 +200,7 @@ export var matchCase = (input, value) => { * @param{dict} callback - Called with the mapped dictionary object and corresponding model node */ export var visitInputs = (inputs, callback, prefix, context) => { - context = $.extend(true, {}, context); + context = $.extend({}, context); _.each(inputs, input => { if (input && input.type && input.name) { context[input.name] = input; diff --git a/client/galaxy/scripts/mvc/form/form-section.js b/client/galaxy/scripts/mvc/form/form-section.js index 54ab581e294..7b1635a0393 100644 --- a/client/galaxy/scripts/mvc/form/form-section.js +++ b/client/galaxy/scripts/mvc/form/form-section.js @@ -26,9 +26,8 @@ var View = Backbone.View.extend({ }, /** Add a new input element */ - add: function(input) { - var input_def = jQuery.extend(true, {}, input); - input_def.id = input.id = Utils.uid(); + add: function(input_def) { + input_def.id = Utils.uid(); this.app.input_list[input_def.id] = input_def; switch (input_def.type) { case "conditional": diff --git a/client/galaxy/scripts/mvc/form/form-view.js b/client/galaxy/scripts/mvc/form/form-view.js index 4fd9c3056b8..c4ee6f52d7a 100644 --- a/client/galaxy/scripts/mvc/form/form-view.js +++ b/client/galaxy/scripts/mvc/form/form-view.js @@ -80,7 +80,7 @@ export default Backbone.View.extend({ .first(); $panel.animate( { - scrollTop: $panel.scrollTop() + input_element.$el.offset().top - 120 + scrollTop: $panel.scrollTop() + input_element.$el.offset().top - $panel.position().top - 120 }, 500 ); diff --git a/client/galaxy/scripts/mvc/grid/grid-template.js b/client/galaxy/scripts/mvc/grid/grid-template.js index 0bb8c2ba7d6..63e407e16ac 100644 --- a/client/galaxy/scripts/mvc/grid/grid-template.js +++ b/client/galaxy/scripts/mvc/grid/grid-template.js @@ -93,7 +93,9 @@ export default { if (column.visible) { tmpl += ``; if (column.sortable) { - tmpl += `${column.label}`; + tmpl += `${ + column.label + }`; } else { tmpl += column.label; } diff --git a/client/galaxy/scripts/mvc/tool/tool-form-composite.js b/client/galaxy/scripts/mvc/tool/tool-form-composite.js index a7a1a900800..01d1a50dee6 100644 --- a/client/galaxy/scripts/mvc/tool/tool-form-composite.js +++ b/client/galaxy/scripts/mvc/tool/tool-form-composite.js @@ -25,18 +25,6 @@ var View = Backbone.View.extend({ $("body").append(this.$el); this._configure(); this.render(); - $(window).resize(() => { - self._refresh(); - }); - }, - - /** Refresh height of scrollable div below header, handle scrolling by lazy loading steps */ - _refresh: function(step_index) { - var margin = - _.reduce(this.$el.children(), (memo, child) => memo + $(child).outerHeight(), 0) - - this.$steps.height() + - 90; - this.$steps.css("height", $(window).height() - margin); }, /** Configures form/step options for each workflow step */ @@ -201,7 +189,6 @@ var View = Backbone.View.extend({ this._renderParameters(); this._renderHistory(); this._renderUseCachedJob(); - this._renderResourceParameters(); _.each(this.steps, step => { self._renderStep(step); }); @@ -312,19 +299,6 @@ var View = Backbone.View.extend({ }); this._append(this.$steps, this.history_form.$el); }, - - /** Render Workflow Options */ - _renderResourceParameters: function() { - this.workflow_resource_parameters_form = null; - if(!_.isEmpty(this.model.get('workflow_resource_parameters'))){ - this.workflow_resource_parameters_form = new Form({ - cls : 'ui-portlet-narrow', - title : 'Workflow Resource Options', - inputs : this.model.get('workflow_resource_parameters') - }); - this._append( this.$steps, this.workflow_resource_parameters_form.$el ); - } - }, /** Render job caching option */ _renderUseCachedJob: function() { @@ -446,7 +420,6 @@ var View = Backbone.View.extend({ } self.forms[step.index] = form; self._append(self.$steps, form.$el); - self._refresh(); step.needs_refresh && self._refreshStep(step); form.portlet[!self.show_progress ? "enable" : "disable"](); self.show_progress && @@ -548,7 +521,6 @@ var View = Backbone.View.extend({ var job_def = { new_history_name: history_form_data["new_history|name"] ? history_form_data["new_history|name"] : null, history_id: !history_form_data["new_history|name"] ? this.model.get("history_id") : null, - resource_params: this.workflow_resource_parameters_form ? this.workflow_resource_parameters_form.data.create() : {}, replacement_params: this.wp_form ? this.wp_form.data.create() : {}, parameters: {}, // Tool form will submit flat maps for each parameter diff --git a/client/galaxy/scripts/mvc/ui/ui-select-genomespace.js b/client/galaxy/scripts/mvc/ui/ui-select-genomespace.js index 8e4ebaf7541..ea3786e2a82 100644 --- a/client/galaxy/scripts/mvc/ui/ui-select-genomespace.js +++ b/client/galaxy/scripts/mvc/ui/ui-select-genomespace.js @@ -26,16 +26,10 @@ var View = Backbone.View.extend({ // create genomespace filepath textbox this.filename_textbox = new Ui.Input(); - // create genomespace token textbox - this.token_textbox = new Ui.Input({ - type: "password" - }); - // create elements this.setElement(this._template(options)); this.$(".ui-gs-browse-button").append(this.browse_button.$el); this.$(".ui-gs-filename-textbox").append(this.filename_textbox.$el); - this.$(".ui-gs-token-textbox").append(this.token_textbox.$el); }, /** Browse GenomeSpace */ @@ -43,7 +37,7 @@ var View = Backbone.View.extend({ var self = this; GenomespaceBrowser.openFileBrowser({ successCallback: function(data) { - self.value(`${data.destination}^${data.token}`); + self.value(data.destination); } }); }, @@ -56,15 +50,11 @@ var View = Backbone.View.extend({ '' + '' + "
" + - '
' + - '
Token
' + - '' + - "
" + "
" ); }, - /** Return/Set currently selected genomespace filename/token */ + /** Return/Set currently selected genomespace filename */ value: function(new_value) { // check if new_value is defined if (new_value !== undefined) { @@ -76,15 +66,13 @@ var View = Backbone.View.extend({ // get value _getValue: function() { - return `${this.filename_textbox.value()}^${this.token_textbox.value()}`; + return this.filename_textbox.value(); }, // set value _setValue: function(new_value) { if (new_value) { - var values = new_value.split("^"); - this.filename_textbox.value(values[0]); - this.token_textbox.value(values[1]); + this.filename_textbox.value(new_value); } this.options.onchange && this.options.onchange(new_value); } diff --git a/client/galaxy/scripts/mvc/upload/collection/collection-view.js b/client/galaxy/scripts/mvc/upload/collection/collection-view.js index 817bcaef646..b8744dcbe69 100644 --- a/client/galaxy/scripts/mvc/upload/collection/collection-view.js +++ b/client/galaxy/scripts/mvc/upload/collection/collection-view.js @@ -116,7 +116,7 @@ export default Backbone.View.extend({ // file upload this.uploadbox = this.$(".upload-box").uploadbox({ - url: this.app.options.nginx_upload_path, + url: this.app.options.upload_path, announce: function(index, file) { self._eventAnnounce(index, file); }, diff --git a/client/galaxy/scripts/mvc/upload/composite/composite-view.js b/client/galaxy/scripts/mvc/upload/composite/composite-view.js index 928de5382ab..830dce6cb5f 100644 --- a/client/galaxy/scripts/mvc/upload/composite/composite-view.js +++ b/client/galaxy/scripts/mvc/upload/composite/composite-view.js @@ -133,7 +133,7 @@ export default Backbone.View.extend({ }); }); $.uploadpost({ - url: this.app.options.nginx_upload_path, + url: this.app.options.upload_path, data: this.app.toData(this.collection.filter()), success: function(message) { self._eventSuccess(message); diff --git a/client/galaxy/scripts/mvc/upload/default/default-row.js b/client/galaxy/scripts/mvc/upload/default/default-row.js index 4f6d5c5fe3b..054c2cba775 100644 --- a/client/galaxy/scripts/mvc/upload/default/default-row.js +++ b/client/galaxy/scripts/mvc/upload/default/default-row.js @@ -11,6 +11,7 @@ export default Backbone.View.extend({ init: "upload-icon-button fa fa-trash-o", queued: "upload-icon fa fa-spinner fa-spin", running: "upload-icon fa fa-spinner fa-spin", + warning: "upload-icon fa fa-spinner fa-spin", success: "upload-icon-button fa fa-check", error: "upload-icon-button fa fa-exclamation-triangle" }, @@ -183,7 +184,7 @@ export default Backbone.View.extend({ _refreshInfo: function() { var info = this.model.get("info"); if (info) { - this.$info_text.html(`Failed: ${info}`).show(); + this.$info_text.html(`Warning: ${info}`).show(); } else { this.$info_text.hide(); } @@ -213,13 +214,17 @@ export default Backbone.View.extend({ this.select_genome.disable(); this.select_extension.disable(); } + this.$info_progress.show(); + this.$el.removeClass().addClass("upload-row"); if (status == "success") { this.$el.addClass("success"); this.$percentage.html("100%"); - } - if (status == "error") { + } else if (status == "error") { this.$el.addClass("danger"); this.$info_progress.hide(); + } else if (status == "warning") { + this.$el.addClass("warning"); + this.$info_progress.hide(); } }, diff --git a/client/galaxy/scripts/mvc/upload/default/default-view.js b/client/galaxy/scripts/mvc/upload/default/default-view.js index 741b44a55ab..ede97d8b757 100644 --- a/client/galaxy/scripts/mvc/upload/default/default-view.js +++ b/client/galaxy/scripts/mvc/upload/default/default-view.js @@ -104,7 +104,7 @@ export default Backbone.View.extend({ // file upload this.uploadbox = this.$uploadbox.uploadbox({ - url: this.app.options.nginx_upload_path, + url: this.app.options.upload_path, announce: function(index, file) { self._eventAnnounce(index, file); }, @@ -120,6 +120,9 @@ export default Backbone.View.extend({ error: function(index, message) { self._eventError(index, message); }, + warning: function(index, message) { + self._eventWarning(index, message); + }, complete: function() { self._eventComplete(); }, @@ -247,7 +250,7 @@ export default Backbone.View.extend({ /** Progress */ _eventProgress: function(index, percentage) { var it = this.collection.get(index); - it.set("percentage", percentage); + it.set({ percentage: percentage, status: "running", info: "" }); this.ui_button.model.set("percentage", this._uploadPercentage(percentage, it.get("file_size"))); }, @@ -263,6 +266,12 @@ export default Backbone.View.extend({ Galaxy.currHistoryPanel.refreshContents(); }, + /** Warning */ + _eventWarning: function(index, message) { + var it = this.collection.get(index); + it.set({ status: "warning", info: message }); + }, + /** Error */ _eventError: function(index, message) { var it = this.collection.get(index); @@ -363,7 +372,10 @@ export default Backbone.View.extend({ this._uploadFtp(); // queue remaining files - this.uploadbox.start(); + this.uploadbox.start({ + id: Galaxy.user.id, + chunk_upload_size: this.app.options.chunk_upload_size + }); this.render(); } }, @@ -430,7 +442,7 @@ export default Backbone.View.extend({ if (list.length > 0) { $.uploadpost({ data: this.app.toData(list), - url: this.app.options.nginx_upload_path, + url: this.app.options.upload_path, success: function(message) { _.each(list, model => { self._eventSuccess(model.id); @@ -452,37 +464,37 @@ export default Backbone.View.extend({ /** Template */ _template: function() { - return ( - '
' + - '
' + - '
' + - "
" + - '
' + - '
Drop files here
' + - '' + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "
" + - '" + - '
' + - "
" - ); + return `
+
+
+
+
+
+ Drop files here +
+ + + + + + + + + + + + + +
+ +
+
`; } }); diff --git a/client/galaxy/scripts/mvc/upload/upload-view.js b/client/galaxy/scripts/mvc/upload/upload-view.js index 14272498fff..5f4684f53e7 100644 --- a/client/galaxy/scripts/mvc/upload/upload-view.js +++ b/client/galaxy/scripts/mvc/upload/upload-view.js @@ -9,7 +9,6 @@ import UploadViewComposite from "mvc/upload/composite/composite-view"; import UploadViewCollection from "mvc/upload/collection/collection-view"; export default Backbone.View.extend({ options: { - nginx_upload_path: "", ftp_upload_site: "n/a", default_genome: "?", default_extension: "auto", diff --git a/client/galaxy/scripts/utils/config.js b/client/galaxy/scripts/utils/config.js index b1a5457eb0e..e060743d978 100644 --- a/client/galaxy/scripts/utils/config.js +++ b/client/galaxy/scripts/utils/config.js @@ -1,6 +1,10 @@ import * as _ from "libs/underscore"; +import * as Backbone from "libs/backbone"; import util_mod from "viz/trackster/util"; -import config_mod from "utils/config"; + +/* global $ */ +/* global Galaxy */ + /** * A configuration setting. Currently key is used as id. */ @@ -307,23 +311,22 @@ var ConfigSettingCollectionView = Backbone.View.extend({ // No propagation to avoid triggering document click (and tip hiding) above. e.stopPropagation(); }); - - var // Icon for setting a new random color; behavior set below. - new_color_icon = $("") + // Icon for setting a new random color; behavior set below. + var new_color_icon = $("") .addClass("icon-button arrow-circle") .appendTo(container_div) .attr("title", "Set new random color") .tooltip(); - var // Color picker in tool tip style. - tip = $("
") + // Color picker in tool tip style. + var tip = $("
") .appendTo(container_div) .hide(); - var // Inner div for padding purposes - tip_inner = $("
").appendTo(tip); + // Inner div for padding purposes + var tip_inner = $("
").appendTo(tip); - var tip_arrow = $("
").appendTo(tip); + $("
").appendTo(tip); var farb_obj = $.farbtastic(tip_inner, { width: 100, diff --git a/client/galaxy/scripts/utils/uploadbox.js b/client/galaxy/scripts/utils/uploadbox.js index bbd2a1c0590..44faa6e7025 100644 --- a/client/galaxy/scripts/utils/uploadbox.js +++ b/client/galaxy/scripts/utils/uploadbox.js @@ -6,81 +6,29 @@ jQuery.event.props.push("dataTransfer"); /** - Posts file data to the API + xhr request helper */ - $.uploadpost = config => { - // parse options + var _uploadrequest = config => { var cnf = $.extend( - {}, { - data: {}, - success: function() {}, - error: function() {}, - progress: function() {}, - url: null, - maxfilesize: 2048, - error_filesize: "File exceeds 2GB. Please use a FTP client.", error_default: "Please make sure the file is available.", error_server: "Upload request failed.", - error_login: "Uploads require you to log in." + error_login: "Uploads require you to log in.", + error_retry: "Waiting for server to resume..." }, config ); - - // link data - var data = cnf.data; - - // check errors - if (data.error_message) { - cnf.error(data.error_message); - return; - } - - // construct form data - var form = new FormData(); - for (var key in data.payload) { - form.append(key, data.payload[key]); - } - - // add files to submission - var sizes = 0; - for (var key in data.files) { - var d = data.files[key]; - form.append(d.name, d.file, d.file.name); - sizes += d.file.size; - } - - // check file size, unless it's an ftp file - if (sizes > 1048576 * cnf.maxfilesize) { - cnf.error(cnf.error_filesize); - return; - } - - // prepare request + console.debug(cnf); var xhr = new XMLHttpRequest(); xhr.open("POST", cnf.url, true); - xhr.setRequestHeader("Accept", "application/json"); xhr.setRequestHeader("Cache-Control", "no-cache"); xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); - - // captures state changes + xhr.setRequestHeader("Accept", "application/json"); xhr.onreadystatechange = () => { - // check for request completed, server connection closed if (xhr.readyState == xhr.DONE) { - // parse response - var response = null; - var extra_info = ""; - if (xhr.responseText) { - try { - response = jQuery.parseJSON(xhr.responseText); - extra_info = response.err_msg; - } catch (e) { - response = xhr.responseText; - extra_info = response; - } - } - // pass any error to the error option - if (xhr.status < 200 || xhr.status > 299) { + if ([502, 0].indexOf(xhr.status) !== -1 && cnf.warning) { + cnf.warning(cnf.error_retry); + } else if (xhr.status < 200 || xhr.status > 299) { var text = xhr.statusText; if (xhr.status == 403) { text = cnf.error_login; @@ -89,27 +37,191 @@ } else if (!text) { text = cnf.error_default; } - cnf.error(`${text} (${xhr.status}). ${extra_info}`); + cnf.error(`${text} (${xhr.status})`); } else { + var response = null; + if (xhr.responseText) { + try { + response = jQuery.parseJSON(xhr.responseText); + } catch (e) { + response = xhr.responseText; + } + } cnf.success(response); } } }; + xhr.upload.addEventListener("progress", cnf.progress, false); + xhr.send(cnf.data); + }; - // prepare upload progress - xhr.upload.addEventListener( - "progress", - e => { + /** + Posts chunked files to the API. + */ + $.uploadchunk = function(config) { + // parse options + var cnf = $.extend( + {}, + { + data: {}, + success: () => {}, + error: () => {}, + warning: () => {}, + progress: () => {}, + attempts: 70000, + timeout: 5000, + url: null, + error_file: "File not provided.", + error_attempt: "Maximum number of attempts reached.", + error_tool: "Tool submission failed." + }, + config + ); + + // initial validation + var data = cnf.data; + if (data.error_message) { + cnf.error(data.error_message); + return; + } + var file_data = data.files && data.files[0]; + if (!file_data) { + cnf.error(cnf.error_file); + return; + } + var file = file_data.file; + var attempts = cnf.attempts; + var session_id = `${cnf.session.id}-${new Date().valueOf()}-${file.size}`; + var chunk_size = cnf.session.chunk_upload_size; + console.debug(`Starting chunked uploads [size=${chunk_size}].`); + + // chunk processing helper + function process(start) { + start = start || 0; + var slicer = file.mozSlice || file.webkitSlice || file.slice; + if (!slicer) { + cnf.error("Browser does not support chunked uploads."); + return; + } + var end = Math.min(start + chunk_size, file.size); + var size = file.size; + console.debug(`Submitting chunk at ${start} bytes...`); + var form = new FormData(); + form.append("session_id", session_id); + form.append("session_start", start); + form.append("session_chunk", slicer.bind(file)(start, end)); + _uploadrequest({ + url: `${Galaxy.root}api/uploads`, + data: form, + success: upload_response => { + var new_start = start + chunk_size; + if (new_start < size) { + attempts = cnf.attempts; + process(new_start); + } else { + console.debug("Upload completed."); + data.payload.inputs = JSON.parse(data.payload.inputs); + data.payload.inputs["files_0|file_data"] = { + session_id: session_id, + name: file.name + }; + data.payload.inputs = JSON.stringify(data.payload.inputs); + $.ajax({ + url: `${Galaxy.root}api/tools`, + method: "POST", + data: data.payload, + success: tool_response => { + cnf.success(tool_response); + }, + error: tool_response => { + var err_msg = + tool_response && tool_response.responseJSON && tool_response.responseJSON.err_msg; + cnf.error(err_msg || cnf.error_tool); + } + }); + } + }, + warning: upload_response => { + if (--attempts > 0) { + console.debug("Retrying last chunk..."); + cnf.warning(upload_response); + setTimeout(() => process(start), cnf.timeout); + } else { + console.debug(cnf.error_attempt); + cnf.error(cnf.error_attempt); + } + }, + error: upload_response => { + console.debug(upload_response); + cnf.error(upload_response); + }, + progress: e => { + if (e.lengthComputable) { + cnf.progress(Math.min(Math.round((start + e.loaded) * 100 / file.size), 100)); + } + } + }); + } + + // initiate processing queue for chunks + process(); + }; + + /** + Posts multiple files without chunking to the API. + */ + $.uploadpost = function(config) { + var cnf = $.extend( + {}, + { + data: {}, + success: () => {}, + error: () => {}, + progress: () => {}, + url: null, + maxfilesize: 1048576 * 2048, + error_filesize: "File exceeds 2GB. Please use a FTP client." + }, + config + ); + var data = cnf.data; + if (data.error_message) { + cnf.error(data.error_message); + return; + } + + // construct form data + var form = new FormData(); + for (let key in data.payload) { + form.append(key, data.payload[key]); + } + + // add files to submission + var sizes = 0; + for (let key in data.files) { + var d = data.files[key]; + form.append(d.name, d.file, d.file.name); + sizes += d.file.size; + } + + // check file size, unless it's an ftp file + if (sizes > cnf.maxfilesize) { + cnf.error(cnf.error_filesize); + return; + } + + // submit request + _uploadrequest({ + url: cnf.url, + data: form, + success: cnf.success, + error: cnf.error, + progress: e => { if (e.lengthComputable) { cnf.progress(Math.round(e.loaded * 100 / e.total)); } - }, - false - ); - - // send request - Galaxy.emit.debug("uploadbox::uploadpost()", "Posting following data.", cnf); - xhr.send(form); + } + }); }; /** @@ -121,9 +233,9 @@ var opts = $.extend( {}, { - ondragover: function() {}, - ondragleave: function() {}, - onchange: function() {}, + ondragover: () => {}, + ondragleave: () => {}, + onchange: () => {}, multiple: false }, options @@ -132,7 +244,7 @@ // append hidden upload field var $input = $(``); el.append( - $input.change(function(e) { + $input.change(e => { opts.onchange(e.target.files); $(this).val(""); }) @@ -157,7 +269,7 @@ // exports return { - dialog: function() { + dialog: () => { $input.trigger("click"); } }; @@ -171,16 +283,17 @@ var opts = $.extend( {}, { - dragover: function() {}, - dragleave: function() {}, - announce: function(d) {}, - initialize: function(d) {}, - progress: function(d, m) {}, - success: function(d, m) {}, - error: function(d, m) { + dragover: () => {}, + dragleave: () => {}, + announce: d => {}, + initialize: d => {}, + progress: (d, m) => {}, + success: (d, m) => {}, + warning: (d, m) => {}, + error: (d, m) => { alert(m); }, - complete: function() {} + complete: () => {} }, options ); @@ -188,6 +301,9 @@ // file queue var queue = {}; + // session options + var session = null; + // queue index/length counter var queue_index = 0; var queue_length = 0; @@ -199,7 +315,10 @@ // element var uploadinput = $(this).uploadinput({ multiple: true, - onchange: function(files) { + onchange: files => { + _.each(files, file => { + file.chunk_mode = true; + }); add(files); }, ondragover: options.ondragover, @@ -252,7 +371,7 @@ // get an identifier from the queue var index = -1; - for (var key in queue) { + for (let key in queue) { index = key; break; } @@ -264,18 +383,32 @@ remove(index); // create and submit data - $.uploadpost({ + var submitter = $.uploadpost; + if ( + file.chunk_mode && + session && + session.id && + session.chunk_upload_size && + session.chunk_upload_size > 0 + ) { + submitter = $.uploadchunk; + } + submitter({ url: opts.url, data: opts.initialize(index), - success: function(message) { + session: session, + success: message => { opts.success(index, message); process(); }, - error: function(message) { + warning: message => { + opts.warning(index, message); + }, + error: message => { opts.error(index, message); process(); }, - progress: function(percentage) { + progress: percentage => { opts.progress(index, percentage); } }); @@ -298,7 +431,8 @@ } // initiate upload process - function start() { + function start(_session) { + session = _session; if (!queue_running) { queue_running = true; process(); diff --git a/client/galaxy/scripts/viz/trackster.js b/client/galaxy/scripts/viz/trackster.js index 2e800c2c6e9..37b1dfccbbb 100644 --- a/client/galaxy/scripts/viz/trackster.js +++ b/client/galaxy/scripts/viz/trackster.js @@ -1,15 +1,15 @@ -import _l from "utils/localization"; /** * Top-level trackster code, used for creating/loading visualizations and user interface elements. */ -//import * as $ from 'jquery'; +import _l from "utils/localization"; import * as _ from "libs/underscore"; +import * as Backbone from "libs/backbone"; import tracks from "viz/trackster/tracks"; import visualization from "viz/visualization"; -import mod_icon_btn from "mvc/ui/icon-button"; +import IconButton from "mvc/ui/icon-button"; import query_string from "utils/query-string-parsing"; import GridView from "mvc/grid/grid-view"; -import mod_utils from "utils/utils"; +import Utils from "utils/utils"; import "libs/jquery/jquery.event.drag"; import "libs/jquery/jquery.event.hover"; import "libs/jquery/jquery.mousewheel"; @@ -20,44 +20,36 @@ import "libs/jquery/jquery.form"; import "libs/jquery/jquery.rating"; import "ui/editable-text"; -// trackster global variables -var ui = null; -var view = null; -var browser_router = null; - -/** - * Base Object/Model for inhertiance. - */ -var Base = function() { - if (this.initialize) { - this.initialize.apply(this, arguments); - } -}; -Base.extend = Backbone.Model.extend; +/* global Galaxy */ +/* global $ */ /** * User interface controls for trackster */ -var TracksterUI = Base.extend({ - initialize: function(baseURL) { - mod_utils.cssLoadFile("static/style/jquery.rating.css"); - mod_utils.cssLoadFile("static/style/autocomplete_tagging.css"); - mod_utils.cssLoadFile("static/style/jquery-ui/smoothness/jquery-ui.css"); - mod_utils.cssLoadFile("static/style/library.css"); - mod_utils.cssLoadFile("static/style/trackster.css"); +class TracksterUI extends Backbone.Model { + constructor(options) { + super(options); + } + + initialize(baseURL) { this.baseURL = baseURL; - }, + Utils.cssLoadFile("static/style/jquery.rating.css"); + Utils.cssLoadFile("static/style/autocomplete_tagging.css"); + Utils.cssLoadFile("static/style/jquery-ui/smoothness/jquery-ui.css"); + Utils.cssLoadFile("static/style/library.css"); + Utils.cssLoadFile("static/style/trackster.css"); + } /** * Save visualization, returning a Deferred object for the remote call to save. */ - save_viz: function() { + save_viz() { // show dialog Galaxy.modal.show({ title: "Saving...", body: "progress" }); // Save bookmarks. var bookmarks = []; - $(".bookmark").each(function() { + $(".bookmark").each(() => { bookmarks.push({ position: $(this) .children(".position") @@ -69,14 +61,16 @@ var TracksterUI = Base.extend({ }); // FIXME: give unique IDs to Drawables and save overview as ID. - var overview_track_name = view.overview_drawable ? view.overview_drawable.config.get_value("name") : null; + var overview_track_name = this.view.overview_drawable + ? this.view.overview_drawable.config.get_value("name") + : null; var viz_config = { - view: view.to_dict(), + view: this.view.to_dict(), viewport: { - chrom: view.chrom, - start: view.low, - end: view.high, + chrom: this.view.chrom, + start: this.view.low, + end: this.view.high, overview: overview_track_name }, bookmarks: bookmarks @@ -88,17 +82,17 @@ var TracksterUI = Base.extend({ type: "POST", dataType: "json", data: { - id: view.vis_id, - title: view.config.get_value("name"), - dbkey: view.dbkey, + id: this.view.vis_id, + title: this.view.config.get_value("name"), + dbkey: this.view.dbkey, type: "trackster", vis_json: JSON.stringify(viz_config) } }) .success(vis_info => { Galaxy.modal.hide(); - view.vis_id = vis_info.vis_id; - view.has_changes = false; + this.view.vis_id = vis_info.vis_id; + this.view.has_changes = false; // Needed to set URL when first saving a visualization. window.history.pushState({}, "", vis_info.url + window.location.hash); @@ -109,29 +103,27 @@ var TracksterUI = Base.extend({ title: _l("Could Not Save"), body: "Could not save visualization. Please try again later.", buttons: { - Cancel: function() { + Cancel: () => { Galaxy.modal.hide(); } } }); }); - }, + } /** * Create button menu */ - createButtonMenu: function() { - var self = this; - - var menu = mod_icon_btn.create_icon_buttons_menu( + createButtonMenu() { + var menu = IconButton.create_icon_buttons_menu( [ { icon_class: "plus-button", title: _l("Add tracks"), - on_click: function() { - visualization.select_datasets({ dbkey: view.dbkey }, new_tracks => { + on_click: () => { + visualization.select_datasets({ dbkey: this.view.dbkey }, new_tracks => { _.each(new_tracks, track => { - view.add_drawable(tracks.object_from_template(track, view, view)); + this.view.add_drawable(tracks.object_from_template(track, this.view, this.view)); }); }); } @@ -139,9 +131,9 @@ var TracksterUI = Base.extend({ { icon_class: "block--plus", title: _l("Add group"), - on_click: function() { - view.add_drawable( - new tracks.DrawableGroup(view, view, { + on_click: () => { + this.view.add_drawable( + new tracks.DrawableGroup(this.view, this.view, { name: "New Group" }) ); @@ -150,7 +142,7 @@ var TracksterUI = Base.extend({ { icon_class: "bookmarks", title: _l("Bookmarks"), - on_click: function() { + on_click: () => { // HACK -- use style to determine if panel is hidden and hide/show accordingly. window.force_right_panel($("div#right").css("right") == "0px" ? "hide" : "show"); } @@ -158,22 +150,22 @@ var TracksterUI = Base.extend({ { icon_class: "globe", title: _l("Circster"), - on_click: function() { - window.location = `${self.baseURL}visualization/circster?id=${view.vis_id}`; + on_click: () => { + window.location = `${this.baseURL}visualization/circster?id=${this.view.vis_id}`; } }, { icon_class: "disk--arrow", title: _l("Save"), - on_click: function() { - self.save_viz(); + on_click: () => { + this.save_viz(); } }, { icon_class: "cross-circle", title: _l("Close"), - on_click: function() { - self.handle_unsaved_changes(view); + on_click: () => { + this.handle_unsaved_changes(this.view); } } ], @@ -184,12 +176,12 @@ var TracksterUI = Base.extend({ this.buttonMenu = menu; return menu; - }, + } /** * Add bookmark. */ - add_bookmark: function(position, annotation, editable) { + add_bookmark(position, annotation, editable) { // Create HTML. var bookmarks_container = $("#right .unified-panel-body"); @@ -201,11 +193,12 @@ var TracksterUI = Base.extend({ .addClass("position") .appendTo(new_bookmark); - var position_link = $("
") + //position_link + $("") .text(position) .appendTo(position_div) .click(() => { - view.go_to(position); + this.view.go_to(position); return false; }); @@ -222,11 +215,12 @@ var TracksterUI = Base.extend({ // Remove bookmark. new_bookmark.slideUp("fast"); new_bookmark.remove(); - view.has_changes = true; + this.view.has_changes = true; return false; }); - var delete_icon = $("") + // delete_icon + $("") .addClass("icon-button delete") .appendTo(delete_icon_container); @@ -239,56 +233,50 @@ var TracksterUI = Base.extend({ .addClass("annotation"); } - view.has_changes = true; + this.view.has_changes = true; return new_bookmark; - }, + } /** * Create a complete Trackster visualization. Returns view. */ - create_visualization: function(view_config, viewport_config, drawables_config, bookmarks_config, editable) { + create_visualization(view_config, viewport_config, drawables_config, bookmarks_config, editable) { // Create view. - var self = this; + this.view = new tracks.TracksterView(_.extend(view_config, { header: false })); + this.view.editor = true; - view = new tracks.TracksterView(_.extend(view_config, { header: false })); - - view.editor = true; - $.when(view.load_chroms_deferred).then(chrom_info => { + $.when(this.view.load_chroms_deferred).then(chrom_info => { + var overview_drawable_name = null; // Viewport config. if (viewport_config) { var chrom = viewport_config.chrom; var start = viewport_config.start; var end = viewport_config.end; - var overview_drawable_name = viewport_config.overview; + overview_drawable_name = viewport_config.overview; if (chrom && start !== undefined && end) { - view.change_chrom(chrom, start, end); + this.view.change_chrom(chrom, start, end); } else { // No valid viewport, so use first chromosome. - view.change_chrom(chrom_info[0].chrom); + this.view.change_chrom(chrom_info[0].chrom); } } else { // No viewport, so use first chromosome. - view.change_chrom(chrom_info[0].chrom); + this.view.change_chrom(chrom_info[0].chrom); } // Add drawables to view. if (drawables_config) { // FIXME: can from_dict() be used to create view and add drawables? - var drawable_config; - - var drawable_type; - var drawable; - for (var i = 0; i < drawables_config.length; i++) { - view.add_drawable(tracks.object_from_template(drawables_config[i], view, view)); + for (let i = 0; i < drawables_config.length; i++) { + this.view.add_drawable(tracks.object_from_template(drawables_config[i], this.view, this.view)); } } // Set overview. - var overview_drawable; - for (var i = 0; i < view.drawables.length; i++) { - if (view.drawables[i].config.get_value("name") === overview_drawable_name) { - view.set_overview(view.drawables[i]); + for (let i = 0; i < this.view.drawables.length; i++) { + if (this.view.drawables[i].config.get_value("name") === overview_drawable_name) { + this.view.set_overview(this.view.drawables[i]); break; } } @@ -296,34 +284,35 @@ var TracksterUI = Base.extend({ // Load bookmarks. if (bookmarks_config) { var bookmark; - for (var i = 0; i < bookmarks_config.length; i++) { + for (let i = 0; i < bookmarks_config.length; i++) { bookmark = bookmarks_config[i]; - self.add_bookmark(bookmark["position"], bookmark["annotation"], editable); + this.add_bookmark(bookmark.position, bookmark.annotation, editable); } } // View has no changes as of yet. - view.has_changes = false; + this.view.has_changes = false; }); // Final initialization. - this.set_up_router({ view: view }); + this.set_up_router({ view: this.view }); - return view; - }, + // TODO: This is hopefully not necessary anymore, since we're using the instance view. Do it for compatibility for now. + return this.view; + } /** * Set up location router to use hashes as track browser locations. */ - set_up_router: function(options) { + set_up_router(options) { new visualization.TrackBrowserRouter(options); Backbone.history.start(); - }, + } /** * Set up keyboard navigation for a visualization. */ - init_keyboard_nav: function(view) { + init_keyboard_nav(view) { // Keyboard navigation. Scroll ~7% of height when scrolling up/down. $(document).keyup(e => { // Do not navigate if arrow keys used in input element. @@ -337,39 +326,38 @@ var TracksterUI = Base.extend({ view.move_fraction(0.25); break; case 38: - var change = Math.round(view.viewport_container.height() / 15.0); + // var change = Math.round(view.viewport_container.height() / 15.0); view.viewport_container.scrollTop(view.viewport_container.scrollTop() - 20); break; case 39: view.move_fraction(-0.25); break; case 40: - var change = Math.round(view.viewport_container.height() / 15.0); + // var change = Math.round(view.viewport_container.height() / 15.0); view.viewport_container.scrollTop(view.viewport_container.scrollTop() + 20); break; } }); - }, + } /** * Handle unsaved changes in visualization. */ - handle_unsaved_changes: function(view) { + handle_unsaved_changes(view) { if (view.has_changes) { - var self = this; Galaxy.modal.show({ title: _l("Close visualization"), body: "There are unsaved changes to your visualization which will be lost if you do not save them.", buttons: { - Cancel: function() { + Cancel: () => { Galaxy.modal.hide(); }, - "Leave without Saving": function() { + "Leave without Saving": () => { $(window).off("beforeunload"); window.location = `${Galaxy.root}visualization`; }, - Save: function() { - $.when(self.save_viz()).then(() => { + Save: () => { + $.when(this.save_viz()).then(() => { window.location = `${Galaxy.root}visualization`; }); } @@ -379,22 +367,25 @@ var TracksterUI = Base.extend({ window.location = `${Galaxy.root}visualization`; } } -}); +} -var TracksterView = Backbone.View.extend({ +class TracksterUIView extends Backbone.View { + constructor(options) { + super(options); + } // initalize trackster - initialize: function() { + initialize() { // load ui - ui = new TracksterUI(Galaxy.root); + this.ui = new TracksterUI(Galaxy.root); // create button menu - ui.createButtonMenu(); + this.ui.createButtonMenu(); // attach the button menu to the panel header and float it left - ui.buttonMenu.$el.attr("style", "float: right"); + this.ui.buttonMenu.$el.attr("style", "float: right"); // add to center panel - $("#center .unified-panel-header-inner").append(ui.buttonMenu.$el); + $("#center .unified-panel-header-inner").append(this.ui.buttonMenu.$el); // configure right panel $("#right .unified-panel-title").append("Bookmarks"); @@ -404,7 +395,7 @@ var TracksterView = Backbone.View.extend({ // resize view when showing/hiding right panel (bookmarks for now). $("#right-border").click(() => { - view.resize_window(); + this.ui.view.resize_window(); }); // hide right panel @@ -418,10 +409,9 @@ var TracksterView = Backbone.View.extend({ } else { this.view_new(); } - }, + } - choose_existing_or_new: function() { - var self = this; + choose_existing_or_new() { var dbkey = query_string.get("dbkey"); var listTracksParams = {}; @@ -442,21 +432,21 @@ var TracksterView = Backbone.View.extend({ body: `

    You can add this dataset as:
  • a new track to one of your existing, saved Trackster sessions if they share the genome build: ${dbkey || "Not available."}
  • or create a new session with this dataset as the only track

`, buttons: { - Cancel: function() { + Cancel: () => { window.location = `${Galaxy.root}visualizations/list`; }, - "View in saved visualization": function() { - self.view_in_saved(dataset_params); + "View in saved visualization": () => { + this.view_in_saved(dataset_params); }, - "View in new visualization": function() { - self.view_new(); + "View in new visualization": () => { + this.view_new(); } } }); - }, + } // view - view_in_saved: function(dataset_params) { + view_in_saved(dataset_params) { var tracks_grid = new GridView({ url_base: `${Galaxy.root}visualization/list_tracks`, embedded: true @@ -465,28 +455,28 @@ var TracksterView = Backbone.View.extend({ title: _l("Add Data to Saved Visualization"), body: tracks_grid.$el, buttons: { - Cancel: function() { + Cancel: () => { window.location = `${Galaxy.root}visualizations/list`; }, - "Add to visualization": function() { - $(parent.document) + "Add to visualization": () => { + $(window.parent.document) .find("input[name=id]:checked") - .each(function() { + .each(() => { dataset_params.id = $(this).val(); window.location = `${Galaxy.root}visualization/trackster?${$.param(dataset_params)}`; }); } } }); - }, + } // view - view_existing: function() { + view_existing() { // get config var viz_config = window.galaxy_config.app.viz_config; // view - view = ui.create_visualization( + this.ui.create_visualization( { container: $("#center .unified-panel-body"), name: viz_config.title, @@ -501,31 +491,28 @@ var TracksterView = Backbone.View.extend({ // initialize editor this.init_editor(); - }, + } // view - view_new: function() { - // reference this - var self = this; - + view_new() { // ajax $.ajax({ url: `${Galaxy.root}api/genomes?chrom_info=True`, data: {}, - error: function() { + error: () => { alert("Couldn't create new browser."); }, - success: function(response) { + success: response => { // show dialog Galaxy.modal.show({ title: _l("New Visualization"), - body: self.template_view_new(response), + body: this.template_view_new(response), buttons: { - Cancel: function() { + Cancel: () => { window.location = `${Galaxy.root}visualizations/list`; }, - Create: function() { - self.create_browser($("#new-title").val(), $("#new-dbkey").val()); + Create: () => { + this.create_browser($("#new-title").val(), $("#new-dbkey").val()); Galaxy.modal.hide(); } } @@ -548,10 +535,10 @@ var TracksterView = Backbone.View.extend({ $("#overlay").css("overflow", "auto"); } }); - }, + } // new browser form - template_view_new: function(response) { + template_view_new(response) { // start template var html = '
' + @@ -568,7 +555,7 @@ var TracksterView = Backbone.View.extend({ '`; + on_click_fn: track => { + var html = ` + Tool:${track.tool.get("name")}
+ Dataset:${track.config.get_value("name")}
+ Region(s): + + `; var cancel_fn = () => { Galaxy.modal.hide(); @@ -2454,22 +2461,22 @@ extend(Track.prototype, Drawable.prototype, { }; var ok_fn = () => { - var regions_to_use = $('select[name="regions"] option:selected').val(), - regions, - view_region = new visualization.GenomeRegion({ - chrom: view.chrom, - start: view.low, - end: view.high - }), - bookmarked_regions = _.map( - $(".bookmark"), - elt => - new visualization.GenomeRegion({ - from_str: $(elt) - .children(".position") - .text() - }) - ); + var regions_to_use = $('select[name="regions"] option:selected').val(); + var regions; + var view_region = new visualization.GenomeRegion({ + chrom: this.view.chrom, + start: this.view.low, + end: this.view.high + }); + var bookmarked_regions = _.map( + $(".bookmark"), + elt => + new visualization.GenomeRegion({ + from_str: $(elt) + .children(".position") + .text() + }) + ); // Get regions for visualization. if (regions_to_use === "cur") { @@ -2493,6 +2500,8 @@ extend(Track.prototype, Drawable.prototype, { })}`; }; + /* + * TODO: Re-enable this when functional. var check_enter_esc = e => { if ((e.keyCode || e.which) === 27) { // Escape key @@ -2502,6 +2511,7 @@ extend(Track.prototype, Drawable.prototype, { ok_fn(); } }; + */ // show dialog Galaxy.modal.show({ @@ -2732,7 +2742,7 @@ extend(Track.prototype, Drawable.prototype, { track.container_div.addClass("pending"); track.show_message(DATA_PENDING); //$("").attr("src", image_path + "/yui/rel_interstitial_loading.gif").appendTo(track.tiles_div); - setTimeout(() => { + window.setTimeout(() => { track.init(); }, track.data_query_wait); } else if (result === "data" || result.status === "data") { @@ -3040,7 +3050,7 @@ extend(TiledTrack.prototype, Drawable.prototype, Track.prototype, { var clear_after = options && options.clear_after; var low = this.view.low; var high = this.view.high; - var range = high - low; + //var range = high - low; var width = this.view.container.width(); var w_scale = this.view.resolution_px_b; var resolution = 1 / w_scale; @@ -3049,7 +3059,7 @@ extend(TiledTrack.prototype, Drawable.prototype, Track.prototype, { if (this.is_overview) { low = this.view.max_low; high = this.view.max_high; - w_scale = width / (view.max_high - view.max_low); + w_scale = width / (this.view.max_high - this.view.max_low); resolution = 1 / w_scale; } @@ -3105,17 +3115,16 @@ extend(TiledTrack.prototype, Drawable.prototype, Track.prototype, { } // When all tiles are drawn, call post-draw actions. - var track = this; $.when.apply($, tile_promises).then(() => { // Step (c) for (re)moving tiles when clear_after is true: - track.tiles_div.children(".remove").remove(); + this.tiles_div.children(".remove").remove(); // Only do postdraw actions for tiles; instances where tiles may not be drawn include: // (a) ReferenceTrack without sufficient resolution; // (b) data_fetch = false. tiles = _.filter(tiles, t => t !== null); if (tiles.length !== 0) { - track.postdraw_actions(tiles, width, w_scale, clear_after); + this.postdraw_actions(tiles, width, w_scale, clear_after); } }); }, @@ -3124,18 +3133,17 @@ extend(TiledTrack.prototype, Drawable.prototype, Track.prototype, { * Add a maximum/minimum label to track. */ _add_yaxis_label: function(type, on_change) { - var track = this; var css_class = type === "max" ? "top" : "bottom"; var text = type === "max" ? "max" : "min"; var pref_name = type === "max" ? "max_value" : "min_value"; var label = this.container_div.find(`.yaxislabel.${css_class}`); - var value = round(track.config.get_value(pref_name), 1); + var value = round(this.config.get_value(pref_name), 1); // Default action for on_change is to redraw track. on_change = on_change || (() => { - track.request_draw({ clear_tile_cache: true }); + this.request_draw({ clear_tile_cache: true }); }); if (label.length !== 0) { @@ -3149,7 +3157,7 @@ extend(TiledTrack.prototype, Drawable.prototype, Track.prototype, { num_cols: 12, on_finish: function(new_val) { $(".tooltip").remove(); - track.config.set_value(pref_name, round(new_val, 1)); + this.config.set_value(pref_name, round(new_val, 1)); on_change(); }, help_text: `Set ${text} value` @@ -3176,18 +3184,17 @@ extend(TiledTrack.prototype, Drawable.prototype, Track.prototype, { // Clear because this is set when drawing. this.max_height_px = 0; - var track = this; _.each(tiles, tile => { if (!(tile instanceof LineTrackTile)) { tile.html_elt.remove(); - track.draw_helper(tile.region, w_scale, { + this.draw_helper(tile.region, w_scale, { force: true, mode: "Coverage" }); } }); - track._add_yaxis_label("max"); + this._add_yaxis_label("max"); } else { // -- Drawing in non-Coverage mode. -- @@ -3247,19 +3254,16 @@ extend(TiledTrack.prototype, Drawable.prototype, Track.prototype, { var mode = options.mode || this.mode; var resolution = 1 / w_scale; - var // Useful vars. - track = this; - var drawables = this._get_drawables(); var key = this._gen_tile_cache_key(w_scale, region); var is_tile = o => o && "track" in o; // Check tile cache, if found show existing tile in correct position - var tile = force ? undefined : track.tile_cache.get_elt(key); + var tile = force ? undefined : this.tile_cache.get_elt(key); if (tile) { if (is_tile(tile)) { - track.show_tile(tile, w_scale); + this.show_tile(tile, w_scale); } return tile; } @@ -3277,16 +3281,16 @@ extend(TiledTrack.prototype, Drawable.prototype, Track.prototype, { // Map drawable object to data needed for drawing. var tile_data = _.map(drawables, ( d // Get the track data/promise. - ) => d.data_manager.get_data(region, data_mode, resolution, track.data_url_extra_params)); + ) => d.data_manager.get_data(region, data_mode, resolution, this.data_url_extra_params)); // Get reference data/promise. - if (view.reference_track) { + if (this.view.reference_track) { tile_data.push( - view.reference_track.data_manager.get_data( + this.view.reference_track.data_manager.get_data( region, mode, resolution, - view.reference_track.data_url_extra_params + this.view.reference_track.data_url_extra_params ) ); } @@ -3298,7 +3302,7 @@ extend(TiledTrack.prototype, Drawable.prototype, Track.prototype, { // When data is available, draw tile. // var tile_drawn = $.Deferred(); - track.tile_cache.set_elt(key, tile_drawn); + this.tile_cache.set_elt(key, tile_drawn); $.when.apply($, get_tile_data()).then(() => { var tile_data = get_tile_data(); var tracks_data = tile_data; @@ -3309,41 +3313,36 @@ extend(TiledTrack.prototype, Drawable.prototype, Track.prototype, { // Deferred, try again from the top. NOTE: this condition could (should?) be handled by the // GenomeDataManager in visualization module. if (_.find(tile_data, d => util.is_deferred(d))) { - track.tile_cache.set_elt(key, undefined); - $.when(track.draw_helper(region, w_scale, options)).then(tile => { + this.tile_cache.set_elt(key, undefined); + $.when(this.draw_helper(region, w_scale, options)).then(tile => { tile_drawn.resolve(tile); }); return; } // If sequence data is available, subset to get only data in region. - if (view.reference_track) { - seq_data = view.reference_track.data_manager.subset_entry(tile_data.pop(), region); + if (this.view.reference_track) { + seq_data = this.view.reference_track.data_manager.subset_entry(tile_data.pop(), region); } // Get drawing modes, heights for all tracks. var drawing_modes = []; - var drawing_heights = []; _.each(drawables, (d, i) => { - var mode = d.mode; var data = tracks_data[i]; - if (mode === "Auto") { - mode = d.get_mode(data); - d.update_auto_mode(mode); + if (d.mode === "Auto") { + d.mode = d.get_mode(data); + d.update_auto_mode(d.mode); } - drawing_modes.push(mode); - drawing_heights.push(d.get_canvas_height(data, mode, w_scale, width)); + drawing_modes.push(d.mode); + drawing_heights.push(d.get_canvas_height(data, d.mode, w_scale, width)); }); - var canvas = track.view.canvas_manager.new_canvas(); + var canvas = this.view.canvas_manager.new_canvas(); var tile_low = region.get("start"); var tile_high = region.get("end"); - var all_data_index = 0; - - var width = Math.ceil((tile_high - tile_low) * w_scale) + track.left_offset; - + var width = Math.ceil((tile_high - tile_low) * w_scale) + this.left_offset; var height = _.max(drawing_heights); var tile; @@ -3354,7 +3353,7 @@ extend(TiledTrack.prototype, Drawable.prototype, Track.prototype, { // Height is specified in options or is the height found above. canvas.height = options.height || height; var ctx = canvas.getContext("2d"); - ctx.translate(track.left_offset, 0); + ctx.translate(this.left_offset, 0); if (drawables.length > 1) { ctx.globalAlpha = 0.5; ctx.globalCompositeOperation = "source-over"; @@ -3365,8 +3364,8 @@ extend(TiledTrack.prototype, Drawable.prototype, Track.prototype, { // Don't cache, show if no tile. if (tile !== undefined) { - track.tile_cache.set_elt(key, tile); - track.show_tile(tile, w_scale); + this.tile_cache.set_elt(key, tile); + this.show_tile(tile, w_scale); } tile_drawn.resolve(tile); @@ -3428,7 +3427,6 @@ extend(TiledTrack.prototype, Drawable.prototype, Track.prototype, { * an existing tile rather than reshowing it. */ show_tile: function(tile, w_scale) { - var track = this; var tile_element = tile.html_elt; // -- Show/move tile element. -- @@ -3785,7 +3783,7 @@ extend(CompositeTrack.prototype, TiledTrack.prototype, { } // Replace track with group. - var index = this.container.replace_drawable(this, group, true); + this.container.replace_drawable(this, group, true); group.request_draw({ clear_tile_cache: true }); }, @@ -3819,7 +3817,6 @@ extend(CompositeTrack.prototype, TiledTrack.prototype, { * Update minimum, maximum for component tracks. */ update_all_min_max: function() { - var track = this; var min_value = this.config.get_value("min_value"); var max_value = this.config.get_value("max_value"); _.each(this.drawables, d => { @@ -4127,7 +4124,8 @@ extend(DiagonalHeatmapTrack.prototype, Drawable.prototype, TiledTrack.prototype, hda_ldda: track.dataset.get("hda_ldda") }, result => { - var data = result.data; + // What does this do? Is it meant to be attached to some higher scope state object? + // var data = result.data; } ); }, @@ -4163,10 +4161,9 @@ var FeatureTrack = function(view, container, obj_dict) { this.slotters = {}; this.start_end_dct = {}; this.left_offset = 200; - - // this.painter = painters.LinkedFeaturePainter; this.set_painter_from_config(); }; + extend(FeatureTrack.prototype, Drawable.prototype, TiledTrack.prototype, { display_modes: ["Auto", "Coverage", "Dense", "Squish", "Pack"], @@ -4258,9 +4255,6 @@ extend(FeatureTrack.prototype, Drawable.prototype, TiledTrack.prototype, { postdraw_actions: function(tiles, width, w_scale, clear_after) { TiledTrack.prototype.postdraw_actions.call(this, tiles, width, w_scale, clear_after); - var track = this; - var i; - var line_track_tiles = _.filter(tiles, t => t instanceof LineTrackTile); // @@ -4277,7 +4271,6 @@ extend(FeatureTrack.prototype, Drawable.prototype, TiledTrack.prototype, { }); // Draw incomplete features on each tile. - var self = this; _.each(tiles, tile => { // Remove features already drawn on tile originally. var tile_incomplete_features = _.omit( @@ -4296,16 +4289,16 @@ extend(FeatureTrack.prototype, Drawable.prototype, TiledTrack.prototype, { data: _.values(tile_incomplete_features) }; - var new_canvas = self.view.canvas_manager.new_canvas(); + var new_canvas = this.view.canvas_manager.new_canvas(); var new_canvas_ctx = new_canvas.getContext("2d"); new_canvas.height = Math.max( tile.canvas.height, - self.get_canvas_height(features, tile.mode, tile.w_scale, 100) + this.get_canvas_height(features, tile.mode, tile.w_scale, 100) ); new_canvas.width = tile.canvas.width; new_canvas_ctx.drawImage(tile.canvas, 0, 0); - new_canvas_ctx.translate(track.left_offset, 0); - var new_tile = self.draw_tile( + new_canvas_ctx.translate(this.left_offset, 0); + var new_tile = this.draw_tile( features, new_canvas_ctx, tile.mode, @@ -4348,8 +4341,8 @@ extend(FeatureTrack.prototype, Drawable.prototype, TiledTrack.prototype, { // // Update filtering UI. - if (track.filters_manager) { - var filters = track.filters_manager.filters; + if (this.filters_manager) { + var filters = this.filters_manager.filters; var f; for (f = 0; f < filters.length; f++) { filters[f].update_ui_elt(); @@ -4361,7 +4354,7 @@ extend(FeatureTrack.prototype, Drawable.prototype, TiledTrack.prototype, { var example_feature; var filter; - for (i = 0; i < tiles.length; i++) { + for (let i = 0; i < tiles.length; i++) { if (tiles[i].data.length) { example_feature = tiles[i].data[0]; for (f = 0; f < filters.length; f++) { @@ -4375,12 +4368,12 @@ extend(FeatureTrack.prototype, Drawable.prototype, TiledTrack.prototype, { } // If filter availability changed, hide filter div if necessary and update menu. - if (track.filters_available !== filters_available) { - track.filters_available = filters_available; - if (!track.filters_available) { - track.filters_manager.hide(); + if (this.filters_available !== filters_available) { + this.filters_available = filters_available; + if (!this.filters_available) { + this.filters_manager.hide(); } - track.update_icons(); + this.update_icons(); } } @@ -4389,7 +4382,7 @@ extend(FeatureTrack.prototype, Drawable.prototype, TiledTrack.prototype, { // if (tiles[0] instanceof FeatureTrackTile) { var all_slotted = true; - for (i = 0; i < tiles.length; i++) { + for (let i = 0; i < tiles.length; i++) { if (!tiles[i].all_slotted) { all_slotted = false; break; @@ -4557,6 +4550,7 @@ extend(FeatureTrack.prototype, Drawable.prototype, TiledTrack.prototype, { ); var feature_mapper = null; + var incomplete_features = null; ctx.fillStyle = this.config.get_value("block_color"); ctx.font = ctx.canvas.manager.default_font; diff --git a/client/galaxy/scripts/viz/visualization.js b/client/galaxy/scripts/viz/visualization.js index 7ba8c775ad3..b3140462fe8 100644 --- a/client/galaxy/scripts/viz/visualization.js +++ b/client/galaxy/scripts/viz/visualization.js @@ -5,7 +5,6 @@ import util_mod from "viz/trackster/util"; import config_mod from "utils/config"; import GridView from "mvc/grid/grid-view"; import Tabs from "mvc/ui/ui-tabs"; -import Ui from "mvc/ui/ui-misc"; /** * Mixin for returning custom JSON representation from toJSON. Class attribute to_json_keys defines a set of attributes * to include in the representation; to_json_mappers defines mappers for returned objects. @@ -1109,12 +1108,11 @@ var TrackBrowserRouter = Backbone.Router.extend({ // Can't put regular expression in routes dictionary. // NOTE: parentheses are used to denote parameters returned to callback. this.route(/([\w]+)$/, "change_location"); - this.route(/([\w\+]+\:[\d,]+-[\d,]+)$/, "change_location"); + this.route(/([\w+]+:[\d,]+-[\d,]+)$/, "change_location"); // Handle navigate events from view. - var self = this; - self.view.on("navigate", new_loc => { - self.navigate(new_loc); + this.view.on("navigate", new_loc => { + this.navigate(new_loc); }); }, diff --git a/client/galaxy/style/less/ui.less b/client/galaxy/style/less/ui.less index 4e3d3eaa1bd..1e48cea2a26 100644 --- a/client/galaxy/style/less/ui.less +++ b/client/galaxy/style/less/ui.less @@ -486,6 +486,9 @@ .ui-form-composite { max-width: 900px; + height: 100%; + flex-direction: column; + display: flex; .ui-form-header { &:extend(h3); margin-top: 0px; @@ -906,10 +909,6 @@ float: right; width: ~'calc(100% - 76px)'; } - .ui-gs-token-textbox { - float: right; - width: ~'calc(100% - 76px)'; - } .ui-gs-browse-button { float: left; .ui-button-icon { @@ -917,15 +916,9 @@ margin-right: 5px; } } - .ui-gs-token-label { - margin-top: 5px; - margin-left: 32px; - margin-right: 5px; - float: left; - } - .ui-gs-token-field { - clear: both; - padding-top: 5px; + .ui-gs-browse-field { + height: @input-height-base; + line-height: @line-height-base; } } diff --git a/config/datatypes_conf.xml.sample b/config/datatypes_conf.xml.sample index a6b87e2c82c..4da482becf8 100644 --- a/config/datatypes_conf.xml.sample +++ b/config/datatypes_conf.xml.sample @@ -21,7 +21,7 @@ - + @@ -299,7 +299,7 @@ - + diff --git a/config/galaxy.yml.sample b/config/galaxy.yml.sample index aa412bf3d72..6951fc2762e 100644 --- a/config/galaxy.yml.sample +++ b/config/galaxy.yml.sample @@ -812,6 +812,11 @@ galaxy: # documentation for the corresponding nginx configuration. #nginx_upload_job_files_path: false + # Galaxy can upload user files in chunks without using nginx. Enable + # the chunk uploader by specifying a chunk size larger than 0. The + # chunk size is specified in bytes (default: 100MB). + #chunk_upload_size: 104857600 + # Have Galaxy manage dynamic proxy component for routing requests to # other services based on Galaxy's session cookie. It will attempt to # do this by default though you do need to install node+npm and do an @@ -1167,6 +1172,15 @@ galaxy: # Set maximum size of ngrams #tool_ngram_maxsize: 4 + # Set tool test data directory. The test framework sets this value to + # 'test-data,https://github.com/galaxyproject/galaxy-test-data.git' + # which will cause Galaxy to clone down extra test data on the fly for + # certain tools distributed with Galaxy but this is likely not + # appropriate for production systems. Instead one can simply clone + # that repository directly and specify a path here instead of a Git + # HTTP repository. + #tool_test_data_directories: test-data + # Galaxy encodes various internal values when these values will be # output in some format (for example, in a URL or cookie). You should # set a key to be used by the algorithm that encodes and decodes these @@ -1621,23 +1635,6 @@ galaxy: # processors, memory and walltime. #job_resource_params_file: config/job_resource_params_conf.xml - # Similar to the above parameter, workflows can describe parameters - # used to influence scheduling of jobs within the workflow. This - # requires both a description of the fields available (which defaults - # to the definitions in job_resource_params_file if not set). - #workflow_resource_params_file: config/workflow_resource_params_conf.xml - - # This parameter describes how to map users and workflows to a set of - # workflow resource parameter to present (typically input IDs from - # workflow_resource_params_file). If this this is a function reference - # it will be passed various inputs (workflow model object and user) - # and it should produce a list of input IDs. If it is a path it is - # expected to an XML or YAML file describing how to map group names to - # parameter descriptions (additional types of mappings via these files - # could be implemented but haven't yet - for instance using workflow - # tags to do the mapping). - #workflow_resource_params_mapper: config/workflow_resource_mapper_conf.yml - # If using job concurrency limits (configured in job_config_file), # several extra database queries must be performed to determine the # number of jobs a user has dispatched to a given destination. By diff --git a/config/job_metrics_conf.xml.sample b/config/job_metrics_conf.xml.sample index 1fdfb3849a9..5824cb4b39c 100644 --- a/config/job_metrics_conf.xml.sample +++ b/config/job_metrics_conf.xml.sample @@ -15,7 +15,7 @@ - + - + + + + # # # - dataset_collectors = map(dataset_collector, output_collection_def.dataset_collector_descriptions) - output_name = output_collection_def.name - filenames = self.find_files(output_name, collection, dataset_collectors) + if name is None: + name = "unnamed output" element_datasets = [] for filename, discovered_file in filenames.items(): @@ -241,6 +406,8 @@ class JobContext(object): # Create new primary dataset name = fields_match.name or designation + link_data = discovered_file.match.link_data + dataset = self.create_dataset( ext=ext, designation=designation, @@ -248,14 +415,15 @@ class JobContext(object): dbkey=dbkey, name=name, filename=filename, - metadata_source_name=output_collection_def.metadata_source, + metadata_source_name=metadata_source_name, + link_data=link_data, ) log.debug( "(%s) Created dynamic collection dataset for path [%s] with element identifier [%s] for output [%s] %s", self.job.id, filename, designation, - output_collection_def.name, + name, create_dataset_timer, ) element_datasets.append((element_identifiers, dataset)) @@ -270,7 +438,7 @@ class JobContext(object): log.debug( "(%s) Add dynamic collection datsets to history for output [%s] %s", self.job.id, - output_collection_def.name, + name, add_datasets_timer, ) @@ -300,12 +468,24 @@ class JobContext(object): dbkey, name, filename, - metadata_source_name, + metadata_source_name=None, + info=None, + library_folder=None, + link_data=False, + primary_data=None, ): app = self.app sa_session = self.sa_session - primary_data = _new_hda(app, sa_session, ext, designation, visible, dbkey, self.permissions) + if primary_data is None: + if not library_folder: + primary_data = _new_hda(app, sa_session, ext, designation, visible, dbkey, self.permissions) + else: + primary_data = _new_ldda(self.work_context, name, ext, visible, dbkey, library_folder) + else: + primary_data.extension = ext + primary_data.visible = visible + primary_data.dbkey = dbkey # Copy metadata from one of the inputs if requested. metadata_source = None @@ -314,7 +494,11 @@ class JobContext(object): sa_session.flush() # Move data from temp location to dataset location - app.object_store.update_from_file(primary_data.dataset, file_name=filename, create=True) + if not link_data: + app.object_store.update_from_file(primary_data.dataset, file_name=filename, create=True) + else: + primary_data.link_to(filename) + primary_data.set_size() # If match specified a name use otherwise generate one from # designation. @@ -325,6 +509,9 @@ class JobContext(object): else: primary_data.init_meta() + if info is not None: + primary_data.info = info + primary_data.set_meta() primary_data.set_peek() @@ -491,6 +678,20 @@ def discover_files(output_name, tool_provided_metadata, extra_file_collectors, j yield DiscoveredFile(match.path, collector, match) +def discovered_file_for_unnamed_output(dataset, job_working_directory, parent_identifiers=[]): + extra_file_collector = DEFAULT_TOOL_PROVIDED_DATASET_COLLECTOR + target_directory = discover_target_directory(extra_file_collector.directory, job_working_directory) + filename = dataset["filename"] + # handle link_data_only here, verify filename is in directory if not linking... + if not dataset.get("link_data_only"): + path = os.path.join(target_directory, filename) + if not util.in_directory(path, target_directory): + raise Exception("Problem with tool configuration, attempting to pull in datasets from outside working directory.") + else: + path = filename + return DiscoveredFile(path, extra_file_collector, JsonCollectedDatasetMatch(dataset, extra_file_collector, filename, path=path, parent_identifiers=parent_identifiers)) + + def discover_target_directory(dir_name, job_working_directory): if dir_name: directory = os.path.join(job_working_directory, dir_name) @@ -605,11 +806,12 @@ def _compose(f, g): class JsonCollectedDatasetMatch(object): - def __init__(self, as_dict, collector, filename, path=None): + def __init__(self, as_dict, collector, filename, path=None, parent_identifiers=[]): self.as_dict = as_dict self.collector = collector self.filename = filename self.path = path + self._parent_identifiers = parent_identifiers @property def designation(self): @@ -627,7 +829,7 @@ class JsonCollectedDatasetMatch(object): @property def element_identifiers(self): - return self.raw_element_identifiers or [self.designation] + return self._parent_identifiers + (self.raw_element_identifiers or [self.designation]) @property def raw_element_identifiers(self): @@ -664,6 +866,14 @@ class JsonCollectedDatasetMatch(object): except KeyError: return self.collector.default_visible + @property + def link_data(self): + return bool(self.as_dict.get("link_data_only", False)) + + @property + def object_id(self): + return self.as_dict.get("object_id", None) + class RegexCollectedDatasetMatch(JsonCollectedDatasetMatch): @@ -676,6 +886,42 @@ class RegexCollectedDatasetMatch(JsonCollectedDatasetMatch): UNSET = object() +def _new_ldda( + trans, + name, + ext, + visible, + dbkey, + library_folder, +): + ld = trans.app.model.LibraryDataset(folder=library_folder, name=name) + trans.sa_session.add(ld) + trans.sa_session.flush() + trans.app.security_agent.copy_library_permissions(trans, library_folder, ld) + + ldda = trans.app.model.LibraryDatasetDatasetAssociation(name=name, + extension=ext, + dbkey=dbkey, + library_dataset=ld, + user=trans.user, + create_dataset=True, + sa_session=trans.sa_session) + trans.sa_session.add(ldda) + ldda.state = ldda.states.OK + # Permissions must be the same on the LibraryDatasetDatasetAssociation and the associated LibraryDataset + trans.app.security_agent.copy_library_permissions(trans, ld, ldda) + # Copy the current user's DefaultUserPermissions to the new LibraryDatasetDatasetAssociation.dataset + trans.app.security_agent.set_all_dataset_permissions(ldda.dataset, trans.app.security_agent.user_get_default_permissions(trans.user)) + library_folder.add_library_dataset(ld, genome_build=dbkey) + trans.sa_session.add(library_folder) + trans.sa_session.flush() + + ld.library_dataset_dataset_association_id = ldda.id + trans.sa_session.add(ld) + trans.sa_session.flush() + return ldda + + def _new_hda( app, sa_session, @@ -702,3 +948,4 @@ def _new_hda( DEFAULT_DATASET_COLLECTOR = DatasetCollector(DEFAULT_DATASET_COLLECTOR_DESCRIPTION) +DEFAULT_TOOL_PROVIDED_DATASET_COLLECTOR = ToolMetadataDatasetCollector(ToolProvidedMetadataDatasetCollection()) diff --git a/lib/galaxy/tools/parser/interface.py b/lib/galaxy/tools/parser/interface.py index 626a8f2114c..0ca8f33187e 100644 --- a/lib/galaxy/tools/parser/interface.py +++ b/lib/galaxy/tools/parser/interface.py @@ -357,26 +357,77 @@ class ToolStdioExitCode(object): class TestCollectionDef(object): - # TODO: do not require XML directly here. - def __init__(self, elem, parse_param_elem): - self.elements = [] + def __init__(self, attrib, name, collection_type, elements): + self.attrib = attrib + self.collection_type = collection_type + self.elements = elements + self.name = name + + @staticmethod + def from_xml(elem, parse_param_elem): + elements = [] attrib = dict(elem.attrib) - self.collection_type = attrib["type"] - self.name = attrib.get("name", "Unnamed Collection") + collection_type = attrib["type"] + name = attrib.get("name", "Unnamed Collection") for element in elem.findall("element"): element_attrib = dict(element.attrib) element_identifier = element_attrib["name"] nested_collection_elem = element.find("collection") if nested_collection_elem is not None: - self.elements.append((element_identifier, TestCollectionDef(nested_collection_elem, parse_param_elem))) + element_definition = TestCollectionDef.from_xml(nested_collection_elem, parse_param_elem) else: - self.elements.append((element_identifier, parse_param_elem(element))) + element_definition = parse_param_elem(element) + elements.append({"element_identifier": element_identifier, "element_definition": element_definition}) + + return TestCollectionDef( + attrib=attrib, + collection_type=collection_type, + elements=elements, + name=name, + ) + + def to_dict(self): + def element_to_dict(element_dict): + element_identifier, element_def = element_dict["element_identifier"], element_dict["element_definition"] + if isinstance(element_def, TestCollectionDef): + element_def = element_def.to_dict() + return { + "element_identifier": element_identifier, + "element_definition": element_def, + } + + return { + "model_class": "TestCollectionDef", + "attributes": self.attrib, + "collection_type": self.collection_type, + "elements": map(element_to_dict, self.elements or []), + "name": self.name, + } + + @staticmethod + def from_dict(as_dict): + assert as_dict["model_class"] == "TestCollectionDef" + + def element_from_dict(element_dict): + if "element_definition" not in element_dict: + raise Exception("Invalid element_dict %s" % element_dict) + element_def = element_dict["element_definition"] + if element_def.get("model_class", None) == "TestCollectionDef": + element_def = TestCollectionDef.from_dict(element_def) + return {"element_identifier": element_dict["element_identifier"], "element_definition": element_def} + + return TestCollectionDef( + attrib=as_dict["attributes"], + name=as_dict["name"], + elements=list(map(element_from_dict, as_dict["elements"] or [])), + collection_type=as_dict["collection_type"], + ) def collect_inputs(self): inputs = [] for element in self.elements: - value = element[1] + value = element["element_definition"] if isinstance(value, TestCollectionDef): inputs.extend(value.collect_inputs()) else: @@ -393,3 +444,18 @@ class TestCollectionOutputDef(object): self.count = int(count) if count is not None else None self.attrib = attrib self.element_tests = element_tests + + @staticmethod + def from_dict(as_dict): + return TestCollectionOutputDef( + name=as_dict["name"], + attrib=as_dict["attributes"], + element_tests=as_dict["element_tests"], + ) + + def to_dict(self): + return dict( + name=self.name, + attributes=self.attrib, + element_tests=self.element_tests + ) diff --git a/lib/galaxy/tools/parser/xml.py b/lib/galaxy/tools/parser/xml.py index 6edce860323..fde2b532935 100644 --- a/lib/galaxy/tools/parser/xml.py +++ b/lib/galaxy/tools/parser/xml.py @@ -391,8 +391,6 @@ class XmlToolSource(ToolSource): for i, test_elem in enumerate(tests_elem.findall("test")): tests.append(_test_elem_to_dict(test_elem, i)) - _copy_to_dict_if_present(tests_elem, rval, ["interactor"]) - return rval def parse_profile(self): @@ -418,7 +416,7 @@ def _test_elem_to_dict(test_elem, i): expect_failure=string_as_bool(test_elem.get("expect_failure", False)), maxseconds=test_elem.get("maxseconds", None), ) - _copy_to_dict_if_present(test_elem, rval, ["interactor", "num_outputs"]) + _copy_to_dict_if_present(test_elem, rval, ["num_outputs"]) return rval @@ -431,7 +429,7 @@ def __parse_output_elems(test_elem): outputs = [] for output_elem in test_elem.findall("output"): name, file, attributes = __parse_output_elem(output_elem) - outputs.append((name, file, attributes)) + outputs.append({"name": name, "value": file, "attributes": attributes}) return outputs @@ -464,7 +462,7 @@ def __parse_output_collection_elem(output_collection_elem): if name is None: raise Exception("Test output collection does not have a 'name'") element_tests = __parse_element_tests(output_collection_elem) - return TestCollectionOutputDef(name, attrib, element_tests) + return TestCollectionOutputDef(name, attrib, element_tests).to_dict() def __parse_element_tests(parent_element): @@ -565,7 +563,12 @@ def __parse_extra_files_elem(extra): assert extra_type == 'directory' or extra_name is not None, \ 'extra_files type (%s) requires a name attribute' % extra_type extra_value, extra_attributes = __parse_test_attributes(extra, attrib) - return extra_type, extra_value, extra_name, extra_attributes + return { + "value": extra_value, + "name": extra_name, + "type": extra_type, + "attributes": extra_attributes + } def __expand_input_elems(root_elem, prefix=""): @@ -628,8 +631,8 @@ def _copy_to_dict_if_present(elem, rval, attributes): def __parse_inputs_elems(test_elem, i): raw_inputs = [] for param_elem in test_elem.findall("param"): - name, value, attrib = __parse_param_elem(param_elem, i) - raw_inputs.append((name, value, attrib)) + raw_inputs.append(__parse_param_elem(param_elem, i)) + return raw_inputs @@ -641,40 +644,43 @@ def __parse_param_elem(param_elem, i=0): value = attrib['value'] else: value = None - attrib['children'] = param_elem - if attrib['children'] is not None: + children_elem = param_elem + if children_elem is not None: # At this time, we can assume having children only # occurs on DataToolParameter test items but this could # change and would cause the below parsing to change # based upon differences in children items - attrib['metadata'] = [] + attrib['metadata'] = {} attrib['composite_data'] = [] attrib['edit_attributes'] = [] # Composite datasets need to be renamed uniquely composite_data_name = None - for child in attrib['children']: + for child in children_elem: if child.tag == 'composite_data': - attrib['composite_data'].append(child) + file_name = child.get("value") + attrib['composite_data'].append(file_name) if composite_data_name is None: # Generate a unique name; each test uses a # fresh history. composite_data_name = '_COMPOSITE_RENAMED_t%d_%s' \ % (i, uuid.uuid1().hex) elif child.tag == 'metadata': - attrib['metadata'].append(child) - elif child.tag == 'metadata': - attrib['metadata'].append(child) + attrib['metadata'][child.get("name")] = child.get("value") elif child.tag == 'edit_attributes': attrib['edit_attributes'].append(child) elif child.tag == 'collection': - attrib['collection'] = TestCollectionDef(child, __parse_param_elem) + attrib['collection'] = TestCollectionDef.from_xml(child, __parse_param_elem) if composite_data_name: # Composite datasets need implicit renaming; # inserted at front of list so explicit declarations # take precedence attrib['edit_attributes'].insert(0, {'type': 'name', 'value': composite_data_name}) name = attrib.pop('name') - return (name, value, attrib) + return { + "name": name, + "value": value, + "attributes": attrib + } class StdioParser(object): diff --git a/lib/galaxy/tools/parser/yaml.py b/lib/galaxy/tools/parser/yaml.py index 71a593e3cf6..4a8d796e273 100644 --- a/lib/galaxy/tools/parser/yaml.py +++ b/lib/galaxy/tools/parser/yaml.py @@ -188,7 +188,7 @@ def _parse_test(i, test_dict): if _is_dict(inputs): new_inputs = [] for key, value in inputs.items(): - new_inputs.append((key, value, {})) + new_inputs.append({"name": key, "value": value, "attributes": {}}) test_dict["inputs"] = new_inputs outputs = test_dict["outputs"] @@ -202,7 +202,11 @@ def _parse_test(i, test_dict): else: file = value attributes = {} - new_outputs.append((key, file, attributes)) + new_outputs.append({ + "name": key, + "value": file, + "attributes": attributes + }) else: for output in outputs: name = output["name"] @@ -211,7 +215,7 @@ def _parse_test(i, test_dict): new_outputs.append((name, value, attributes)) for output in new_outputs: - attributes = output[2] + attributes = output["attributes"] defaults = { 'compare': 'diff', 'lines_diff': 0, diff --git a/lib/galaxy/tools/special_tools.py b/lib/galaxy/tools/special_tools.py index 953e69dee64..129b7064a94 100644 --- a/lib/galaxy/tools/special_tools.py +++ b/lib/galaxy/tools/special_tools.py @@ -4,6 +4,7 @@ log = logging.getLogger(__name__) SPECIAL_TOOLS = { "history export": "galaxy/tools/imp_exp/exp_history_to_archive.xml", "history import": "galaxy/tools/imp_exp/imp_history_from_archive.xml", + "data fetch": "galaxy/tools/data_fetch.xml", } diff --git a/lib/galaxy/tools/test.py b/lib/galaxy/tools/test.py index 82ab9061dee..a26f26fea9d 100644 --- a/lib/galaxy/tools/test.py +++ b/lib/galaxy/tools/test.py @@ -6,6 +6,7 @@ from six import string_types import galaxy.tools.parameters.basic import galaxy.tools.parameters.grouping +from galaxy.tools.verify.interactor import ToolTestDescription from galaxy.util import string_as_bool try: @@ -16,11 +17,6 @@ except ImportError: log = logging.getLogger(__name__) -DEFAULT_FTYPE = 'auto' -DEFAULT_DBKEY = 'hg17' -DEFAULT_INTERACTOR = "api" # Default mechanism test code uses for interacting with Galaxy instance. -DEFAULT_MAX_SECS = None - @nottest def parse_tests(tool, tests_source): @@ -28,207 +24,128 @@ def parse_tests(tool, tests_source): Build ToolTestBuilder objects for each "" elements and return default interactor (if any). """ - default_interactor = os.environ.get('GALAXY_TEST_DEFAULT_INTERACTOR', DEFAULT_INTERACTOR) - tests_dict = tests_source.parse_tests_to_dict() - tests_default_interactor = tests_dict.get('interactor', default_interactor) + raw_tests_dict = tests_source.parse_tests_to_dict() tests = [] - for i, test_dict in enumerate(tests_dict.get('tests', [])): - test = ToolTestBuilder(tool, test_dict, i, default_interactor=tests_default_interactor) + for i, raw_test_dict in enumerate(raw_tests_dict.get('tests', [])): + test = description_from_tool_object(tool, i, raw_test_dict) tests.append(test) return tests -class ToolTestBuilder(object): +def description_from_tool_object(tool, test_index, raw_test_dict): + required_files = [] + + num_outputs = raw_test_dict.get('expect_num_outputs', None) + if num_outputs: + num_outputs = int(num_outputs) + + try: + processed_inputs = _process_raw_inputs(tool, tool.inputs, raw_test_dict["inputs"], required_files) + processed_test_dict = { + "inputs": processed_inputs, + "outputs": raw_test_dict["outputs"], + "output_collections": raw_test_dict["output_collections"], + "num_outputs": num_outputs, + "command_line": raw_test_dict.get("command", None), + "stdout": raw_test_dict.get("stdout", None), + "stderr": raw_test_dict.get("stderr", None), + "expect_exit_code": raw_test_dict.get("expect_exit_code", None), + "expect_failure": raw_test_dict.get("expect_failure", False), + "required_files": required_files, + "tool_id": tool.id, + "test_index": test_index, + "error": False, + } + except Exception as e: + log.exception("Failed to load tool test number [%d] for %s" % (test_index, tool.id)) + processed_test_dict = { + "tool_id": tool.id, + "test_index": test_index, + "inputs": {}, + "error": True, + "exception": str(e), + } + + return ToolTestDescription(processed_test_dict) + + +def _process_raw_inputs(tool, tool_inputs, raw_inputs, required_files, parent_context=None): """ - Encapsulates information about a tool test, and allows creation of a - dynamic TestCase class (the unittest framework is very class oriented, - doing dynamic tests in this way allows better integration) + Recursively expand flat list of inputs into "tree" form of flat list + (| using to nest to new levels) structure and expand dataset + information as proceeding to populate self.required_files. """ - - def __init__(self, tool, test_dict, i, default_interactor): - name = test_dict.get('name', 'Test-%d' % (i + 1)) - maxseconds = test_dict.get('maxseconds', DEFAULT_MAX_SECS) - if maxseconds is not None: - maxseconds = int(maxseconds) - - self.tool = tool - self.name = name - self.maxseconds = maxseconds - self.required_files = [] - self.inputs = {} - self.outputs = [] - # By default do not making assertions on number of outputs - but to - # test filtering allow explicitly state number of outputs. - self.num_outputs = None - self.error = False - self.exception = None - - self.__handle_test_dict(test_dict, i, default_interactor) - - def test_data(self): - """ - Iterator over metadata representing the required files for upload. - """ - return test_data_iter(self.required_files) - - def __matching_case_for_value(self, cond, declared_value): - test_param = cond.test_param - if isinstance(test_param, galaxy.tools.parameters.basic.BooleanToolParameter): - if declared_value is None: - # No explicit value for param in test case, determine from default - query_value = test_param.checked - else: - query_value = _process_bool_param_value(test_param, declared_value) - - def matches_declared_value(case_value): - return _process_bool_param_value(test_param, case_value) == query_value - elif isinstance(test_param, galaxy.tools.parameters.basic.SelectToolParameter): - if declared_value is not None: - # Test case supplied explicit value to check against. - - def matches_declared_value(case_value): - return case_value == declared_value - elif test_param.static_options: - # No explicit value in test case, not much to do if options are dynamic but - # if static options are available can find the one specified as default or - # fallback on top most option (like GUI). - for (name, value, selected) in test_param.static_options: - if selected: - default_option = name - else: - first_option = test_param.static_options[0] - first_option_value = first_option[1] - default_option = first_option_value - - def matches_declared_value(case_value): - return case_value == default_option - else: - # No explicit value for this param and cannot determine a - # default - give up. Previously this would just result in a key - # error exception. - msg = "Failed to find test parameter value specification required for conditional %s" % cond.name - raise Exception(msg) - - # Check the tool's defined cases against predicate to determine - # selected or default. - for i, case in enumerate(cond.cases): - if matches_declared_value(case.value): - return case - else: - msg_template = "%s - Failed to find case matching value (%s) for test parameter specification for conditional %s. Remainder of test behavior is unspecified." - msg = msg_template % (self.tool.id, declared_value, cond.name) - log.info(msg) - - def __split_if_str(self, value): - split = isinstance(value, string_types) - if split: - value = value.split(",") - return value - - def __handle_test_dict(self, test_dict, i, default_interactor): - try: - # Mechanism test code uses for interacting with Galaxy instance, - # until 'api' is the default switch this to API to use its new - # features. Once 'api' is the default set to 'twill' to use legacy - # features or workarounds. - self.interactor = test_dict.get('interactor', default_interactor) - - self.inputs = self.__process_raw_inputs(self.tool.inputs, test_dict["inputs"]) - self.outputs = test_dict["outputs"] - self.output_collections = test_dict["output_collections"] - num_outputs = test_dict.get('expect_num_outputs', None) - if num_outputs: - num_outputs = int(num_outputs) - self.num_outputs = num_outputs - self.command_line = test_dict.get("command", None) - self.stdout = test_dict.get("stdout", None) - self.stderr = test_dict.get("stderr", None) - self.expect_exit_code = test_dict.get("expect_exit_code", None) - self.expect_failure = test_dict.get("expect_failure", False) - self.md5 = test_dict.get("md5", None) - except Exception as e: - self.inputs = {} - self.error = True - self.exception = e - - def __process_raw_inputs(self, tool_inputs, raw_inputs, parent_context=None): - """ - Recursively expand flat list of inputs into "tree" form of flat list - (| using to nest to new levels) structure and expand dataset - information as proceeding to populate self.required_files. - """ - parent_context = parent_context or RootParamContext() - expanded_inputs = {} - for key, value in tool_inputs.items(): - if isinstance(value, galaxy.tools.parameters.grouping.Conditional): - cond_context = ParamContext(name=value.name, parent_context=parent_context) - case_context = ParamContext(name=value.test_param.name, parent_context=cond_context) - raw_input = case_context.extract_value(raw_inputs) - case_value = raw_input[1] if raw_input else None - case = self.__matching_case_for_value(value, case_value) - if case: - for input_name, input_value in case.inputs.items(): - case_inputs = self.__process_raw_inputs({input_name: input_value}, raw_inputs, parent_context=cond_context) - expanded_inputs.update(case_inputs) - if not value.type == "text": - expanded_case_value = self.__split_if_str(case.value) - if case_value is not None: - # A bit tricky here - we are growing inputs with value - # that may be implicit (i.e. not defined by user just - # a default defined in tool). So we do not want to grow - # expanded_inputs and risk repeat block viewing this - # as a new instance with value defined and hence enter - # an infinite loop - hence the "case_value is not None" - # check. - processed_value = _process_simple_value(value.test_param, expanded_case_value) - expanded_inputs[case_context.for_state()] = processed_value - elif isinstance(value, galaxy.tools.parameters.grouping.Section): - context = ParamContext(name=value.name, parent_context=parent_context) + parent_context = parent_context or RootParamContext() + expanded_inputs = {} + for key, value in tool_inputs.items(): + if isinstance(value, galaxy.tools.parameters.grouping.Conditional): + cond_context = ParamContext(name=value.name, parent_context=parent_context) + case_context = ParamContext(name=value.test_param.name, parent_context=cond_context) + raw_input_dict = case_context.extract_value(raw_inputs) + case_value = raw_input_dict["value"] if raw_input_dict else None + case = _matching_case_for_value(tool, value, case_value) + if case: + for input_name, input_value in case.inputs.items(): + case_inputs = _process_raw_inputs(tool, {input_name: input_value}, raw_inputs, required_files, parent_context=cond_context) + expanded_inputs.update(case_inputs) + if not value.type == "text": + expanded_case_value = _split_if_str(case.value) + if case_value is not None: + # A bit tricky here - we are growing inputs with value + # that may be implicit (i.e. not defined by user just + # a default defined in tool). So we do not want to grow + # expanded_inputs and risk repeat block viewing this + # as a new instance with value defined and hence enter + # an infinite loop - hence the "case_value is not None" + # check. + processed_value = _process_simple_value(value.test_param, expanded_case_value) + expanded_inputs[case_context.for_state()] = processed_value + elif isinstance(value, galaxy.tools.parameters.grouping.Section): + context = ParamContext(name=value.name, parent_context=parent_context) + for r_name, r_value in value.inputs.items(): + expanded_input = _process_raw_inputs(tool, {context.for_state(): r_value}, raw_inputs, required_files, parent_context=context) + if expanded_input: + expanded_inputs.update(expanded_input) + elif isinstance(value, galaxy.tools.parameters.grouping.Repeat): + repeat_index = 0 + while True: + context = ParamContext(name=value.name, index=repeat_index, parent_context=parent_context) + updated = False for r_name, r_value in value.inputs.items(): - expanded_input = self.__process_raw_inputs({context.for_state(): r_value}, raw_inputs, parent_context=context) + expanded_input = _process_raw_inputs(tool, {context.for_state(): r_value}, raw_inputs, required_files, parent_context=context) if expanded_input: expanded_inputs.update(expanded_input) - elif isinstance(value, galaxy.tools.parameters.grouping.Repeat): - repeat_index = 0 - while True: - context = ParamContext(name=value.name, index=repeat_index, parent_context=parent_context) - updated = False - for r_name, r_value in value.inputs.items(): - expanded_input = self.__process_raw_inputs({context.for_state(): r_value}, raw_inputs, parent_context=context) - if expanded_input: - expanded_inputs.update(expanded_input) - updated = True - if not updated: - break - repeat_index += 1 - else: - context = ParamContext(name=value.name, parent_context=parent_context) - raw_input = context.extract_value(raw_inputs) - if raw_input: - (name, param_value, param_extra) = raw_input - if not value.type == "text": - param_value = self.__split_if_str(param_value) - if isinstance(value, galaxy.tools.parameters.basic.DataToolParameter): - if not isinstance(param_value, list): - param_value = [param_value] - map(lambda v: self.__add_uploaded_dataset(context.for_state(), v, param_extra, value), param_value) - processed_value = param_value - elif isinstance(value, galaxy.tools.parameters.basic.DataCollectionToolParameter): - assert 'collection' in param_extra - collection_def = param_extra['collection'] - for (name, value, extra) in collection_def.collect_inputs(): - require_file(name, value, extra, self.required_files) - processed_value = collection_def - else: - processed_value = _process_simple_value(value, param_value) - expanded_inputs[context.for_state()] = processed_value - return expanded_inputs - - def __add_uploaded_dataset(self, name, value, extra, input_parameter): - if value is None: - assert input_parameter.optional, '%s is not optional. You must provide a valid filename.' % name - return value - return require_file(name, value, extra, self.required_files) + updated = True + if not updated: + break + repeat_index += 1 + else: + context = ParamContext(name=value.name, parent_context=parent_context) + raw_input_dict = context.extract_value(raw_inputs) + if raw_input_dict: + name = raw_input_dict["name"] + param_value = raw_input_dict["value"] + param_extra = raw_input_dict["attributes"] + if not value.type == "text": + param_value = _split_if_str(param_value) + if isinstance(value, galaxy.tools.parameters.basic.DataToolParameter): + if not isinstance(param_value, list): + param_value = [param_value] + map(lambda v: _add_uploaded_dataset(context.for_state(), v, param_extra, value, required_files), param_value) + processed_value = param_value + elif isinstance(value, galaxy.tools.parameters.basic.DataCollectionToolParameter): + assert 'collection' in param_extra + collection_def = param_extra['collection'] + for input_dict in collection_def.collect_inputs(): + name = input_dict["name"] + value = input_dict["value"] + attributes = input_dict["attributes"] + require_file(name, value, attributes, required_files) + processed_value = collection_def + else: + processed_value = _process_simple_value(value, param_value) + expanded_inputs[context.for_state()] = processed_value + return expanded_inputs def _process_simple_value(param, param_value): @@ -266,6 +183,69 @@ def _process_simple_value(param, param_value): return processed_value +def _matching_case_for_value(tool, cond, declared_value): + test_param = cond.test_param + if isinstance(test_param, galaxy.tools.parameters.basic.BooleanToolParameter): + if declared_value is None: + # No explicit value for param in test case, determine from default + query_value = test_param.checked + else: + query_value = _process_bool_param_value(test_param, declared_value) + + def matches_declared_value(case_value): + return _process_bool_param_value(test_param, case_value) == query_value + elif isinstance(test_param, galaxy.tools.parameters.basic.SelectToolParameter): + if declared_value is not None: + # Test case supplied explicit value to check against. + + def matches_declared_value(case_value): + return case_value == declared_value + elif test_param.static_options: + # No explicit value in test case, not much to do if options are dynamic but + # if static options are available can find the one specified as default or + # fallback on top most option (like GUI). + for (name, value, selected) in test_param.static_options: + if selected: + default_option = name + else: + first_option = test_param.static_options[0] + first_option_value = first_option[1] + default_option = first_option_value + + def matches_declared_value(case_value): + return case_value == default_option + else: + # No explicit value for this param and cannot determine a + # default - give up. Previously this would just result in a key + # error exception. + msg = "Failed to find test parameter value specification required for conditional %s" % cond.name + raise Exception(msg) + + # Check the tool's defined cases against predicate to determine + # selected or default. + for i, case in enumerate(cond.cases): + if matches_declared_value(case.value): + return case + else: + msg_template = "%s - Failed to find case matching value (%s) for test parameter specification for conditional %s. Remainder of test behavior is unspecified." + msg = msg_template % (tool.id, declared_value, cond.name) + log.info(msg) + + +def _add_uploaded_dataset(name, value, extra, input_parameter, required_files): + if value is None: + assert input_parameter.optional, '%s is not optional. You must provide a valid filename.' % name + return value + return require_file(name, value, extra, required_files) + + +def _split_if_str(value): + split = isinstance(value, string_types) + if split: + value = value.split(",") + return value + + def _process_bool_param_value(param, param_value): assert isinstance(param, galaxy.tools.parameters.basic.BooleanToolParameter) was_list = False @@ -281,30 +261,6 @@ def _process_bool_param_value(param, param_value): return [processed_value] if was_list else processed_value -@nottest -def test_data_iter(required_files): - for fname, extra in required_files: - data_dict = dict( - fname=fname, - metadata=extra.get('metadata', []), - composite_data=extra.get('composite_data', []), - ftype=extra.get('ftype', DEFAULT_FTYPE), - dbkey=extra.get('dbkey', DEFAULT_DBKEY), - ) - edit_attributes = extra.get('edit_attributes', []) - - # currently only renaming is supported - for edit_att in edit_attributes: - if edit_att.get('type', None) == 'name': - new_name = edit_att.get('value', None) - assert new_name, 'You must supply the new dataset name as the value tag of the edit_attributes tag' - data_dict['name'] = new_name - else: - raise Exception('edit_attributes type (%s) is unimplemented' % edit_att.get('type', None)) - - yield data_dict - - def require_file(name, value, extra, required_files): if (value, extra) not in required_files: required_files.append((value, extra)) # these files will be uploaded @@ -359,13 +315,13 @@ class ParamContext(object): def __raw_param_found(self, param_name, raw_inputs): index = None - for i, raw_input in enumerate(raw_inputs): - if raw_input[0] == param_name: + for i, raw_input_dict in enumerate(raw_inputs): + if raw_input_dict["name"] == param_name: index = i if index is not None: - raw_input = raw_inputs[index] + raw_input_dict = raw_inputs[index] del raw_inputs[index] - return raw_input + return raw_input_dict else: return None diff --git a/lib/galaxy/tools/toolbox/filters/__init__.py b/lib/galaxy/tools/toolbox/filters/__init__.py index 8e9a1677f11..2184e37e846 100644 --- a/lib/galaxy/tools/toolbox/filters/__init__.py +++ b/lib/galaxy/tools/toolbox/filters/__init__.py @@ -34,7 +34,7 @@ class FilterFactory(object): filters = deepcopy(self.default_filters) if trans.user: for name, value in trans.user.preferences.items(): - if value.strip(): + if value and value.strip(): user_filters = listify(value, do_strip=True) category = '' if name == 'toolbox_tool_filters': diff --git a/lib/galaxy/tools/verify/asserts/__init__.py b/lib/galaxy/tools/verify/asserts/__init__.py index 626a4dd7309..59b90d32479 100644 --- a/lib/galaxy/tools/verify/asserts/__init__.py +++ b/lib/galaxy/tools/verify/asserts/__init__.py @@ -2,6 +2,8 @@ import inspect import logging import sys +from galaxy.util import unicodify + log = logging.getLogger(__name__) assertion_module_names = ['text', 'tabular', 'xml'] @@ -66,7 +68,11 @@ def verify_assertion(data, assertion_description): # - .) if "output" in assert_function_args: - args["output"] = data + # This was read in as bytes for checksum and such, but all current + # assertions expect text data. If binary assertions are added at + # some point, just checkout for "output_bytes" for instance and pass + # data in unchanged. + args["output"] = unicodify(data) if "verify_assertions_function" in assert_function_args: args["verify_assertions_function"] = verify_assertions diff --git a/lib/galaxy/tools/verify/interactor.py b/lib/galaxy/tools/verify/interactor.py new file mode 100644 index 00000000000..5623a7faa44 --- /dev/null +++ b/lib/galaxy/tools/verify/interactor.py @@ -0,0 +1,982 @@ +from __future__ import absolute_import +from __future__ import print_function + +import os +import re +import sys +import time +from json import dumps +from logging import getLogger + +try: + from nose.tools import nottest +except ImportError: + def nottest(x): + return x +try: + import requests +except ImportError: + requests = None +from six import StringIO, text_type + +from galaxy import util +from galaxy.tools.parser.interface import TestCollectionDef, TestCollectionOutputDef +from galaxy.util.bunch import Bunch +from galaxy.util.odict import odict +from .asserts import verify_assertions +from ..verify import verify + +log = getLogger(__name__) + +# Off by default because it can pound the database pretty heavily +# and result in sqlite errors on larger tests or larger numbers of +# tests. +VERBOSE_ERRORS = util.asbool(os.environ.get("GALAXY_TEST_VERBOSE_ERRORS", False)) +UPLOAD_ASYNC = util.asbool(os.environ.get("GALAXY_TEST_UPLOAD_ASYNC", True)) +ERROR_MESSAGE_DATASET_SEP = "--------------------------------------" +DEFAULT_TOOL_TEST_WAIT = os.environ.get("GALAXY_TEST_DEFAULT_WAIT", 86400) + +DEFAULT_FTYPE = 'auto' +# This following default dbkey was traditionally hg17 before Galaxy 18.05, +# restore this behavior by setting GALAXY_TEST_DEFAULT_DBKEY to hg17. +DEFAULT_DBKEY = os.environ.get("GALAXY_TEST_DEFAULT_DBKEY", "?") +DEFAULT_MAX_SECS = DEFAULT_TOOL_TEST_WAIT + + +def stage_data_in_history(galaxy_interactor, tool_id, all_test_data, history): + # Upload any needed files + upload_waits = [] + + assert tool_id + + if UPLOAD_ASYNC: + for test_data in all_test_data: + upload_waits.append(galaxy_interactor.stage_data_async(test_data, history, tool_id)) + for upload_wait in upload_waits: + upload_wait() + else: + for test_data in all_test_data: + upload_wait = galaxy_interactor.stage_data_async(test_data, history, tool_id) + upload_wait() + + +class GalaxyInteractorApi(object): + + def __init__(self, **kwds): + self.api_url = "%s/api" % kwds["galaxy_url"].rstrip("/") + self.master_api_key = kwds["master_api_key"] + self.api_key = self.__get_user_key(kwds.get("api_key"), kwds.get("master_api_key"), test_user=kwds.get("test_user")) + self.keep_outputs_dir = kwds["keep_outputs_dir"] + + self.uploads = {} + + def __get_user_key(self, user_key, admin_key, test_user=None): + if not test_user: + test_user = "test@bx.psu.edu" + if user_key: + return user_key + test_user = self.ensure_user_with_email(test_user) + return self._post("users/%s/api_key" % test_user['id'], key=admin_key).json() + + # def get_tools(self): + # response = self._get("tools?in_panel=false") + # assert response.status_code == 200, "Non 200 response from tool index API. [%s]" % response.content + # return response.json() + + def get_tests_summary(self): + response = self._get("tools/tests_summary") + assert response.status_code == 200, "Non 200 response from tool tests available API. [%s]" % response.content + return response.json() + + def get_tool_tests(self, tool_id, tool_version=None): + url = "tools/%s/test_data" % tool_id + if tool_version is not None: + url += "?tool_version=%s" % tool_version + response = self._get(url) + assert response.status_code == 200, "Non 200 response from tool test API. [%s]" % response.content + return response.json() + + def verify_output(self, history_id, jobs, output_data, output_testdef, tool_id, maxseconds): + outfile = output_testdef.outfile + attributes = output_testdef.attributes + name = output_testdef.name + self.wait_for_jobs(history_id, jobs, maxseconds) + hid = self.__output_id(output_data) + # TODO: Twill version verifys dataset is 'ok' in here. + self.verify_output_dataset(history_id=history_id, hda_id=hid, outfile=outfile, attributes=attributes, tool_id=tool_id) + + primary_datasets = attributes.get('primary_datasets', {}) + if primary_datasets: + job_id = self._dataset_provenance(history_id, hid)["job_id"] + outputs = self._get("jobs/%s/outputs" % (job_id)).json() + + for designation, (primary_outfile, primary_attributes) in primary_datasets.items(): + primary_output = None + for output in outputs: + if output["name"] == '__new_primary_file_%s|%s__' % (name, designation): + primary_output = output + break + + if not primary_output: + msg_template = "Failed to find primary dataset with designation [%s] for output with name [%s]" + msg_args = (designation, name) + raise Exception(msg_template % msg_args) + + primary_hda_id = primary_output["dataset"]["id"] + self.verify_output_dataset(history_id, primary_hda_id, primary_outfile, primary_attributes, tool_id=tool_id) + + def wait_for_jobs(self, history_id, jobs, maxseconds): + for job in jobs: + self.wait_for_job(job['id'], history_id, maxseconds) + + def verify_output_dataset(self, history_id, hda_id, outfile, attributes, tool_id): + fetcher = self.__dataset_fetcher(history_id) + test_data_path_builder = self.__test_data_path_builder(tool_id) + verify_hid( + outfile, + hda_id=hda_id, + attributes=attributes, + dataset_fetcher=fetcher, + test_data_path_builder=test_data_path_builder, + keep_outputs_dir=self.keep_outputs_dir + ) + self._verify_metadata(history_id, hda_id, attributes) + + def _verify_metadata(self, history_id, hid, attributes): + """Check dataset metadata. + + ftype on output maps to `file_ext` on the hda's API description, `name`, `info`, + `dbkey` and `tags` all map to the API description directly. Other metadata attributes + are assumed to be datatype-specific and mapped with a prefix of `metadata_`. + """ + metadata = attributes.get('metadata', {}).copy() + for key, value in metadata.copy().items(): + if key not in ['name', 'info', 'tags']: + new_key = "metadata_%s" % key + metadata[new_key] = metadata[key] + del metadata[key] + elif key == "info": + metadata["misc_info"] = metadata["info"] + del metadata["info"] + expected_file_type = attributes.get('ftype', None) + if expected_file_type: + metadata["file_ext"] = expected_file_type + + if metadata: + time.sleep(5) + dataset = self._get("histories/%s/contents/%s" % (history_id, hid)).json() + for key, value in metadata.items(): + try: + dataset_value = dataset.get(key, None) + + def compare(val, expected): + if text_type(val) != text_type(expected): + msg = "Dataset metadata verification for [%s] failed, expected [%s] but found [%s]. Dataset API value was [%s]." + msg_params = (key, value, dataset_value, dataset) + msg = msg % msg_params + raise Exception(msg) + + if isinstance(dataset_value, list): + value = text_type(value).split(",") + if len(value) != len(dataset_value): + msg = "Dataset metadata verification for [%s] failed, expected [%s] but found [%s], lists differ in length. Dataset API value was [%s]." + msg_params = (key, value, dataset_value, dataset) + msg = msg % msg_params + raise Exception(msg) + for val, expected in zip(dataset_value, value): + compare(val, expected) + else: + compare(dataset_value, value) + except KeyError: + msg = "Failed to verify dataset metadata, metadata key [%s] was not found." % key + raise Exception(msg) + + def wait_for_job(self, job_id, history_id, maxseconds): + self.wait_for(lambda: not self.__job_ready(job_id, history_id), maxseconds=maxseconds) + + def wait_for(self, func, **kwd): + sleep_amount = 0.2 + slept = 0 + walltime_exceeded = kwd.get("maxseconds", DEFAULT_TOOL_TEST_WAIT) + + while slept <= walltime_exceeded: + result = func() + if result: + time.sleep(sleep_amount) + slept += sleep_amount + sleep_amount *= 2 + else: + return + + message = 'Tool test run exceeded walltime [total %s, max %s], terminating.' % (slept, walltime_exceeded) + log.info(message) + raise AssertionError(message) + + def get_job_stdio(self, job_id): + job_stdio = self.__get_job_stdio(job_id).json() + return job_stdio + + def __get_job(self, job_id): + return self._get('jobs/%s' % job_id) + + def __get_job_stdio(self, job_id): + return self._get('jobs/%s?full=true' % job_id) + + def new_history(self): + history_json = self._post("histories", {"name": "test_history"}).json() + return history_json['id'] + + @nottest + def test_data_path(self, tool_id, filename): + return self._get("tools/%s/test_data_path?filename=%s" % (tool_id, filename)).json() + + def __output_id(self, output_data): + # Allow data structure coming out of tools API - {id: , output_name: , etc...} + # or simple id as comes out of workflow API. + try: + output_id = output_data.get('id') + except AttributeError: + output_id = output_data + return output_id + + def stage_data_async(self, test_data, history_id, tool_id, async=True): + fname = test_data['fname'] + tool_input = { + "file_type": test_data['ftype'], + "dbkey": test_data['dbkey'], + } + metadata = test_data.get("metadata", {}) + if not hasattr(metadata, "items"): + raise Exception("Invalid metadata description found for input [%s] - [%s]" % (fname, metadata)) + for name, value in test_data.get('metadata', {}).items(): + tool_input["files_metadata|%s" % name] = value + + composite_data = test_data['composite_data'] + if composite_data: + files = {} + for i, file_name in enumerate(composite_data): + file_name = self.test_data_path(tool_id, file_name) + files["files_%s|file_data" % i] = open(file_name, 'rb') + tool_input.update({ + "files_%d|type" % i: "upload_dataset", + }) + name = test_data['name'] + else: + file_name = self.test_data_path(tool_id, fname) + name = test_data.get('name', None) + if not name: + name = os.path.basename(file_name) + + tool_input.update({ + "files_0|NAME": name, + "files_0|type": "upload_dataset", + }) + # TODO: Option to upload by path since we are getting the paths from Galaxy now it makes more + # sense to move this there. + files = { + "files_0|file_data": open(file_name, 'rb') + } + submit_response_object = self.__submit_tool(history_id, "upload1", tool_input, extra_data={"type": "upload_dataset"}, files=files) + if submit_response_object.status_code != 200: + raise Exception("Request to upload dataset failed [%s]" % submit_response_object.content) + submit_response = submit_response_object.json() + assert "outputs" in submit_response, "Invalid response from server [%s], expecteding outputs in response." % submit_response + outputs = submit_response["outputs"] + assert len(outputs) > 0, "Invalid response from server [%s], expecting an output dataset." % submit_response + dataset = outputs[0] + hid = dataset['id'] + self.uploads[os.path.basename(fname)] = self.uploads[fname] = self.uploads[name] = {"src": "hda", "id": hid} + assert "jobs" in submit_response, "Invalid response from server [%s], expecting jobs in response." % submit_response + jobs = submit_response["jobs"] + assert len(jobs) > 0, "Invalid response from server [%s], expecting a job." % submit_response + return lambda: self.wait_for_job(jobs[0]["id"], history_id, DEFAULT_TOOL_TEST_WAIT) + + def run_tool(self, testdef, history_id, resource_parameters={}): + # We need to handle the case where we've uploaded a valid compressed file since the upload + # tool will have uncompressed it on the fly. + + inputs_tree = testdef.inputs.copy() + for key, value in inputs_tree.items(): + values = [value] if not isinstance(value, list) else value + new_values = [] + for value in values: + if isinstance(value, TestCollectionDef): + hdca_id = self._create_collection(history_id, value) + new_values = [dict(src="hdca", id=hdca_id)] + elif value in self.uploads: + new_values.append(self.uploads[value]) + else: + new_values.append(value) + inputs_tree[key] = new_values + + if resource_parameters: + inputs_tree["__job_resource|__job_resource__select"] = "yes" + for key, value in resource_parameters.items(): + inputs_tree["__job_resource|%s" % key] = value + + # HACK: Flatten single-value lists. Required when using expand_grouping + for key, value in inputs_tree.items(): + if isinstance(value, list) and len(value) == 1: + inputs_tree[key] = value[0] + + submit_response = self.__submit_tool(history_id, tool_id=testdef.tool_id, tool_input=inputs_tree) + submit_response_object = submit_response.json() + try: + return Bunch( + inputs=inputs_tree, + outputs=self.__dictify_outputs(submit_response_object), + output_collections=self.__dictify_output_collections(submit_response_object), + jobs=submit_response_object['jobs'], + ) + except KeyError: + message = "Error creating a job for these tool inputs - %s" % submit_response_object['err_msg'] + raise RunToolException(message, inputs_tree) + + def _create_collection(self, history_id, collection_def): + create_payload = dict( + name=collection_def.name, + element_identifiers=dumps(self._element_identifiers(collection_def)), + collection_type=collection_def.collection_type, + history_id=history_id, + ) + return self._post("dataset_collections", data=create_payload).json()["id"] + + def _element_identifiers(self, collection_def): + element_identifiers = [] + for element_dict in collection_def.elements: + element_identifier = element_dict["element_identifier"] + element_def = element_dict["element_definition"] + if isinstance(element_def, TestCollectionDef): + subelement_identifiers = self._element_identifiers(element_def) + element = dict( + name=element_identifier, + src="new_collection", + collection_type=element_def.collection_type, + element_identifiers=subelement_identifiers + ) + else: + element = self.uploads[element_def["value"]].copy() + element["name"] = element_identifier + element_identifiers.append(element) + return element_identifiers + + def __dictify_output_collections(self, submit_response): + output_collections_dict = odict() + for output_collection in submit_response['output_collections']: + output_collections_dict[output_collection.get("output_name")] = output_collection + return output_collections_dict + + def __dictify_outputs(self, datasets_object): + # Convert outputs list to a dictionary that can be accessed by + # output_name so can be more flexiable about ordering of outputs + # but also allows fallback to legacy access as list mode. + outputs_dict = odict() + index = 0 + for output in datasets_object['outputs']: + outputs_dict[index] = outputs_dict[output.get("output_name")] = output + index += 1 + # Adding each item twice (once with index for backward compat), + # overiding length to reflect the real number of outputs. + outputs_dict.__len__ = lambda: index + return outputs_dict + + def output_hid(self, output_data): + return output_data['id'] + + def delete_history(self, history): + return None + + def __job_ready(self, job_id, history_id): + if job_id is None: + raise ValueError("__job_ready passed empty job_id") + job_json = self._get("jobs/%s" % job_id).json() + state = job_json['state'] + try: + return self._state_ready(state, error_msg="Job in error state.") + except Exception: + if VERBOSE_ERRORS: + self._summarize_history(history_id) + raise + + def _summarize_history(self, history_id): + if history_id is None: + raise ValueError("_summarize_history passed empty history_id") + print("Problem in history with id %s - summary of datasets below." % history_id) + try: + history_contents = self.__contents(history_id) + except Exception: + print("*TEST FRAMEWORK FAILED TO FETCH HISTORY DETAILS*") + + for history_content in history_contents: + + dataset = history_content + + print(ERROR_MESSAGE_DATASET_SEP) + dataset_id = dataset.get('id', None) + print("| %d - %s (HID - NAME) " % (int(dataset['hid']), dataset['name'])) + if history_content['history_content_type'] == 'dataset_collection': + history_contents_json = self._get("histories/%s/contents/dataset_collections/%s" % (history_id, history_content["id"])).json() + print("| Dataset Collection: %s" % history_contents_json) + continue + + try: + dataset_info = self._dataset_info(history_id, dataset_id) + print("| Dataset State:") + print(self.format_for_summary(dataset_info.get("state"), "Dataset state is unknown.")) + print("| Dataset Blurb:") + print(self.format_for_summary(dataset_info.get("misc_blurb", ""), "Dataset blurb was empty.")) + print("| Dataset Info:") + print(self.format_for_summary(dataset_info.get("misc_info", ""), "Dataset info is empty.")) + print("| Peek:") + print(self.format_for_summary(dataset_info.get("peek", ""), "Peek unavilable.")) + except Exception: + print("| *TEST FRAMEWORK ERROR FETCHING DATASET DETAILS*") + try: + provenance_info = self._dataset_provenance(history_id, dataset_id) + print("| Dataset Job Standard Output:") + print(self.format_for_summary(provenance_info.get("stdout", ""), "Standard output was empty.")) + print("| Dataset Job Standard Error:") + print(self.format_for_summary(provenance_info.get("stderr", ""), "Standard error was empty.")) + except Exception: + print("| *TEST FRAMEWORK ERROR FETCHING JOB DETAILS*") + print("|") + print(ERROR_MESSAGE_DATASET_SEP) + + def format_for_summary(self, blob, empty_message, prefix="| "): + contents = "\n".join(["%s%s" % (prefix, line.strip()) for line in StringIO(blob).readlines() if line.rstrip("\n\r")]) + return contents or "%s*%s*" % (prefix, empty_message) + + def _dataset_provenance(self, history_id, id): + provenance = self._get("histories/%s/contents/%s/provenance" % (history_id, id)).json() + return provenance + + def _dataset_info(self, history_id, id): + dataset_json = self._get("histories/%s/contents/%s" % (history_id, id)).json() + return dataset_json + + def __contents(self, history_id): + history_contents_json = self._get("histories/%s/contents" % history_id).json() + return history_contents_json + + def _state_ready(self, state_str, error_msg): + if state_str == 'ok': + return True + elif state_str == 'error': + raise Exception(error_msg) + return False + + def __submit_tool(self, history_id, tool_id, tool_input, extra_data={}, files=None): + data = dict( + history_id=history_id, + tool_id=tool_id, + inputs=dumps(tool_input), + **extra_data + ) + return self._post("tools", files=files, data=data) + + def ensure_user_with_email(self, email, password=None): + admin_key = self.master_api_key + all_users = self._get('users', key=admin_key).json() + try: + test_user = [user for user in all_users if user["email"] == email][0] + except IndexError: + username = re.sub('[^a-z-]', '--', email.lower()) + password = password or 'testpass' + # If remote user middleware is enabled - this endpoint consumes + # ``remote_user_email`` otherwise it requires ``email``, ``password`` + # and ``username``. + data = dict( + remote_user_email=email, + email=email, + password=password, + username=username, + ) + test_user = self._post('users', data, key=admin_key).json() + return test_user + + def __test_data_path_builder(self, tool_id): + return lambda filename: self.test_data_path(tool_id, filename) + + def __dataset_fetcher(self, history_id): + def fetcher(hda_id, base_name=None): + url = "histories/%s/contents/%s/display?raw=true" % (history_id, hda_id) + if base_name: + url += "&filename=%s" % base_name + return self._get(url).content + + return fetcher + + def _post(self, path, data={}, files=None, key=None, admin=False, anon=False): + if not anon: + if not key: + key = self.api_key if not admin else self.master_api_key + data = data.copy() + data['key'] = key + return requests.post("%s/%s" % (self.api_url, path), data=data, files=files) + + def _delete(self, path, data={}, key=None, admin=False, anon=False): + if not anon: + if not key: + key = self.api_key if not admin else self.master_api_key + data = data.copy() + data['key'] = key + return requests.delete("%s/%s" % (self.api_url, path), params=data) + + def _patch(self, path, data={}, key=None, admin=False, anon=False): + if not anon: + if not key: + key = self.api_key if not admin else self.master_api_key + params = dict(key=key) + data = data.copy() + data['key'] = key + else: + params = {} + return requests.patch("%s/%s" % (self.api_url, path), params=params, data=data) + + def _put(self, path, data={}, key=None, admin=False, anon=False): + if not anon: + if not key: + key = self.api_key if not admin else self.master_api_key + params = dict(key=key) + data = data.copy() + data['key'] = key + else: + params = {} + return requests.put("%s/%s" % (self.api_url, path), params=params, data=data) + + def _get(self, path, data={}, key=None, admin=False, anon=False): + if not anon: + if not key: + key = self.api_key if not admin else self.master_api_key + data = data.copy() + data['key'] = key + if path.startswith("/api"): + path = path[len("/api"):] + url = "%s/%s" % (self.api_url, path) + return requests.get(url, params=data) + + +class RunToolException(Exception): + + def __init__(self, message, inputs=None): + super(RunToolException, self).__init__(message) + self.inputs = inputs + + +# Galaxy specific methods - rest of this can be used with arbitrary files and such. +def verify_hid(filename, hda_id, attributes, test_data_path_builder, hid="", dataset_fetcher=None, keep_outputs_dir=False): + assert dataset_fetcher is not None + + def verify_extra_files(extra_files): + _verify_extra_files_content(extra_files, hda_id, dataset_fetcher=dataset_fetcher, test_data_path_builder=test_data_path_builder, keep_outputs_dir=keep_outputs_dir) + + data = dataset_fetcher(hda_id) + item_label = "History item %s" % hid + verify( + item_label, + data, + attributes=attributes, + filename=filename, + get_filename=test_data_path_builder, + keep_outputs_dir=keep_outputs_dir, + verify_extra_files=verify_extra_files, + ) + + +def _verify_composite_datatype_file_content(file_name, hda_id, base_name=None, attributes=None, dataset_fetcher=None, test_data_path_builder=None, keep_outputs_dir=False): + assert dataset_fetcher is not None + + data = dataset_fetcher(hda_id, base_name) + item_label = "History item %s" % hda_id + try: + verify( + item_label, + data, + attributes=attributes, + filename=file_name, + get_filename=test_data_path_builder, + keep_outputs_dir=keep_outputs_dir, + ) + except AssertionError as err: + errmsg = 'Composite file (%s) of %s different than expected, difference:\n' % (base_name, item_label) + errmsg += str(err) + raise AssertionError(errmsg) + + +def _verify_extra_files_content(extra_files, hda_id, dataset_fetcher, test_data_path_builder, keep_outputs_dir): + files_list = [] + for extra_file_dict in extra_files: + extra_file_type = extra_file_dict["type"] + extra_file_name = extra_file_dict["name"] + extra_file_attributes = extra_file_dict["attributes"] + extra_file_value = extra_file_dict["value"] + + if extra_file_type == 'file': + files_list.append((extra_file_name, extra_file_value, extra_file_attributes)) + elif extra_file_type == 'directory': + for filename in os.listdir(test_data_path_builder(extra_file_value)): + files_list.append((filename, os.path.join(extra_file_value, filename), extra_file_attributes)) + else: + raise ValueError('unknown extra_files type: %s' % extra_file_type) + for filename, filepath, attributes in files_list: + _verify_composite_datatype_file_content(filepath, hda_id, base_name=filename, attributes=attributes, dataset_fetcher=dataset_fetcher, test_data_path_builder=test_data_path_builder, keep_outputs_dir=keep_outputs_dir) + + +def verify_tool(tool_id, galaxy_interactor, resource_parameters={}, register_job_data=None, test_index=0, tool_version=None, quiet=False): + tool_test_dicts = galaxy_interactor.get_tool_tests(tool_id, tool_version=tool_version) + tool_test_dict = tool_test_dicts[test_index] + testdef = ToolTestDescription(tool_test_dict) + + _handle_def_errors(testdef) + + test_history = galaxy_interactor.new_history() + + stage_data_in_history(galaxy_interactor, tool_id, testdef.test_data(), test_history) + + # Once data is ready, run the tool and check the outputs - record API + # input, job info, tool run exception, as well as exceptions related to + # job output checking and register they with the test plugin so it can + # record structured information. + tool_inputs = None + job_stdio = None + job_output_exceptions = None + tool_execution_exception = None + expected_failure_occurred = False + begin_time = time.time() + try: + try: + tool_response = galaxy_interactor.run_tool(testdef, test_history, resource_parameters=resource_parameters) + data_list, jobs, tool_inputs = tool_response.outputs, tool_response.jobs, tool_response.inputs + data_collection_list = tool_response.output_collections + except RunToolException as e: + tool_inputs = e.inputs + tool_execution_exception = e + if not testdef.expect_failure: + raise e + else: + expected_failure_occurred = True + except Exception as e: + tool_execution_exception = e + raise e + + if not expected_failure_occurred: + assert data_list or data_collection_list + + try: + job_stdio = _verify_outputs(testdef, test_history, jobs, tool_id, data_list, data_collection_list, galaxy_interactor, quiet=quiet) + except JobOutputsError as e: + job_stdio = e.job_stdio + job_output_exceptions = e.output_exceptions + raise e + except Exception as e: + job_output_exceptions = [e] + raise e + finally: + if register_job_data is not None: + end_time = time.time() + job_data = { + "tool_id": tool_id, + "tool_version": tool_version, + "test_index": test_index, + "time_seconds": end_time - begin_time, + } + if tool_inputs is not None: + job_data["inputs"] = tool_inputs + if job_stdio is not None: + job_data["job"] = job_stdio + status = "success" + if job_output_exceptions: + job_data["output_problems"] = [str(_) for _ in job_output_exceptions] + status = "failure" + if tool_execution_exception: + job_data["execution_problem"] = str(tool_execution_exception) + status = "error" + job_data["status"] = status + register_job_data(job_data) + + galaxy_interactor.delete_history(test_history) + + +def _handle_def_errors(testdef): + # If the test generation had an error, raise + if testdef.error: + if testdef.exception: + if isinstance(testdef.exception, Exception): + raise testdef.exception + else: + raise Exception(testdef.exception) + else: + raise Exception("Test parse failure") + + +def _verify_outputs(testdef, history, jobs, tool_id, data_list, data_collection_list, galaxy_interactor, quiet=False): + assert len(jobs) == 1, "Test framework logic error, somehow tool test resulted in more than one job." + job = jobs[0] + + maxseconds = testdef.maxseconds + if testdef.num_outputs is not None: + expected = testdef.num_outputs + actual = len(data_list) + if expected != actual: + messaage_template = "Incorrect number of outputs - expected %d, found %s." + message = messaage_template % (expected, actual) + raise Exception(message) + found_exceptions = [] + + def register_exception(e): + if not found_exceptions and not quiet: + # Only print this stuff out once. + for stream in ['stdout', 'stderr']: + if stream in job_stdio: + print(_format_stream(job_stdio[stream], stream=stream, format=True), file=sys.stderr) + found_exceptions.append(e) + + if testdef.expect_failure: + if testdef.outputs: + raise Exception("Cannot specify outputs in a test expecting failure.") + + # Wait for the job to complete and register expections if the final + # status was not what test was expecting. + job_failed = False + try: + galaxy_interactor.wait_for_job(job['id'], history, maxseconds) + except Exception as e: + job_failed = True + if not testdef.expect_failure: + found_exceptions.append(e) + + job_stdio = galaxy_interactor.get_job_stdio(job['id']) + + if not job_failed and testdef.expect_failure: + error = AssertionError("Expected job to fail but Galaxy indicated the job successfully completed.") + register_exception(error) + + expect_exit_code = testdef.expect_exit_code + if expect_exit_code is not None: + exit_code = job_stdio["exit_code"] + if str(expect_exit_code) != str(exit_code): + error = AssertionError("Expected job to complete with exit code %s, found %s" % (expect_exit_code, exit_code)) + register_exception(error) + + for output_index, output_dict in enumerate(testdef.outputs): + # Get the correct hid + name = output_dict["name"] + outfile = output_dict["value"] + attributes = output_dict["attributes"] + output_testdef = Bunch(name=name, outfile=outfile, attributes=attributes) + try: + output_data = data_list[name] + except (TypeError, KeyError): + # Legacy - fall back on ordered data list access if data_list is + # just a list (case with twill variant or if output changes its + # name). + if hasattr(data_list, "values"): + output_data = list(data_list.values())[output_index] + else: + output_data = data_list[len(data_list) - len(testdef.outputs) + output_index] + assert output_data is not None + try: + galaxy_interactor.verify_output(history, jobs, output_data, output_testdef=output_testdef, tool_id=tool_id, maxseconds=maxseconds) + except Exception as e: + register_exception(e) + + other_checks = { + "command_line": "Command produced by the job", + "stdout": "Standard output of the job", + "stderr": "Standard error of the job", + } + for what, description in other_checks.items(): + if getattr(testdef, what, None) is not None: + try: + data = job_stdio[what] + verify_assertions(data, getattr(testdef, what)) + except AssertionError as err: + errmsg = '%s different than expected\n' % description + errmsg += str(err) + register_exception(AssertionError(errmsg)) + + for output_collection_def in testdef.output_collections: + try: + name = output_collection_def.name + # TODO: data_collection_list is clearly a bad name for dictionary. + if name not in data_collection_list: + template = "Failed to find output [%s], tool outputs include [%s]" + message = template % (name, ",".join(data_collection_list.keys())) + raise AssertionError(message) + + # Data collection returned from submission, elements may have been populated after + # the job completed so re-hit the API for more information. + data_collection_returned = data_collection_list[name] + data_collection = galaxy_interactor._get("dataset_collections/%s" % data_collection_returned["id"], data={"instance_type": "history"}).json() + + def get_element(elements, id): + for element in elements: + if element["element_identifier"] == id: + return element + return False + + expected_collection_type = output_collection_def.collection_type + if expected_collection_type: + collection_type = data_collection["collection_type"] + if expected_collection_type != collection_type: + template = "Expected output collection [%s] to be of type [%s], was of type [%s]." + message = template % (name, expected_collection_type, collection_type) + raise AssertionError(message) + + expected_element_count = output_collection_def.count + if expected_element_count: + actual_element_count = len(data_collection["elements"]) + if expected_element_count != actual_element_count: + template = "Expected output collection [%s] to have %s elements, but it had %s." + message = template % (name, expected_element_count, actual_element_count) + raise AssertionError(message) + + def verify_elements(element_objects, element_tests): + for element_identifier, (element_outfile, element_attrib) in element_tests.items(): + element = get_element(element_objects, element_identifier) + if not element: + template = "Failed to find identifier [%s] for testing, tool generated collection elements [%s]" + message = template % (element_identifier, element_objects) + raise AssertionError(message) + + element_type = element["element_type"] + if element_type != "dataset_collection": + hda = element["object"] + galaxy_interactor.verify_output_dataset( + history, + hda_id=hda["id"], + outfile=element_outfile, + attributes=element_attrib, + tool_id=tool_id + ) + if element_type == "dataset_collection": + elements = element["object"]["elements"] + verify_elements(elements, element_attrib.get("elements", {})) + + verify_elements(data_collection["elements"], output_collection_def.element_tests) + except Exception as e: + register_exception(e) + + if found_exceptions: + raise JobOutputsError(found_exceptions, job_stdio) + else: + return job_stdio + + +def _format_stream(output, stream, format): + output = output or '' + if format: + msg = "---------------------- >> begin tool %s << -----------------------\n" % stream + msg += output + "\n" + msg += "----------------------- >> end tool %s << ------------------------\n" % stream + else: + msg = output + return msg + + +class JobOutputsError(AssertionError): + + def __init__(self, output_exceptions, job_stdio): + big_message = "\n".join(map(str, output_exceptions)) + super(JobOutputsError, self).__init__(big_message) + self.job_stdio = job_stdio + self.output_exceptions = output_exceptions + + +class ToolTestDescription(object): + """ + Encapsulates information about a tool test, and allows creation of a + dynamic TestCase class (the unittest framework is very class oriented, + doing dynamic tests in this way allows better integration) + """ + + def __init__(self, processed_test_dict): + test_index = processed_test_dict["test_index"] + name = processed_test_dict.get('name', 'Test-%d' % (test_index + 1)) + maxseconds = processed_test_dict.get('maxseconds', DEFAULT_MAX_SECS) + if maxseconds is not None: + maxseconds = int(maxseconds) + + self.test_index = test_index + self.tool_id = processed_test_dict["tool_id"] + self.name = name + self.maxseconds = maxseconds + self.required_files = processed_test_dict.get("required_files", []) + + inputs = processed_test_dict.get("inputs", {}) + loaded_inputs = {} + for key, value in inputs.items(): + if isinstance(value, dict) and value.get("model_class"): + loaded_inputs[key] = TestCollectionDef.from_dict(value) + else: + loaded_inputs[key] = value + + self.inputs = loaded_inputs + self.outputs = processed_test_dict.get("outputs", []) + self.num_outputs = processed_test_dict.get("num_outputs", 0) + + self.error = processed_test_dict.get("error", False) + self.exception = processed_test_dict.get("exception", None) + + self.output_collections = map(TestCollectionOutputDef.from_dict, processed_test_dict.get("output_collections", [])) + self.command_line = processed_test_dict.get("command", None) + self.stdout = processed_test_dict.get("stdout", None) + self.stderr = processed_test_dict.get("stderr", None) + self.expect_exit_code = processed_test_dict.get("expect_exit_code", None) + self.expect_failure = processed_test_dict.get("expect_failure", False) + + def test_data(self): + """ + Iterator over metadata representing the required files for upload. + """ + return test_data_iter(self.required_files) + + def to_dict(self): + inputs_dict = {} + for key, value in self.inputs.items(): + if hasattr(value, "to_dict"): + inputs_dict[key] = value.to_dict() + else: + inputs_dict[key] = value + + return { + "inputs": inputs_dict, + "outputs": self.outputs, + "output_collections": map(lambda o: o.to_dict(), self.output_collections), + "num_outputs": self.num_outputs, + "command_line": self.command_line, + "stdout": self.stdout, + "stderr": self.stderr, + "expect_exit_code": self.expect_exit_code, + "expect_failure": self.expect_failure, + "name": self.name, + "test_index": self.test_index, + "tool_id": self.tool_id, + "required_files": self.required_files, + "error": self.error, + "exception": self.exception, + } + + +@nottest +def test_data_iter(required_files): + for fname, extra in required_files: + data_dict = dict( + fname=fname, + metadata=extra.get('metadata', {}), + composite_data=extra.get('composite_data', []), + ftype=extra.get('ftype', DEFAULT_FTYPE), + dbkey=extra.get('dbkey', DEFAULT_DBKEY), + ) + edit_attributes = extra.get('edit_attributes', []) + + # currently only renaming is supported + for edit_att in edit_attributes: + if edit_att.get('type', None) == 'name': + new_name = edit_att.get('value', None) + assert new_name, 'You must supply the new dataset name as the value tag of the edit_attributes tag' + data_dict['name'] = new_name + else: + raise Exception('edit_attributes type (%s) is unimplemented' % edit_att.get('type', None)) + + yield data_dict diff --git a/lib/galaxy/tools/verify/script.py b/lib/galaxy/tools/verify/script.py new file mode 100644 index 00000000000..64873a368f4 --- /dev/null +++ b/lib/galaxy/tools/verify/script.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python +from __future__ import print_function + +import argparse +import json +import sys + +from galaxy.tools.verify.interactor import GalaxyInteractorApi, verify_tool + +DESCRIPTION = """Script to quickly run a tool test against a running Galaxy instance.""" +ALL_TESTS = "*all_tests*" + + +def main(argv=None): + if argv is None: + argv = sys.argv[1:] + + args = _arg_parser().parse_args(argv) + galaxy_interactor_kwds = { + "galaxy_url": args.galaxy_url, + "master_api_key": args.admin_key, + "api_key": args.key, + "keep_outputs_dir": args.output, + } + tool_id = args.tool_id + tool_version = args.tool_version + + galaxy_interactor = GalaxyInteractorApi(**galaxy_interactor_kwds) + raw_test_index = args.test_index + if raw_test_index == ALL_TESTS: + tool_test_dicts = galaxy_interactor.get_tool_tests(tool_id, tool_version=tool_version) + test_indices = list(range(len(tool_test_dicts))) + else: + test_indices = [int(raw_test_index)] + + test_results = [] + + if args.append: + with open(args.output_json, "r") as f: + previous_results = json.load(f) + test_results = previous_results["tests"] + + exceptions = [] + verbose = args.verbose + for test_index in test_indices: + if tool_version: + tool_id_and_version = "%s/%s" % (tool_id, tool_version) + else: + tool_id_and_version = tool_id + + test_identifier = "tool %s test # %d" % (tool_id_and_version, test_index) + + def register(job_data): + test_results.append({ + 'id': tool_id + "-" + str(test_index), + 'has_data': True, + 'data': job_data, + }) + + try: + verify_tool( + tool_id, galaxy_interactor, test_index=test_index, tool_version=tool_version, + register_job_data=register, quiet=not verbose + ) + + if verbose: + print("%s passed" % test_identifier) + + except Exception as e: + if verbose: + print("%s failed, %s" % (test_identifier, e)) + exceptions.append(e) + + report_obj = { + 'version': '0.1', + 'tests': test_results, + } + output_json = args.output_json + if output_json: + if args.output_json == "-": + assert not args.append + print(json.dumps(report_obj)) + else: + with open(args.output_json, "w") as f: + json.dump(report_obj, f) + + if exceptions: + raise exceptions[0] + + +def _arg_parser(): + parser = argparse.ArgumentParser(description=DESCRIPTION) + parser.add_argument('-u', '--galaxy-url', default="http://localhost:8080", help='Galaxy URL') + parser.add_argument('-k', '--key', default=None, help='Galaxy User API Key') + parser.add_argument('-a', '--admin-key', default=None, help='Galaxy Admin API Key') + parser.add_argument('-t', '--tool-id', default=None, help='Tool ID') + parser.add_argument('--tool-version', default=None, help='Tool Version') + parser.add_argument('-i', '--test-index', default=ALL_TESTS, help='Tool Test Index (starting at 0) - by default all tests will run.') + parser.add_argument('-o', '--output', default=None, help='directory to dump outputs to') + parser.add_argument('--append', default=False, action="store_true", help="Extend a test record json (created with --output-json) with additional tests.") + parser.add_argument('-j', '--output-json', default=None, help='output metadata json') + parser.add_argument('--verbose', default=False, action="store_true", help="Verbose logging.") + return parser + + +if __name__ == "__main__": + main() diff --git a/lib/galaxy/tools/verify/test_data.py b/lib/galaxy/tools/verify/test_data.py index 1c629741ee1..ea63d864aa8 100644 --- a/lib/galaxy/tools/verify/test_data.py +++ b/lib/galaxy/tools/verify/test_data.py @@ -8,6 +8,7 @@ from string import Template from galaxy.util import ( asbool, + in_directory, smart_str ) @@ -27,32 +28,25 @@ LIST_SEP = re.compile("\s*,\s*") class TestDataResolver(object): - def __init__(self, env_var='GALAXY_TEST_FILE_DIR', environ=os.environ): - file_dirs = environ.get(env_var, None) + def __init__(self, file_dirs=None, env_var='GALAXY_TEST_FILE_DIR', environ=os.environ): + if file_dirs is None: + file_dirs = environ.get(env_var, None) + if file_dirs is None: + file_dirs = "test-data,https://github.com/galaxyproject/galaxy-test-data.git" if file_dirs: self.resolvers = [build_resolver(u, environ) for u in LIST_SEP.split(file_dirs)] else: self.resolvers = [] def get_filename(self, name): - if not self.resolvers: - filename = None - else: - resolver = self.resolvers[0] + filename = None + for resolver in self.resolvers or []: + if not resolver.exists(name): + continue filename = resolver.path(name) - if not resolver.exists(filename): - for resolver in self.resolvers[1:]: - if resolver.exists(name): - filename = resolver.path(name) - else: - # For backward compat. returning first path if none - # exist - though I don't know if this function is ever - # actually used in a context where one should return - # a file even if it doesn't exist (e.g. a prefix or - # or something) - I am pretty sure it is not used in - # such a fashion in the context of tool tests. - filename = resolver.path(name) - return os.path.abspath(filename) + + if filename: + return os.path.abspath(filename) def build_resolver(uri, environ): @@ -68,7 +62,8 @@ class FileDataResolver(object): self.file_dir = file_dir def exists(self, filename): - return os.path.exists(self.path(filename)) + path = os.path.abspath(self.path(filename)) + return os.path.exists(path) and in_directory(path, self.file_dir) def path(self, filename): return os.path.join(self.file_dir, filename) diff --git a/lib/galaxy/util/compression_utils.py b/lib/galaxy/util/compression_utils.py index 7c70aaad644..e4e4734d4d3 100644 --- a/lib/galaxy/util/compression_utils.py +++ b/lib/galaxy/util/compression_utils.py @@ -1,5 +1,10 @@ +from __future__ import absolute_import + import gzip import io +import logging +import os +import tarfile import zipfile from .checkers import ( @@ -8,6 +13,8 @@ from .checkers import ( is_gzip ) +log = logging.getLogger(__name__) + def get_fileobj(filename, mode="r", compressed_formats=None): """ @@ -45,3 +52,121 @@ def get_fileobj(filename, mode="r", compressed_formats=None): return io.TextIOWrapper(fh, encoding='utf-8') else: return fh + + +class CompressedFile(object): + + def __init__(self, file_path, mode='r'): + if tarfile.is_tarfile(file_path): + self.file_type = 'tar' + elif zipfile.is_zipfile(file_path) and not file_path.endswith('.jar'): + self.file_type = 'zip' + self.file_name = os.path.splitext(os.path.basename(file_path))[0] + if self.file_name.endswith('.tar'): + self.file_name = os.path.splitext(self.file_name)[0] + self.type = self.file_type + method = 'open_%s' % self.file_type + if hasattr(self, method): + self.archive = getattr(self, method)(file_path, mode) + else: + raise NameError('File type %s specified, no open method found.' % self.file_type) + + def extract(self, path): + '''Determine the path to which the archive should be extracted.''' + contents = self.getmembers() + extraction_path = path + common_prefix = '' + if len(contents) == 1: + # The archive contains a single file, return the extraction path. + if self.isfile(contents[0]): + extraction_path = os.path.join(path, self.file_name) + if not os.path.exists(extraction_path): + os.makedirs(extraction_path) + self.archive.extractall(extraction_path) + else: + # Get the common prefix for all the files in the archive. If the common prefix ends with a slash, + # or self.isdir() returns True, the archive contains a single directory with the desired contents. + # Otherwise, it contains multiple files and/or directories at the root of the archive. + common_prefix = os.path.commonprefix([self.getname(item) for item in contents]) + if len(common_prefix) >= 1 and not common_prefix.endswith(os.sep) and self.isdir(self.getmember(common_prefix)): + common_prefix += os.sep + if not common_prefix.endswith(os.sep): + common_prefix = '' + extraction_path = os.path.join(path, self.file_name) + if not os.path.exists(extraction_path): + os.makedirs(extraction_path) + self.archive.extractall(extraction_path) + # Since .zip files store unix permissions separately, we need to iterate through the zip file + # and set permissions on extracted members. + if self.file_type == 'zip': + for zipped_file in contents: + filename = self.getname(zipped_file) + absolute_filepath = os.path.join(extraction_path, filename) + external_attributes = self.archive.getinfo(filename).external_attr + # The 2 least significant bytes are irrelevant, the next two contain unix permissions. + unix_permissions = external_attributes >> 16 + if unix_permissions != 0: + if os.path.exists(absolute_filepath): + os.chmod(absolute_filepath, unix_permissions) + else: + log.warning("Unable to change permission on extracted file '%s' as it does not exist" % absolute_filepath) + return os.path.abspath(os.path.join(extraction_path, common_prefix)) + + def getmembers_tar(self): + return self.archive.getmembers() + + def getmembers_zip(self): + return self.archive.infolist() + + def getname_tar(self, item): + return item.name + + def getname_zip(self, item): + return item.filename + + def getmember(self, name): + for member in self.getmembers(): + if self.getname(member) == name: + return member + + def getmembers(self): + return getattr(self, 'getmembers_%s' % self.type)() + + def getname(self, member): + return getattr(self, 'getname_%s' % self.type)(member) + + def isdir(self, member): + return getattr(self, 'isdir_%s' % self.type)(member) + + def isdir_tar(self, member): + return member.isdir() + + def isdir_zip(self, member): + if member.filename.endswith(os.sep): + return True + return False + + def isfile(self, member): + if not self.isdir(member): + return True + return False + + def open_tar(self, filepath, mode): + return tarfile.open(filepath, mode, errorlevel=0) + + def open_zip(self, filepath, mode): + return zipfile.ZipFile(filepath, mode) + + def zipfile_ok(self, path_to_archive): + """ + This function is a bit pedantic and not functionally necessary. It checks whether there is + no file pointing outside of the extraction, because ZipFile.extractall() has some potential + security holes. See python zipfile documentation for more details. + """ + basename = os.path.realpath(os.path.dirname(path_to_archive)) + zip_archive = zipfile.ZipFile(path_to_archive) + for member in zip_archive.namelist(): + member_path = os.path.realpath(os.path.join(basename, member)) + if not member_path.startswith(basename): + return False + return True diff --git a/lib/galaxy/util/path/__init__.py b/lib/galaxy/util/path/__init__.py index b1756514d24..9424c3965de 100644 --- a/lib/galaxy/util/path/__init__.py +++ b/lib/galaxy/util/path/__init__.py @@ -6,7 +6,10 @@ import errno import imp import logging from functools import partial -from grp import getgrgid +try: + from grp import getgrgid +except ImportError: + getgrgid = None from itertools import starmap from operator import getitem from os import ( @@ -28,7 +31,10 @@ from os.path import ( relpath, sep as separator, ) -from pwd import getpwuid +try: + from pwd import getpwuid +except ImportError: + getpwuid = None from six import iteritems, string_types from six.moves import filter, map, zip @@ -165,6 +171,9 @@ def __path_permission_for_user(path, username): :type username: string :param username: a username matching the systems username """ + if getpwuid is None: + raise NotImplementedError("This functionality is not implemented for Windows.") + group_id_of_file = stat(path).st_gid file_owner = getpwuid(stat(path).st_uid) group_members = getgrgid(group_id_of_file).gr_mem diff --git a/lib/galaxy/util/sanitize_html.py b/lib/galaxy/util/sanitize_html.py index 525c0d8ed4d..3dd2d4c185f 100644 --- a/lib/galaxy/util/sanitize_html.py +++ b/lib/galaxy/util/sanitize_html.py @@ -1,427 +1,45 @@ """ -HTML Sanitizer (ripped from feedparser) +HTML Sanitizer (lists of acceptable_* ripped from feedparser) """ +import bleach -import re -import sgmllib +_acceptable_elements = ['a', 'abbr', 'acronym', 'address', 'area', 'article', + 'aside', 'audio', 'b', 'big', 'blockquote', 'br', 'button', 'canvas', + 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'command', + 'datagrid', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', + 'div', 'dl', 'dt', 'em', 'event-source', 'fieldset', 'figure', + 'footer', 'font', 'form', 'header', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'hr', 'i', 'img', 'input', 'ins', 'keygen', 'kbd', 'label', 'legend', + 'li', 'm', 'map', 'menu', 'meter', 'multicol', 'nav', 'nextid', 'ol', + 'output', 'optgroup', 'option', 'p', 'pre', 'progress', 'q', 's', + 'samp', 'section', 'select', 'small', 'sound', 'source', 'spacer', + 'span', 'strike', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', + 'textarea', 'time', 'tfoot', 'th', 'thead', 'tr', 'tt', 'u', 'ul', + 'var', 'video', 'noscript'] -from six import unichr -from six.moves.html_entities import name2codepoint - -from galaxy.util import unicodify - -_cp1252 = { - unichr(128): unichr(8364), # euro sign - unichr(130): unichr(8218), # single low-9 quotation mark - unichr(131): unichr(402), # latin small letter f with hook - unichr(132): unichr(8222), # double low-9 quotation mark - unichr(133): unichr(8230), # horizontal ellipsis - unichr(134): unichr(8224), # dagger - unichr(135): unichr(8225), # double dagger - unichr(136): unichr(710), # modifier letter circumflex accent - unichr(137): unichr(8240), # per mille sign - unichr(138): unichr(352), # latin capital letter s with caron - unichr(139): unichr(8249), # single left-pointing angle quotation mark - unichr(140): unichr(338), # latin capital ligature oe - unichr(142): unichr(381), # latin capital letter z with caron - unichr(145): unichr(8216), # left single quotation mark - unichr(146): unichr(8217), # right single quotation mark - unichr(147): unichr(8220), # left double quotation mark - unichr(148): unichr(8221), # right double quotation mark - unichr(149): unichr(8226), # bullet - unichr(150): unichr(8211), # en dash - unichr(151): unichr(8212), # em dash - unichr(152): unichr(732), # small tilde - unichr(153): unichr(8482), # trade mark sign - unichr(154): unichr(353), # latin small letter s with caron - unichr(155): unichr(8250), # single right-pointing angle quotation mark - unichr(156): unichr(339), # latin small ligature oe - unichr(158): unichr(382), # latin small letter z with caron - unichr(159): unichr(376)} # latin capital letter y with diaeresis +_acceptable_attributes = ['abbr', 'accept', 'accept-charset', 'accesskey', + 'action', 'align', 'alt', 'autocomplete', 'autofocus', 'axis', + 'background', 'balance', 'bgcolor', 'bgproperties', 'border', + 'bordercolor', 'bordercolordark', 'bordercolorlight', 'bottompadding', + 'cellpadding', 'cellspacing', 'ch', 'challenge', 'char', 'charoff', + 'choff', 'charset', 'checked', 'cite', 'class', 'clear', 'color', + 'cols', 'colspan', 'compact', 'contenteditable', 'controls', 'coords', + 'data', 'datafld', 'datapagesize', 'datasrc', 'datetime', 'default', + 'delay', 'dir', 'disabled', 'draggable', 'dynsrc', 'enctype', 'end', + 'face', 'for', 'form', 'frame', 'galleryimg', 'gutter', 'headers', + 'height', 'hidefocus', 'hidden', 'high', 'href', 'hreflang', 'hspace', + 'icon', 'id', 'inputmode', 'ismap', 'keytype', 'label', 'leftspacing', + 'lang', 'list', 'longdesc', 'loop', 'loopcount', 'loopend', + 'loopstart', 'low', 'lowsrc', 'max', 'maxlength', 'media', 'method', + 'min', 'multiple', 'name', 'nohref', 'noshade', 'nowrap', 'open', + 'optimum', 'pattern', 'ping', 'point-size', 'prompt', 'pqg', + 'radiogroup', 'readonly', 'rel', 'repeat-max', 'repeat-min', 'replace', + 'required', 'rev', 'rightspacing', 'rows', 'rowspan', 'rules', 'scope', + 'selected', 'shape', 'size', 'span', 'src', 'start', 'step', 'summary', + 'suppress', 'tabindex', 'target', 'template', 'title', 'toppadding', + 'type', 'unselectable', 'usemap', 'urn', 'valign', 'value', 'variable', + 'volume', 'vspace', 'vrml', 'width', 'wrap', 'xml:lang'] -class _BaseHTMLProcessor(sgmllib.SGMLParser): - special = re.compile('''[<>'"]''') - bare_ampersand = re.compile("&(?!#\d+;|#x[0-9a-fA-F]+;|\w+;)") - elements_no_end_tag = ['area', 'base', 'basefont', 'br', 'col', 'frame', 'hr', - 'img', 'input', 'isindex', 'link', 'meta', 'param'] - - def __init__(self, encoding, type): - self.encoding = encoding - self.type = type - # if _debug: sys.stderr.write('entering BaseHTMLProcessor, encoding=%s\n' % self.encoding) - sgmllib.SGMLParser.__init__(self) - - def reset(self): - self.pieces = [] - sgmllib.SGMLParser.reset(self) - - def _shorttag_replace(self, match): - tag = match.group(1) - if tag in self.elements_no_end_tag: - return '<' + tag + ' />' - else: - return '<' + tag + '>' - - def parse_starttag(self, i): - j = sgmllib.SGMLParser.parse_starttag(self, i) - if self.type == 'application/xhtml+xml': - if j > 2 and self.rawdata[j - 2:j] == '/>': - self.unknown_endtag(self.lasttag) - return j - - def feed(self, data): - data = re.compile(r'\s]+?)\s*/>', self._shorttag_replace, data) - data = data.replace(''', "'") - data = data.replace('"', '"') - sgmllib.SGMLParser.feed(self, data) - sgmllib.SGMLParser.close(self) - - def normalize_attrs(self, attrs): - if not attrs: - return attrs - # utility method to be called by descendants - attrs = dict([(k.lower(), v) for k, v in attrs]).items() - attrs = [(k, k in ('rel', 'type') and v.lower() or v) for k, v in attrs] - attrs.sort() - return attrs - - def unknown_starttag(self, tag, attrs): - # called for each start tag - # attrs is a list of (attr, value) tuples - # e.g. for
, tag='pre', attrs=[('class', 'screen')]
-        uattrs = []
-        strattrs = ''
-        if attrs:
-            for key, value in attrs:
-                value = value.replace('>', '>').replace('<', '<').replace('"', '"')
-                value = self.bare_ampersand.sub("&", value)
-                uattrs.append((key, value))
-            strattrs = ''.join([' %s="%s"' % (k, v) for k, v in uattrs])
-        if tag in self.elements_no_end_tag:
-            self.pieces.append('<%s%s />' % (tag, strattrs))
-        else:
-            self.pieces.append('<%s%s>' % (tag, strattrs))
-
-    def unknown_endtag(self, tag):
-        # called for each end tag, e.g. for 
, tag will be 'pre' - # Reconstruct the original end tag. - if tag not in self.elements_no_end_tag: - self.pieces.append("" % locals()) - - def handle_charref(self, ref): - # called for each character reference, e.g. for ' ', ref will be '160' - # Reconstruct the original character reference. - if ref.startswith('x'): - value = unichr(int(ref[1:], 16)) - else: - value = unichr(int(ref)) - - if value in _cp1252.keys(): - self.pieces.append('&#%s;' % hex(ord(_cp1252[value]))[1:]) - else: - self.pieces.append('&#%(ref)s;' % locals()) - - def handle_entityref(self, ref): - # called for each entity reference, e.g. for '©', ref will be 'copy' - # Reconstruct the original entity reference. - if ref in name2codepoint: - self.pieces.append('&%(ref)s;' % locals()) - else: - self.pieces.append('&%(ref)s' % locals()) - - def handle_data(self, text): - # called for each block of plain text, i.e. outside of any tag and - # not containing any character or entity references - # Store the original text verbatim. - self.pieces.append(text) - - def handle_comment(self, text): - # called for each HTML comment, e.g. - # Reconstruct the original comment. - self.pieces.append('' % locals()) - - def handle_pi(self, text): - # called for each processing instruction, e.g. - # Reconstruct original processing instruction. - self.pieces.append('' % locals()) - - def handle_decl(self, text): - # called for the DOCTYPE, if present, e.g. - # - # Reconstruct original DOCTYPE - self.pieces.append('' % locals()) - - _new_declname_match = re.compile(r'[a-zA-Z][-_.a-zA-Z0-9:]*\s*').match - - def _scan_name(self, i, declstartpos): - rawdata = self.rawdata - n = len(rawdata) - if i == n: - return None, -1 - m = self._new_declname_match(rawdata, i) - if m: - s = m.group() - name = s.strip() - if (i + len(s)) == n: - return None, -1 # end of buffer - return name.lower(), m.end() - else: - self.handle_data(rawdata) -# self.updatepos(declstartpos, i) - return None, -1 - - def convert_charref(self, name): - return '&#%s;' % name - - def convert_entityref(self, name): - return '&%s;' % name - - def output(self): - '''Return processed HTML as a single string''' - return ''.join(self.pieces) - - -class _HTMLSanitizer(_BaseHTMLProcessor): - acceptable_elements = ['a', 'abbr', 'acronym', 'address', 'area', 'article', - 'aside', 'audio', 'b', 'big', 'blockquote', 'br', 'button', 'canvas', - 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'command', - 'datagrid', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', - 'div', 'dl', 'dt', 'em', 'event-source', 'fieldset', 'figure', 'footer', - 'font', 'form', 'header', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', - 'img', 'input', 'ins', 'keygen', 'kbd', 'label', 'legend', 'li', 'm', 'map', - 'menu', 'meter', 'multicol', 'nav', 'nextid', 'ol', 'output', 'optgroup', - 'option', 'p', 'pre', 'progress', 'q', 's', 'samp', 'section', 'select', - 'small', 'sound', 'source', 'spacer', 'span', 'strike', 'strong', 'sub', - 'sup', 'table', 'tbody', 'td', 'textarea', 'time', 'tfoot', 'th', 'thead', - 'tr', 'tt', 'u', 'ul', 'var', 'video', 'noscript'] - - acceptable_attributes = ['abbr', 'accept', 'accept-charset', 'accesskey', - 'action', 'align', 'alt', 'autocomplete', 'autofocus', 'axis', - 'background', 'balance', 'bgcolor', 'bgproperties', 'border', - 'bordercolor', 'bordercolordark', 'bordercolorlight', 'bottompadding', - 'cellpadding', 'cellspacing', 'ch', 'challenge', 'char', 'charoff', - 'choff', 'charset', 'checked', 'cite', 'class', 'clear', 'color', 'cols', - 'colspan', 'compact', 'contenteditable', 'controls', 'coords', 'data', - 'datafld', 'datapagesize', 'datasrc', 'datetime', 'default', 'delay', - 'dir', 'disabled', 'draggable', 'dynsrc', 'enctype', 'end', 'face', 'for', - 'form', 'frame', 'galleryimg', 'gutter', 'headers', 'height', 'hidefocus', - 'hidden', 'high', 'href', 'hreflang', 'hspace', 'icon', 'id', 'inputmode', - 'ismap', 'keytype', 'label', 'leftspacing', 'lang', 'list', 'longdesc', - 'loop', 'loopcount', 'loopend', 'loopstart', 'low', 'lowsrc', 'max', - 'maxlength', 'media', 'method', 'min', 'multiple', 'name', 'nohref', - 'noshade', 'nowrap', 'open', 'optimum', 'pattern', 'ping', 'point-size', - 'prompt', 'pqg', 'radiogroup', 'readonly', 'rel', 'repeat-max', - 'repeat-min', 'replace', 'required', 'rev', 'rightspacing', 'rows', - 'rowspan', 'rules', 'scope', 'selected', 'shape', 'size', 'span', 'src', - 'start', 'step', 'summary', 'suppress', 'tabindex', 'target', 'template', - 'title', 'toppadding', 'type', 'unselectable', 'usemap', 'urn', 'valign', - 'value', 'variable', 'volume', 'vspace', 'vrml', 'width', 'wrap', - 'xml:lang'] - - unacceptable_elements_with_end_tag = ['script', 'applet', 'style'] - - acceptable_css_properties = ['azimuth', 'background-color', - 'border-bottom-color', 'border-collapse', 'border-color', - 'border-left-color', 'border-right-color', 'border-top-color', 'clear', - 'color', 'cursor', 'direction', 'display', 'elevation', 'float', 'font', - 'font-family', 'font-size', 'font-style', 'font-variant', 'font-weight', - 'height', 'letter-spacing', 'line-height', 'overflow', 'pause', - 'pause-after', 'pause-before', 'pitch', 'pitch-range', 'richness', - 'speak', 'speak-header', 'speak-numeral', 'speak-punctuation', - 'speech-rate', 'stress', 'text-align', 'text-decoration', 'text-indent', - 'unicode-bidi', 'vertical-align', 'voice-family', 'volume', - 'white-space', 'width'] - - # survey of common keywords found in feeds - acceptable_css_keywords = ['auto', 'aqua', 'black', 'block', 'blue', - 'bold', 'both', 'bottom', 'brown', 'center', 'collapse', 'dashed', - 'dotted', 'fuchsia', 'gray', 'green', '!important', 'italic', 'left', - 'lime', 'maroon', 'medium', 'none', 'navy', 'normal', 'nowrap', 'olive', - 'pointer', 'purple', 'red', 'right', 'solid', 'silver', 'teal', 'top', - 'transparent', 'underline', 'white', 'yellow'] - - valid_css_values = re.compile('^(#[0-9a-f]+|rgb\(\d+%?,\d*%?,?\d*%?\)?|' + - '\d{0,2}\.?\d{0,2}(cm|em|ex|in|mm|pc|pt|px|%|,|\))?)$') - - mathml_elements = ['annotation', 'annotation-xml', 'maction', 'math', - 'merror', 'mfenced', 'mfrac', 'mi', 'mmultiscripts', 'mn', 'mo', 'mover', 'mpadded', - 'mphantom', 'mprescripts', 'mroot', 'mrow', 'mspace', 'msqrt', 'mstyle', - 'msub', 'msubsup', 'msup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', - 'munderover', 'none', 'semantics'] - - mathml_attributes = ['actiontype', 'align', 'columnalign', 'columnalign', - 'columnalign', 'close', 'columnlines', 'columnspacing', 'columnspan', 'depth', - 'display', 'displaystyle', 'encoding', 'equalcolumns', 'equalrows', - 'fence', 'fontstyle', 'fontweight', 'frame', 'height', 'linethickness', - 'lspace', 'mathbackground', 'mathcolor', 'mathvariant', 'mathvariant', - 'maxsize', 'minsize', 'open', 'other', 'rowalign', 'rowalign', 'rowalign', - 'rowlines', 'rowspacing', 'rowspan', 'rspace', 'scriptlevel', 'selection', - 'separator', 'separators', 'stretchy', 'width', 'width', 'xlink:href', - 'xlink:show', 'xlink:type', 'xmlns', 'xmlns:xlink'] - - # svgtiny - foreignObject + linearGradient + radialGradient + stop - svg_elements = ['a', 'animate', 'animateColor', 'animateMotion', - 'animateTransform', 'circle', 'defs', 'desc', 'ellipse', 'foreignObject', - 'font-face', 'font-face-name', 'font-face-src', 'g', 'glyph', 'hkern', - 'linearGradient', 'line', 'marker', 'metadata', 'missing-glyph', 'mpath', - 'path', 'polygon', 'polyline', 'radialGradient', 'rect', 'set', 'stop', - 'svg', 'switch', 'text', 'title', 'tspan', 'use'] - - # svgtiny + class + opacity + offset + xmlns + xmlns:xlink - svg_attributes = ['accent-height', 'accumulate', 'additive', 'alphabetic', - 'arabic-form', 'ascent', 'attributeName', 'attributeType', - 'baseProfile', 'bbox', 'begin', 'by', 'calcMode', 'cap-height', - 'class', 'color', 'color-rendering', 'content', 'cx', 'cy', 'd', 'dx', - 'dy', 'descent', 'display', 'dur', 'end', 'fill', 'fill-opacity', - 'fill-rule', 'font-family', 'font-size', 'font-stretch', 'font-style', - 'font-variant', 'font-weight', 'from', 'fx', 'fy', 'g1', 'g2', - 'glyph-name', 'gradientUnits', 'hanging', 'height', 'horiz-adv-x', - 'horiz-origin-x', 'id', 'ideographic', 'k', 'keyPoints', 'keySplines', - 'keyTimes', 'lang', 'mathematical', 'marker-end', 'marker-mid', - 'marker-start', 'markerHeight', 'markerUnits', 'markerWidth', 'max', - 'min', 'name', 'offset', 'opacity', 'orient', 'origin', - 'overline-position', 'overline-thickness', 'panose-1', 'path', - 'pathLength', 'points', 'preserveAspectRatio', 'r', 'refX', 'refY', - 'repeatCount', 'repeatDur', 'requiredExtensions', 'requiredFeatures', - 'restart', 'rotate', 'rx', 'ry', 'slope', 'stemh', 'stemv', - 'stop-color', 'stop-opacity', 'strikethrough-position', - 'strikethrough-thickness', 'stroke', 'stroke-dasharray', - 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', - 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', - 'target', 'text-anchor', 'to', 'transform', 'type', 'u1', 'u2', - 'underline-position', 'underline-thickness', 'unicode', 'unicode-range', - 'units-per-em', 'values', 'version', 'viewBox', 'visibility', 'width', - 'widths', 'x', 'x-height', 'x1', 'x2', 'xlink:actuate', 'xlink:arcrole', - 'xlink:href', 'xlink:role', 'xlink:show', 'xlink:title', 'xlink:type', - 'xml:base', 'xml:lang', 'xml:space', 'xmlns', 'xmlns:xlink', 'y', 'y1', - 'y2', 'zoomAndPan'] - - svg_attr_map = None - svg_elem_map = None - - acceptable_svg_properties = ['fill', 'fill-opacity', 'fill-rule', - 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', - 'stroke-opacity'] - - def reset(self): - _BaseHTMLProcessor.reset(self) - self.unacceptablestack = 0 - self.mathmlOK = 0 - self.svgOK = 0 - - def unknown_starttag(self, tag, attrs): - acceptable_attributes = self.acceptable_attributes - keymap = {} - if tag not in self.acceptable_elements or self.svgOK: - if tag in self.unacceptable_elements_with_end_tag: - self.unacceptablestack += 1 - - # not otherwise acceptable, perhaps it is MathML or SVG? - if tag == 'math' and ('xmlns', 'http://www.w3.org/1998/Math/MathML') in attrs: - self.mathmlOK += 1 - if tag == 'svg' and ('xmlns', 'http://www.w3.org/2000/svg') in attrs: - self.svgOK += 1 - - # chose acceptable attributes based on tag class, else bail - if self.mathmlOK and tag in self.mathml_elements: - acceptable_attributes = self.mathml_attributes - elif self.svgOK and tag in self.svg_elements: - # for most vocabularies, lowercasing is a good idea. Many - # svg elements, however, are camel case - if not self.svg_attr_map: - lower = [attr.lower() for attr in self.svg_attributes] - mix = [a for a in self.svg_attributes if a not in lower] - self.svg_attributes = lower - self.svg_attr_map = dict([(a.lower(), a) for a in mix]) - - lower = [attr.lower() for attr in self.svg_elements] - mix = [a for a in self.svg_elements if a not in lower] - self.svg_elements = lower - self.svg_elem_map = dict([(a.lower(), a) for a in mix]) - acceptable_attributes = self.svg_attributes - tag = self.svg_elem_map.get(tag, tag) - keymap = self.svg_attr_map - elif tag not in self.acceptable_elements: - return - - # declare xlink namespace, if needed - if self.mathmlOK or self.svgOK: - if any(map(lambda n, v: n.startswith('xlink:'), attrs)): - if not ('xmlns:xlink', 'http://www.w3.org/1999/xlink') in attrs: - attrs.append(('xmlns:xlink', 'http://www.w3.org/1999/xlink')) - - clean_attrs = [] - for key, value in self.normalize_attrs(attrs): - if key == "href" and value.strip().startswith("javascript"): - pass - elif key in acceptable_attributes: - key = keymap.get(key, key) - clean_attrs.append((key, value)) - elif key == 'style': - pass - # clean_value = self.sanitize_style(value) - # if clean_value: clean_attrs.append((key,clean_value)) - _BaseHTMLProcessor.unknown_starttag(self, tag, clean_attrs) - - def unknown_endtag(self, tag): - if tag not in self.acceptable_elements: - if tag in self.unacceptable_elements_with_end_tag: - self.unacceptablestack -= 1 - if self.mathmlOK and tag in self.mathml_elements: - if tag == 'math' and self.mathmlOK: - self.mathmlOK -= 1 - elif self.svgOK and tag in self.svg_elements: - tag = self.svg_elem_map.get(tag, tag) - if tag == 'svg' and self.svgOK: - self.svgOK -= 1 - else: - return - _BaseHTMLProcessor.unknown_endtag(self, tag) - - def handle_pi(self, text): - pass - - def handle_decl(self, text): - pass - - def handle_data(self, text): - if not self.unacceptablestack: - _BaseHTMLProcessor.handle_data(self, text) - - def sanitize_style(self, style): - # disallow urls - style = re.compile('url\s*\(\s*[^\s)]+?\s*\)\s*').sub(' ', style) - - # gauntlet - if not re.match("""^([:,;#%.\sa-zA-Z0-9!]|\w-\w|'[\s\w]+'|"[\s\w]+"|\([\d,\s]+\))*$""", style): - return '' - if not re.match("^(\s*[-\w]+\s*:\s*[^:;]*(;|$))*$", style): - return '' - - clean = [] - for prop, value in re.findall("([-\w]+)\s*:\s*([^:;]*)", style): - if not value: - continue - if prop.lower() in self.acceptable_css_properties: - clean.append(prop + ': ' + value + ';') - elif prop.split('-')[0].lower() in ['background', 'border', 'margin', 'padding']: - for keyword in value.split(): - if keyword not in self.acceptable_css_keywords and \ - not self.valid_css_values.match(keyword): - break - else: - clean.append(prop + ': ' + value + ';') - elif self.svgOK and prop.lower() in self.acceptable_svg_properties: - clean.append(prop + ': ' + value + ';') - return ' '.join(clean) - - -def sanitize_html(htmlSource, encoding="utf-8", type="text/html"): - p = _HTMLSanitizer(encoding, type) - p.feed(unicodify(htmlSource, encoding)) - data = p.output() - data = data.strip().replace('\r\n', '\n') - return data +def sanitize_html(htmlSource): + return bleach.clean(htmlSource, tags=_acceptable_elements, attributes=_acceptable_attributes, strip=True) diff --git a/lib/galaxy/util/validation.py b/lib/galaxy/util/validation.py index a851148c5e5..1ecf60d129e 100644 --- a/lib/galaxy/util/validation.py +++ b/lib/galaxy/util/validation.py @@ -12,13 +12,13 @@ def validate_and_sanitize_basestring(key, val): if not isinstance(val, string_types): raise exceptions.RequestParameterInvalidException('%s must be a string or unicode: %s' % (key, str(type(val)))) - return sanitize_html(val, 'utf-8', 'text/html') + return sanitize_html(val) def validate_and_sanitize_basestring_list(key, val): try: assert isinstance(val, list) - return [sanitize_html(t, 'utf-8', 'text/html') for t in val] + return [sanitize_html(t) for t in val] except (AssertionError, TypeError): raise exceptions.RequestParameterInvalidException('%s must be a list of strings: %s' % (key, str(type(val)))) @@ -32,7 +32,7 @@ def validate_boolean(key, val): # TODO: -# def validate_integer( self, key, val, min, max ): -# def validate_float( self, key, val, min, max ): -# def validate_number( self, key, val, min, max ): -# def validate_genome_build( self, key, val ): +# def validate_integer(self, key, val, min, max): +# def validate_float(self, key, val, min, max): +# def validate_number(self, key, val, min, max): +# def validate_genome_build(self, key, val): diff --git a/lib/galaxy/visualization/plugins/interactive_environments.py b/lib/galaxy/visualization/plugins/interactive_environments.py index edb108eb864..10cb067282d 100644 --- a/lib/galaxy/visualization/plugins/interactive_environments.py +++ b/lib/galaxy/visualization/plugins/interactive_environments.py @@ -6,6 +6,7 @@ import shlex import stat import string import tempfile +import time import uuid from itertools import product from subprocess import PIPE, Popen @@ -442,7 +443,20 @@ class InteractiveEnvironmentRequest(object): """ run_args = self.container_run_args(image, env_override, volumes) container = self.attr.container_interface.run_in_container(None, **run_args) - container_port = self._find_port_mapping(container.ports) + attempt = 0 + container_ports = container.ports + while container_ports is None and attempt < 30: + # TODO: it would be better to do this in /interactive_environments/ready so the client doesn't block here, + # but _find_port_mapping needs certain non-persisted data (the port configured to be published) and the + # proxy manager doesn't have an update method, so that'd require bigger changes than I have the time for + # right now + attempt += 1 + log.warning("Sleeping for 2 seconds while waiting for container %s ports", container.id) + time.sleep(2) + container_ports = container.ports + if container_ports is None: + raise Exception("Failed to determine ports for container '%s' after 30 attempts" % container.id) + container_port = self._find_port_mapping(container_ports) log.debug("Container '%s' accessible at: %s:%s", container.id, container_port.hostaddr, container_port.hostport) self.attr.proxy_request = self.trans.app.proxy_manager.setup_proxy( self.trans, diff --git a/lib/galaxy/visualization/plugins/plugin.py b/lib/galaxy/visualization/plugins/plugin.py index 443dbfdf7c7..c5ba7fbb583 100644 --- a/lib/galaxy/visualization/plugins/plugin.py +++ b/lib/galaxy/visualization/plugins/plugin.py @@ -269,6 +269,14 @@ class InteractiveEnvironmentPlugin(VisualizationPlugin): context['base_url'] = 'interactive_environments' super(InteractiveEnvironmentPlugin, self).__init__(app, path, name, config, context=context, **kwargs) + def _error_template(self, trans): + return trans.fill_template('message.mako', + message='Loading the interactive environment failed, please contact the {admin_tag} for assistance'.format( + admin_tag='
Galaxy administrator'.format( + admin_mail=trans.app.config.error_email_to) + if trans.app.config.error_email_to else 'Galaxy administrator'), + status='error') + def _render(self, render_vars, trans=None, embedded=None, **kwargs): """ Override to add interactive environment specific template vars. @@ -294,16 +302,15 @@ class InteractiveEnvironmentPlugin(VisualizationPlugin): request = self.INTENV_REQUEST_FACTORY(trans, self) except Exception: log.exception("IE plugin request handling failed") - return trans.fill_template('message.mako', - message='Loading the interactive environment failed, please contact the {admin_tag} for assistance'.format( - admin_tag='Galaxy administrator'.format( - admin_mail=trans.app.config.error_email_to) - if trans.app.config.error_email_to else 'Galaxy administrator'), - status='error') + return self._error_template(trans) render_vars["ie_request"] = request template_filename = self.config['entry_point']['file'] - return trans.fill_template(template_filename, template_lookup=self.template_lookup, **render_vars) + try: + return trans.fill_template(template_filename, template_lookup=self.template_lookup, **render_vars) + except Exception: + log.exception("IE plugin template fill failed") + return self._error_template(trans) class ScriptVisualizationPlugin(VisualizationPlugin): diff --git a/lib/galaxy/visualization/plugins/resource_parser.py b/lib/galaxy/visualization/plugins/resource_parser.py index 1ac6b65464a..ebc1754a277 100644 --- a/lib/galaxy/visualization/plugins/resource_parser.py +++ b/lib/galaxy/visualization/plugins/resource_parser.py @@ -29,7 +29,7 @@ class ResourceParser(object): new keys (e.g. dataset_id="NNN" -> hda=). """ primitive_parsers = { - 'str' : lambda param: galaxy.util.sanitize_html.sanitize_html(param, 'utf-8'), + 'str' : lambda param: galaxy.util.sanitize_html.sanitize_html(param), 'bool' : lambda param: galaxy.util.string_as_bool(param), 'int' : int, 'float' : float, @@ -217,7 +217,7 @@ class ResourceParser(object): # TODO: ideally this would check v. a list of valid dbkeys elif param_type == 'dbkey': dbkey = query_param - parsed_param = galaxy.util.sanitize_html.sanitize_html(dbkey, 'utf-8') + parsed_param = galaxy.util.sanitize_html.sanitize_html(dbkey) return parsed_param diff --git a/lib/galaxy/web/base/controller.py b/lib/galaxy/web/base/controller.py index 10c2fd4c01b..a7f25f5844e 100644 --- a/lib/galaxy/web/base/controller.py +++ b/lib/galaxy/web/base/controller.py @@ -517,7 +517,7 @@ class UsesLibraryMixinItems(SharableItemSecurityMixin): # PRECONDITION: folder_id has already been altered to remove the folder prefix ('F') # TODO: allow name and other, editable ldda attrs? if ldda_message: - ldda_message = util.sanitize_html.sanitize_html(ldda_message, 'utf-8') + ldda_message = sanitize_html(ldda_message) # check permissions on (all three?) resources: hda, library, folder # TODO: do we really need the library?? @@ -1121,7 +1121,7 @@ class UsesVisualizationMixin(UsesLibraryMixinItems): else: self.create_item_slug(trans.sa_session, visualization) if annotation: - annotation = sanitize_html(annotation, 'utf-8', 'text/html') + annotation = sanitize_html(annotation) # TODO: if this is to stay in the mixin, UsesAnnotations should be added to the superclasses # right now this is depending on the classes that include this mixin to have UsesAnnotations self.add_item_annotation(trans.sa_session, trans.user, visualization, annotation) diff --git a/lib/galaxy/web/framework/decorators.py b/lib/galaxy/web/framework/decorators.py index ef23ed0a127..9ce0ea099c2 100644 --- a/lib/galaxy/web/framework/decorators.py +++ b/lib/galaxy/web/framework/decorators.py @@ -134,7 +134,7 @@ def expose_api(func, to_json=True, user_required=True): trans.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" # Perform api_run_as processing, possibly changing identity - if 'payload' in kwargs and 'run_as' in kwargs['payload']: + if 'payload' in kwargs and isinstance(kwargs['payload'], dict) and 'run_as' in kwargs['payload']: if not trans.user_can_do_run_as(): error_message = 'User does not have permissions to run jobs as another user' return error diff --git a/lib/galaxy/webapps/galaxy/api/_fetch_util.py b/lib/galaxy/webapps/galaxy/api/_fetch_util.py new file mode 100644 index 00000000000..7c5e2ea8e54 --- /dev/null +++ b/lib/galaxy/webapps/galaxy/api/_fetch_util.py @@ -0,0 +1,217 @@ +import logging +import os + +from galaxy.actions.library import ( + validate_path_upload, + validate_server_directory_upload, +) +from galaxy.exceptions import ( + RequestParameterInvalidException +) +from galaxy.tools.actions.upload_common import validate_url +from galaxy.util import ( + relpath, +) + +log = logging.getLogger(__name__) + +VALID_DESTINATION_TYPES = ["library", "library_folder", "hdca", "hdas"] +ELEMENTS_FROM_TYPE = ["archive", "bagit", "bagit_archive", "directory"] +# These elements_from cannot be sym linked to because they only exist during upload. +ELEMENTS_FROM_TRANSIENT_TYPES = ["archive", "bagit_archive"] + + +def validate_and_normalize_targets(trans, payload): + """Validate and normalize all src references in fetch targets. + + - Normalize ftp_import and server_dir src entries into simple path entires + with the relevant paths resolved and permissions / configuration checked. + - Check for file:// URLs in items src of "url" and convert them into path + src items - after verifying path pastes are allowed and user is admin. + - Check for valid URLs to be fetched for http and https entries. + - Based on Galaxy configuration and upload types set purge_source and in_place + as needed for each upload. + """ + targets = payload.get("targets", []) + + for target in targets: + destination = _get_required_item(target, "destination", "Each target must specify a 'destination'") + destination_type = _get_required_item(destination, "type", "Each target destination must specify a 'type'") + if "object_id" in destination: + raise RequestParameterInvalidException("object_id not allowed to appear in the request.") + + if destination_type not in VALID_DESTINATION_TYPES: + template = "Invalid target destination type [%s] encountered, must be one of %s" + msg = template % (destination_type, VALID_DESTINATION_TYPES) + raise RequestParameterInvalidException(msg) + if destination_type == "library": + library_name = _get_required_item(destination, "name", "Must specify a library name") + description = destination.get("description", "") + synopsis = destination.get("synopsis", "") + library = trans.app.library_manager.create( + trans, library_name, description=description, synopsis=synopsis + ) + destination["type"] = "library_folder" + for key in ["name", "description", "synopsis"]: + if key in destination: + del destination[key] + destination["library_folder_id"] = trans.app.security.encode_id(library.root_folder.id) + + # Unlike upload.py we don't transmit or use run_as_real_user in the job - we just make sure + # in_place and purge_source are set on the individual upload fetch sources as needed based + # on this. + run_as_real_user = trans.app.config.external_chown_script is not None # See comment in upload.py + purge_ftp_source = getattr(trans.app.config, 'ftp_upload_purge', True) and not run_as_real_user + + payload["check_content"] = trans.app.config.check_upload_content + + def check_src(item): + if "object_id" in item: + raise RequestParameterInvalidException("object_id not allowed to appear in the request.") + + # Normalize file:// URLs into paths. + if item["src"] == "url" and item["url"].startswith("file://"): + item["src"] = "path" + item["path"] = item["url"][len("file://"):] + del item["path"] + + if "in_place" in item: + raise RequestParameterInvalidException("in_place cannot be set in the upload request") + + src = item["src"] + + # Check link_data_only can only be set for certain src types and certain elements_from types. + _handle_invalid_link_data_only_elements_type(item) + if src not in ["path", "server_dir"]: + _handle_invalid_link_data_only_type(item) + elements_from = item.get("elements_from", None) + if elements_from and elements_from not in ELEMENTS_FROM_TYPE: + raise RequestParameterInvalidException("Invalid elements_from/items_from found in request") + + if src == "path" or (src == "url" and item["url"].startswith("file:")): + # Validate is admin, leave alone. + validate_path_upload(trans) + elif src == "server_dir": + # Validate and replace with path definition. + server_dir = item["server_dir"] + full_path, _ = validate_server_directory_upload(trans, server_dir) + item["src"] = "path" + item["path"] = full_path + elif src == "ftp_import": + ftp_path = item["ftp_path"] + full_path = None + + # It'd be nice if this can be de-duplicated with what is in parameters/grouping.py. + user_ftp_dir = trans.user_ftp_dir + is_directory = False + + assert not os.path.islink(user_ftp_dir), "User FTP directory cannot be a symbolic link" + for (dirpath, dirnames, filenames) in os.walk(user_ftp_dir): + for filename in filenames: + if ftp_path == filename: + path = relpath(os.path.join(dirpath, filename), user_ftp_dir) + if not os.path.islink(os.path.join(dirpath, filename)): + full_path = os.path.abspath(os.path.join(user_ftp_dir, path)) + break + + for dirname in dirnames: + if ftp_path == dirname: + path = relpath(os.path.join(dirpath, dirname), user_ftp_dir) + if not os.path.islink(os.path.join(dirpath, dirname)): + full_path = os.path.abspath(os.path.join(user_ftp_dir, path)) + is_directory = True + break + + if is_directory: + # If the target is a directory - make sure no files under it are symbolic links + for (dirpath, dirnames, filenames) in os.walk(full_path): + for filename in filenames: + if ftp_path == filename: + path = relpath(os.path.join(dirpath, filename), full_path) + if not os.path.islink(os.path.join(dirpath, filename)): + full_path = False + break + + for dirname in dirnames: + if ftp_path == dirname: + path = relpath(os.path.join(dirpath, filename), full_path) + if not os.path.islink(os.path.join(dirpath, filename)): + full_path = False + break + + if not full_path: + raise RequestParameterInvalidException("Failed to find referenced ftp_path or symbolic link was enountered") + + item["src"] = "path" + item["path"] = full_path + item["purge_source"] = purge_ftp_source + elif src == "url": + url = item["url"] + looks_like_url = False + for url_prefix in ["http://", "https://", "ftp://", "ftps://"]: + if url.startswith(url_prefix): + looks_like_url = True + break + + if not looks_like_url: + raise RequestParameterInvalidException("Invalid URL [%s] found in src definition." % url) + + validate_url(url, trans.app.config.fetch_url_whitelist_ips) + item["in_place"] = run_as_real_user + elif src == "files": + item["in_place"] = run_as_real_user + + # Small disagreement with traditional uploads - we purge less by default since whether purging + # happens varies based on upload options in non-obvious ways. + # https://github.com/galaxyproject/galaxy/issues/5361 + if "purge_source" not in item: + item["purge_source"] = False + + _replace_request_syntax_sugar(targets) + _for_each_src(check_src, targets) + + +def _replace_request_syntax_sugar(obj): + # For data libraries and hdas to make sense - allow items and items_from in place of elements + # and elements_from. This is destructive and modifies the supplied request. + if isinstance(obj, list): + for el in obj: + _replace_request_syntax_sugar(el) + elif isinstance(obj, dict): + if "items" in obj: + obj["elements"] = obj["items"] + del obj["items"] + if "items_from" in obj: + obj["elements_from"] = obj["items_from"] + del obj["items_from"] + for value in obj.values(): + _replace_request_syntax_sugar(value) + + +def _handle_invalid_link_data_only_type(item): + link_data_only = item.get("link_data_only", False) + if link_data_only: + raise RequestParameterInvalidException("link_data_only is invalid for src type [%s]" % item.get("src")) + + +def _handle_invalid_link_data_only_elements_type(item): + link_data_only = item.get("link_data_only", False) + if link_data_only and item.get("elements_from", False) in ELEMENTS_FROM_TRANSIENT_TYPES: + raise RequestParameterInvalidException("link_data_only is invalid for derived elements from [%s]" % item.get("elements_from")) + + +def _get_required_item(from_dict, key, message): + if key not in from_dict: + raise RequestParameterInvalidException(message) + return from_dict[key] + + +def _for_each_src(f, obj): + if isinstance(obj, list): + for item in obj: + _for_each_src(f, item) + if isinstance(obj, dict): + if "src" in obj: + f(obj) + for key, value in obj.items(): + _for_each_src(f, value) diff --git a/lib/galaxy/webapps/galaxy/api/annotations.py b/lib/galaxy/webapps/galaxy/api/annotations.py index 50ea8f0931c..cfe9872ac61 100644 --- a/lib/galaxy/webapps/galaxy/api/annotations.py +++ b/lib/galaxy/webapps/galaxy/api/annotations.py @@ -8,7 +8,7 @@ from galaxy import ( managers ) from galaxy.model.item_attrs import UsesAnnotations -from galaxy.util import sanitize_html +from galaxy.util.sanitize_html import sanitize_html from galaxy.web import _future_expose_api as expose_api from galaxy.web.base.controller import ( BaseAPIController, @@ -36,7 +36,7 @@ class BaseAnnotationsController(BaseAPIController, UsesStoredWorkflowMixin, Uses if item is not None: new_annotation = payload.get("text") # TODO: sanitize on display not entry - new_annotation = sanitize_html.sanitize_html(new_annotation, 'utf-8', 'text/html') + new_annotation = sanitize_html(new_annotation) self.add_item_annotation(trans.sa_session, trans.get_user(), item, new_annotation) trans.sa_session.flush() diff --git a/lib/galaxy/webapps/galaxy/api/folder_contents.py b/lib/galaxy/webapps/galaxy/api/folder_contents.py index 041892c726b..b012d4a4c59 100644 --- a/lib/galaxy/webapps/galaxy/api/folder_contents.py +++ b/lib/galaxy/webapps/galaxy/api/folder_contents.py @@ -275,7 +275,7 @@ class FolderContentsController(BaseAPIController, UsesLibraryMixin, UsesLibraryM from_hdca_id = payload.pop('from_hdca_id', None) ldda_message = payload.pop('ldda_message', '') if ldda_message: - ldda_message = util.sanitize_html.sanitize_html(ldda_message, 'utf-8') + ldda_message = util.sanitize_html.sanitize_html(ldda_message) try: if from_hda_id: decoded_hda_id = self.decode_id(from_hda_id) diff --git a/lib/galaxy/webapps/galaxy/api/library_contents.py b/lib/galaxy/webapps/galaxy/api/library_contents.py index 5b873f24087..58201190f5c 100644 --- a/lib/galaxy/webapps/galaxy/api/library_contents.py +++ b/lib/galaxy/webapps/galaxy/api/library_contents.py @@ -185,7 +185,8 @@ class LibraryContentsController(BaseAPIController, UsesLibraryMixin, UsesLibrary * upload_option: (optional) one of 'upload_file' (default), 'upload_directory' or 'upload_paths' * server_dir: (optional, only if upload_option is 'upload_directory') relative path of the subdirectory of Galaxy - ``library_import_dir`` to upload. All and only the files (i.e. + ``library_import_dir`` (if admin) or ``user_library_import_dir`` + (if non-admin) to upload. All and only the files (i.e. no subdirectories) contained in the specified directory will be uploaded. * filesystem_paths: (optional, only if upload_option is diff --git a/lib/galaxy/webapps/galaxy/api/page_revisions.py b/lib/galaxy/webapps/galaxy/api/page_revisions.py index 31e8969eed5..978585ebec1 100644 --- a/lib/galaxy/webapps/galaxy/api/page_revisions.py +++ b/lib/galaxy/webapps/galaxy/api/page_revisions.py @@ -66,7 +66,7 @@ class PageRevisionsController(BaseAPIController, SharableItemSecurityMixin, Uses else: title = page.title - content = sanitize_html(content, 'utf-8', 'text/html') + content = sanitize_html(content) page_revision = trans.app.model.PageRevision() page_revision.title = title diff --git a/lib/galaxy/webapps/galaxy/api/pages.py b/lib/galaxy/webapps/galaxy/api/pages.py index baf046cf86a..73eb5c9cc5a 100644 --- a/lib/galaxy/webapps/galaxy/api/pages.py +++ b/lib/galaxy/webapps/galaxy/api/pages.py @@ -81,13 +81,13 @@ class PagesController(BaseAPIController, SharableItemSecurityMixin, UsesAnnotati raise exceptions.DuplicatedSlugException("Page slug must be unique") content = payload.get("content", "") - content = sanitize_html(content, 'utf-8', 'text/html') + content = sanitize_html(content) # Create the new stored page page = trans.app.model.Page() page.title = payload['title'] page.slug = payload['slug'] - page_annotation = sanitize_html(payload.get("annotation", ""), 'utf-8', 'text/html') + page_annotation = sanitize_html(payload.get("annotation", "")) self.add_item_annotation(trans.sa_session, trans.get_user(), page, page_annotation) page.user = user # And the first (empty) page revision diff --git a/lib/galaxy/webapps/galaxy/api/tools.py b/lib/galaxy/webapps/galaxy/api/tools.py index 412cc4082d8..08576ee812d 100644 --- a/lib/galaxy/webapps/galaxy/api/tools.py +++ b/lib/galaxy/webapps/galaxy/api/tools.py @@ -6,15 +6,23 @@ from six.moves.urllib.parse import unquote_plus import galaxy.queue_worker from galaxy import exceptions, managers, util, web from galaxy.managers.collections_util import dictify_dataset_collection_instance +from galaxy.util.json import safe_dumps +from galaxy.util.odict import odict from galaxy.visualization.genomes import GenomeRegion from galaxy.web import _future_expose_api as expose_api from galaxy.web import _future_expose_api_anonymous as expose_api_anonymous from galaxy.web import _future_expose_api_anonymous_and_sessionless as expose_api_anonymous_and_sessionless +from galaxy.web import _future_expose_api_raw_anonymous_and_sessionless as expose_api_raw_anonymous_and_sessionless from galaxy.web.base.controller import BaseAPIController from galaxy.web.base.controller import UsesVisualizationMixin +from ._fetch_util import validate_and_normalize_targets log = logging.getLogger(__name__) +# Do not allow these tools to be called directly - they (it) enforces extra security and +# provides access via a different API endpoint. +PROTECTED_TOOLS = ["__DATA_FETCH__"] + class ToolsController(BaseAPIController, UsesVisualizationMixin): """ @@ -98,6 +106,74 @@ class ToolsController(BaseAPIController, UsesVisualizationMixin): tool = self._get_tool(id, tool_version=tool_version, user=trans.user) return tool.to_json(trans, kwd.get('inputs', kwd)) + @expose_api + @web.require_admin + def test_data_path(self, trans, id, **kwd): + """ + GET /api/tools/{tool_id}/test_data_path?tool_version={tool_version} + """ + # TODO: eliminate copy and paste with above code. + if 'payload' in kwd: + kwd = kwd.get('payload') + tool_version = kwd.get('tool_version', None) + tool = self._get_tool(id, tool_version=tool_version, user=trans.user) + path = tool.test_data_path(kwd.get("filename")) + if path: + return path + else: + raise exceptions.ObjectNotFound("Specified test data path not found.") + + @expose_api_anonymous_and_sessionless + def tests_summary(self, trans, **kwd): + """ + GET /api/tools/tests_summary + + Fetch summary information for each tool and version combination with tool tests + defined. This summary information currently includes tool name and a count of + the tests. + + Fetch complete test data for each tool with /api/tools/{tool_id}/test_data?tool_version= + """ + test_counts_by_tool = {} + for id, tool in self.app.toolbox.tools(): + tests = tool.tests + if tests: + if tool.id not in test_counts_by_tool: + test_counts_by_tool[tool.id] = {} + available_versions = test_counts_by_tool[tool.id] + available_versions[tool.version] = { + "tool_name": tool.name, + "count": len(tests), + } + return test_counts_by_tool + + @expose_api_raw_anonymous_and_sessionless + def test_data(self, trans, id, **kwd): + """ + GET /api/tools/{tool_id}/test_data?tool_version={tool_version} + + This API endpoint is unstable and experimental. In particular the format of the + response has not been entirely nailed down (it exposes too many Galaxy + internals/Pythonisms in a rough way). If this endpoint is being used from outside + of scripts shipped with Galaxy let us know and please be prepared for the response + from this API to change its format in some ways. + """ + # TODO: eliminate copy and paste with above code. + if 'payload' in kwd: + kwd = kwd.get('payload') + tool_version = kwd.get('tool_version', None) + tool = self._get_tool(id, tool_version=tool_version, user=trans.user) + + # Encode in this method to handle odict objects in tool representation. + def json_encodeify(obj): + if isinstance(obj, odict): + return dict(obj) + else: + return obj + + result = [t.to_dict() for t in tool.tests] + return safe_dumps(result, default=json_encodeify) + @expose_api @web.require_admin def reload(self, trans, id, **kwd): @@ -290,12 +366,52 @@ class ToolsController(BaseAPIController, UsesVisualizationMixin): trans.response.headers["Content-Disposition"] = 'attachment; filename="%s.tgz"' % (id) return download_file + @expose_api_anonymous + def fetch(self, trans, payload, **kwd): + """Adapt clean API to tool-constrained API. + """ + log.info("Keywords are %s" % payload) + request_version = '1' + history_id = payload.pop("history_id") + clean_payload = {} + files_payload = {} + for key, value in payload.items(): + if key == "key": + continue + if key.startswith('files_') or key.startswith('__files_'): + files_payload[key] = value + continue + clean_payload[key] = value + log.info("payload %s" % clean_payload) + validate_and_normalize_targets(trans, clean_payload) + clean_payload["check_content"] = trans.app.config.check_upload_content + request = dumps(clean_payload) + log.info(request) + create_payload = { + 'tool_id': "__DATA_FETCH__", + 'history_id': history_id, + 'inputs': { + 'request_version': request_version, + 'request_json': request, + }, + } + create_payload.update(files_payload) + return self._create(trans, create_payload, **kwd) + @expose_api_anonymous def create(self, trans, payload, **kwd): """ POST /api/tools Executes tool using specified inputs and returns tool's outputs. """ + tool_id = payload.get("tool_id") + if tool_id in PROTECTED_TOOLS: + raise exceptions.RequestParameterInvalidException("Cannot execute tool [%s] directly, must use alternative endpoint." % tool_id) + if tool_id is None: + raise exceptions.RequestParameterInvalidException("Must specify a valid tool_id to use this endpoint.") + return self._create(trans, payload, **kwd) + + def _create(self, trans, payload, **kwd): # HACK: for now, if action is rerun, rerun tool. action = payload.get('action', None) if action == 'rerun': diff --git a/lib/galaxy/webapps/galaxy/api/uploads.py b/lib/galaxy/webapps/galaxy/api/uploads.py new file mode 100644 index 00000000000..bedf6daf061 --- /dev/null +++ b/lib/galaxy/webapps/galaxy/api/uploads.py @@ -0,0 +1,53 @@ +""" +API operations for uploaded files in storage. +""" +import logging +import os +import re + +from galaxy.exceptions import MessageException, NotImplemented +from galaxy.web import expose_api_anonymous +from galaxy.web.base.controller import BaseAPIController + +log = logging.getLogger(__name__) + + +class UploadsAPIController(BaseAPIController): + + READ_CHUNK_SIZE = 2 ** 16 + + @expose_api_anonymous + def index(self, trans, **kwd): + raise NotImplemented("Listing uploads is not implemented.") + + @expose_api_anonymous + def create(self, trans, payload, **kwd): + """ + POST /api/uploads/ + """ + session_id = payload.get("session_id") + session_start = payload.get("session_start") + session_chunk = payload.get("session_chunk") + if re.match('^[\w-]+$', session_id) is None: + raise MessageException("Requires a session id.") + if session_start is None: + raise MessageException("Requires a session start.") + if not hasattr(session_chunk, "file"): + raise MessageException("Requires a session chunk.") + target_file = os.path.join(trans.app.config.new_file_path, session_id) + target_size = 0 + if os.path.exists(target_file): + target_size = os.path.getsize(target_file) + if session_start != target_size: + raise MessageException("Incorrect session start.") + chunk_size = os.fstat(session_chunk.file.fileno()).st_size + if chunk_size > trans.app.config.chunk_upload_size: + raise MessageException("Invalid chunk size.") + with open(target_file, "a") as f: + while True: + read_chunk = session_chunk.file.read(self.READ_CHUNK_SIZE) + if not read_chunk: + break + f.write(read_chunk) + session_chunk.file.close() + return {"message": "Successful."} diff --git a/lib/galaxy/webapps/galaxy/api/visualizations.py b/lib/galaxy/webapps/galaxy/api/visualizations.py index de44a81fe6a..63f3c42d0e9 100644 --- a/lib/galaxy/webapps/galaxy/api/visualizations.py +++ b/lib/galaxy/webapps/galaxy/api/visualizations.py @@ -176,7 +176,7 @@ class VisualizationsController(BaseAPIController, UsesVisualizationMixin, Sharab if key == 'type': if not isinstance(val, string_types): raise ValidationError('%s must be a string or unicode: %s' % (key, str(type(val)))) - val = util.sanitize_html.sanitize_html(val, 'utf-8') + val = util.sanitize_html.sanitize_html(val) elif key == 'config': if not isinstance(val, dict): raise ValidationError('%s must be a dictionary: %s' % (key, str(type(val)))) @@ -184,22 +184,22 @@ class VisualizationsController(BaseAPIController, UsesVisualizationMixin, Sharab elif key == 'annotation': if not isinstance(val, string_types): raise ValidationError('%s must be a string or unicode: %s' % (key, str(type(val)))) - val = util.sanitize_html.sanitize_html(val, 'utf-8') + val = util.sanitize_html.sanitize_html(val) # these are keys that actually only be *updated* at the revision level and not here # (they are still valid for create, tho) elif key == 'title': if not isinstance(val, string_types): raise ValidationError('%s must be a string or unicode: %s' % (key, str(type(val)))) - val = util.sanitize_html.sanitize_html(val, 'utf-8') + val = util.sanitize_html.sanitize_html(val) elif key == 'slug': if not isinstance(val, string_types): raise ValidationError('%s must be a string: %s' % (key, str(type(val)))) - val = util.sanitize_html.sanitize_html(val, 'utf-8') + val = util.sanitize_html.sanitize_html(val) elif key == 'dbkey': if not isinstance(val, string_types): raise ValidationError('%s must be a string or unicode: %s' % (key, str(type(val)))) - val = util.sanitize_html.sanitize_html(val, 'utf-8') + val = util.sanitize_html.sanitize_html(val) elif key not in valid_but_uneditable_keys: continue diff --git a/lib/galaxy/webapps/galaxy/buildapp.py b/lib/galaxy/webapps/galaxy/buildapp.py index c2c10940df9..f303fbfa92a 100644 --- a/lib/galaxy/webapps/galaxy/buildapp.py +++ b/lib/galaxy/webapps/galaxy/buildapp.py @@ -256,6 +256,7 @@ def populate_api_routes(webapp, app): webapp.mapper.resource('dataset_collection', 'dataset_collections', path_prefix='/api/') webapp.mapper.resource('form', 'forms', path_prefix='/api') webapp.mapper.resource('role', 'roles', path_prefix='/api') + webapp.mapper.resource('upload', 'uploads', path_prefix='/api') webapp.mapper.connect('/api/ftp_files', controller='remote_files') webapp.mapper.resource('remote_file', 'remote_files', path_prefix='/api') webapp.mapper.resource('group', 'groups', path_prefix='/api') @@ -271,9 +272,13 @@ def populate_api_routes(webapp, app): # ====== TOOLS API ====== # ======================= + webapp.mapper.connect('/api/tools/fetch', action='fetch', controller='tools', conditions=dict(method=["POST"])) webapp.mapper.connect('/api/tools/all_requirements', action='all_requirements', controller="tools") webapp.mapper.connect('/api/tools/{id:.+?}/build', action='build', controller="tools") webapp.mapper.connect('/api/tools/{id:.+?}/reload', action='reload', controller="tools") + webapp.mapper.connect('/api/tools/tests_summary', action='tests_summary', controller="tools") + webapp.mapper.connect('/api/tools/{id:.+?}/test_data_path', action='test_data_path', controller="tools") + webapp.mapper.connect('/api/tools/{id:.+?}/test_data', action='test_data', controller="tools") webapp.mapper.connect('/api/tools/{id:.+?}/diagnostics', action='diagnostics', controller="tools") webapp.mapper.connect('/api/tools/{id:.+?}/citations', action='citations', controller="tools") webapp.mapper.connect('/api/tools/{id:.+?}/download', action='download', controller="tools") diff --git a/lib/galaxy/webapps/galaxy/config_schema.yml b/lib/galaxy/webapps/galaxy/config_schema.yml index 6c365e68b64..5679ffb01f1 100644 --- a/lib/galaxy/webapps/galaxy/config_schema.yml +++ b/lib/galaxy/webapps/galaxy/config_schema.yml @@ -1239,6 +1239,15 @@ mapping: operations on the remote end. See the Galaxy nginx documentation for the corresponding nginx configuration. + chunk_upload_size: + type: int + default: 104857600 + required: false + desc: | + Galaxy can upload user files in chunks without using nginx. Enable the chunk + uploader by specifying a chunk size larger than 0. The chunk size is specified + in bytes (default: 100MB). + dynamic_proxy_manage: type: bool default: true @@ -1817,6 +1826,18 @@ mapping: desc: | Set maximum size of ngrams + tool_test_data_directories: + type: str + default: 'test-data' + required: false + desc: | + Set tool test data directory. The test framework sets this value to + 'test-data,https://github.com/galaxyproject/galaxy-test-data.git' which will + cause Galaxy to clone down extra test data on the fly for certain tools + distributed with Galaxy but this is likely not appropriate for production systems. + Instead one can simply clone that repository directly and specify a path here + instead of a Git HTTP repository. + id_secret: type: str default: USING THE DEFAULT IS NOT SECURE! @@ -2518,29 +2539,6 @@ mapping: overwrite default job resources such as number of processors, memory and walltime. - workflow_resource_params_file: - type: str - default: config/workflow_resource_params_conf.xml - required: false - desc: | - Similar to the above parameter, workflows can describe parameters used to - influence scheduling of jobs within the workflow. This requires both a description - of the fields available (which defaults to the definitions in - job_resource_params_file if not set). - - workflow_resource_params_mapper: - type: str - default: config/workflow_resource_mapper_conf.yml - required: false - desc: | - This parameter describes how to map users and workflows to a set of workflow - resource parameter to present (typically input IDs from workflow_resource_params_file). - If this this is a function reference it will be passed various inputs (workflow model - object and user) and it should produce a list of input IDs. If it is a path - it is expected to an XML or YAML file describing how to map group names to parameter - descriptions (additional types of mappings via these files could be implemented but - haven't yet - for instance using workflow tags to do the mapping). - cache_user_job_count: type: bool default: false diff --git a/lib/galaxy/webapps/galaxy/controllers/dataset.py b/lib/galaxy/webapps/galaxy/controllers/dataset.py index 6b1b009aaad..8382ad48ebe 100644 --- a/lib/galaxy/webapps/galaxy/controllers/dataset.py +++ b/lib/galaxy/webapps/galaxy/controllers/dataset.py @@ -435,7 +435,7 @@ class DatasetInterface(BaseUIController, UsesAnnotations, UsesItemRatings, UsesE data.datatype.after_setting_metadata(data) # Sanitize annotation before adding it. if payload.get('annotation'): - annotation = sanitize_html(payload.get('annotation'), 'utf-8', 'text/html') + annotation = sanitize_html(payload.get('annotation')) self.add_item_annotation(trans.sa_session, trans.get_user(), data, annotation) # if setting metadata previously failed and all required elements have now been set, clear the failed state. if data._state == trans.model.Dataset.states.FAILED_METADATA and not data.missing_meta(): @@ -700,7 +700,7 @@ class DatasetInterface(BaseUIController, UsesAnnotations, UsesItemRatings, UsesE web.httpexceptions.HTTPNotFound() if dataset and new_annotation: # Sanitize annotation before adding it. - new_annotation = sanitize_html(new_annotation, 'utf-8', 'text/html') + new_annotation = sanitize_html(new_annotation) self.add_item_annotation(trans.sa_session, trans.get_user(), dataset, new_annotation) trans.sa_session.flush() return new_annotation diff --git a/lib/galaxy/webapps/galaxy/controllers/history.py b/lib/galaxy/webapps/galaxy/controllers/history.py index 2e2d1909af5..9d51b8a7618 100644 --- a/lib/galaxy/webapps/galaxy/controllers/history.py +++ b/lib/galaxy/webapps/galaxy/controllers/history.py @@ -494,11 +494,10 @@ class HistoryController(BaseUIController, SharableMixin, UsesAnnotations, UsesIt history = trans.sa_session.query(model.History).options( eagerload_all('active_datasets.creating_job_associations.job.workflow_invocation_step.workflow_invocation.workflow'), ).get(id) - assert history - # TODO: formalize to trans.show_error - assert (history.user and (history.user.id == trans.user.id) or - (history.id == trans.history.id) or - (trans.user_is_admin())) + if not (history and ((history.user and trans.user and history.user.id == trans.user.id) or + (trans.history and history.id == trans.history.id) or + trans.user_is_admin())): + return trans.show_error_message("Cannot display history structure.") # Resolve jobs and workflow invocations for the datasets in the history # items is filled with items (hdas, jobs, or workflows) that go at the # top level @@ -1127,7 +1126,7 @@ class HistoryController(BaseUIController, SharableMixin, UsesAnnotations, UsesIt @web.expose def purge_deleted_datasets(self, trans): count = 0 - if trans.app.config.allow_user_dataset_purge: + if trans.app.config.allow_user_dataset_purge and trans.history: for hda in trans.history.datasets: if not hda.deleted or hda.purged: continue @@ -1145,7 +1144,8 @@ class HistoryController(BaseUIController, SharableMixin, UsesAnnotations, UsesIt except Exception: log.exception('Unable to purge dataset (%s) on purge of hda (%s):' % (hda.dataset.id, hda.id)) count += 1 - return trans.show_ok_message("%d datasets have been deleted permanently" % count, refresh_frames=['history']) + return trans.show_ok_message("%d datasets have been deleted permanently" % count, refresh_frames=['history']) + return trans.show_error_message("Cannot purge deleted datasets from this session.") @web.expose def delete(self, trans, id, purge=False): diff --git a/lib/galaxy/webapps/galaxy/controllers/page.py b/lib/galaxy/webapps/galaxy/controllers/page.py index 2cff6220813..943dd0da10f 100644 --- a/lib/galaxy/webapps/galaxy/controllers/page.py +++ b/lib/galaxy/webapps/galaxy/controllers/page.py @@ -1,16 +1,75 @@ +import re from json import loads from markupsafe import escape -from sqlalchemy import and_, desc, false, true -from sqlalchemy.orm import eagerload, undefer +from six.moves.html_entities import name2codepoint +from six.moves.html_parser import HTMLParser +from sqlalchemy import ( + and_, + desc, + false, + true +) +from sqlalchemy.orm import ( + eagerload, + undefer +) -from galaxy import managers, model, util, web +from galaxy import ( + managers, + model, + util, + web +) from galaxy.model.item_attrs import UsesItemRatings from galaxy.util import unicodify -from galaxy.util.sanitize_html import _BaseHTMLProcessor, sanitize_html -from galaxy.web import error, url_for -from galaxy.web.base.controller import BaseUIController, SharableMixin, UsesStoredWorkflowMixin, UsesVisualizationMixin -from galaxy.web.framework.helpers import grids, time_ago +from galaxy.util.sanitize_html import sanitize_html +from galaxy.web import ( + error, + url_for +) +from galaxy.web.base.controller import ( + BaseUIController, + SharableMixin, + UsesStoredWorkflowMixin, + UsesVisualizationMixin +) +from galaxy.web.framework.helpers import ( + grids, + time_ago +) + + +# Copied from https://github.com/kurtmckee/feedparser +_cp1252 = { + 128: u'\u20ac', # euro sign + 130: u'\u201a', # single low-9 quotation mark + 131: u'\u0192', # latin small letter f with hook + 132: u'\u201e', # double low-9 quotation mark + 133: u'\u2026', # horizontal ellipsis + 134: u'\u2020', # dagger + 135: u'\u2021', # double dagger + 136: u'\u02c6', # modifier letter circumflex accent + 137: u'\u2030', # per mille sign + 138: u'\u0160', # latin capital letter s with caron + 139: u'\u2039', # single left-pointing angle quotation mark + 140: u'\u0152', # latin capital ligature oe + 142: u'\u017d', # latin capital letter z with caron + 145: u'\u2018', # left single quotation mark + 146: u'\u2019', # right single quotation mark + 147: u'\u201c', # left double quotation mark + 148: u'\u201d', # right double quotation mark + 149: u'\u2022', # bullet + 150: u'\u2013', # en dash + 151: u'\u2014', # em dash + 152: u'\u02dc', # small tilde + 153: u'\u2122', # trade mark sign + 154: u'\u0161', # latin small letter s with caron + 155: u'\u203a', # single right-pointing angle quotation mark + 156: u'\u0153', # latin small ligature oe + 158: u'\u017e', # latin small letter z with caron + 159: u'\u0178', # latin capital letter y with diaeresis +} def format_bool(b): @@ -223,18 +282,52 @@ class VisualizationSelectionGrid(ItemSelectionGrid): ) -class _PageContentProcessor(_BaseHTMLProcessor): - """ Processes page content to produce HTML that is suitable for display. For now, processor renders embedded objects. """ +# Adapted from the _BaseHTMLProcessor class of https://github.com/kurtmckee/feedparser +class _PageContentProcessor(HTMLParser, object): + """ + Processes page content to produce HTML that is suitable for display. + For now, processor renders embedded objects. + """ + bare_ampersand = re.compile("&(?!#\d+;|#x[0-9a-fA-F]+;|\w+;)") + elements_no_end_tag = set([ + 'area', 'base', 'basefont', 'br', 'col', 'command', 'embed', 'frame', + 'hr', 'img', 'input', 'isindex', 'keygen', 'link', 'meta', 'param', + 'source', 'track', 'wbr' + ]) - def __init__(self, trans, encoding, type, render_embed_html_fn): - _BaseHTMLProcessor.__init__(self, encoding, type) + def __init__(self, trans, render_embed_html_fn): + HTMLParser.__init__(self) self.trans = trans self.ignore_content = False self.num_open_tags_for_ignore = 0 self.render_embed_html_fn = render_embed_html_fn - def unknown_starttag(self, tag, attrs): - """ Called for each start tag; attrs is a list of (attr, value) tuples. """ + def reset(self): + self.pieces = [] + HTMLParser.reset(self) + + def _shorttag_replace(self, match): + tag = match.group(1) + if tag in self.elements_no_end_tag: + return '<' + tag + ' />' + else: + return '<' + tag + '>' + + def feed(self, data): + data = re.compile(r'\s]+?)\s*/>', self._shorttag_replace, data) + data = data.replace(''', "'") + data = data.replace('"', '"') + HTMLParser.feed(self, data) + HTMLParser.close(self) + + def handle_starttag(self, tag, attrs): + """ + Called for each start tag + + attrs is a list of (attr, value) tuples, e.g. for
,
+        tag='pre', attrs=[('class', 'screen')]
+        """
 
         # If ignoring content, just increment tag count and ignore.
         if self.ignore_content:
@@ -264,17 +357,25 @@ class _PageContentProcessor(_BaseHTMLProcessor):
             return
 
         # Default behavior: not ignoring and no embedded content.
-        _BaseHTMLProcessor.unknown_starttag(self, tag, attrs)
+        uattrs = []
+        strattrs = ''
+        if attrs:
+            for key, value in attrs:
+                value = value.replace('>', '>').replace('<', '<').replace('"', '"')
+                value = self.bare_ampersand.sub("&", value)
+                uattrs.append((key, value))
+            strattrs = ''.join(' %s="%s"' % (k, v) for k, v in uattrs)
+        if tag in self.elements_no_end_tag:
+            self.pieces.append('<%s%s />' % (tag, strattrs))
+        else:
+            self.pieces.append('<%s%s>' % (tag, strattrs))
 
-    def handle_data(self, text):
-        """ Called for each block of plain text. """
-        if self.ignore_content:
-            return
-        _BaseHTMLProcessor.handle_data(self, text)
-
-    def unknown_endtag(self, tag):
-        """ Called for each end tag. """
+    def handle_endtag(self, tag):
+        """
+        Called for each end tag
 
+        E.g. for 
, tag will be 'pre' + """ # If ignoring content, see if current tag is the end of content to ignore. if self.ignore_content: self.num_open_tags_for_ignore -= 1 @@ -283,8 +384,63 @@ class _PageContentProcessor(_BaseHTMLProcessor): self.ignore_content = False return - # Default behavior: - _BaseHTMLProcessor.unknown_endtag(self, tag) + # Default behavior: reconstruct the original end tag. + if tag not in self.elements_no_end_tag: + self.pieces.append("" % tag) + + def handle_charref(self, ref): + # called for each character reference, e.g. for ' ', ref will be '160' + # Reconstruct the original character reference. + ref = ref.lower() + if ref.startswith('x'): + value = int(ref[1:], 16) + else: + value = int(ref) + + if value in _cp1252: + self.pieces.append('&#%s;' % hex(ord(_cp1252[value]))[1:]) + else: + self.pieces.append('&#%s;' % ref) + + def handle_entityref(self, ref): + # called for each entity reference, e.g. for '©', ref will be 'copy' + # Reconstruct the original entity reference. + if ref in name2codepoint or ref == 'apos': + self.pieces.append('&%s;' % ref) + else: + self.pieces.append('&%s' % ref) + + def handle_data(self, text): + """ + Called for each block of plain text + + Called outside of any tag and not containing any character or entity + references. Store the original text verbatim. + """ + if self.ignore_content: + return + self.pieces.append(text) + + def handle_comment(self, text): + # called for each HTML comment, e.g. + # Reconstruct the original comment. + self.pieces.append('' % text) + + def handle_decl(self, text): + # called for the DOCTYPE, if present, e.g. + # + # Reconstruct original DOCTYPE + self.pieces.append('' % text) + + def handle_pi(self, text): + # called for each processing instruction, e.g. + # Reconstruct original processing instruction. + self.pieces.append('' % text) + + def output(self): + '''Return processed HTML as a single string''' + return ''.join(self.pieces) class PageController(BaseUIController, SharableMixin, @@ -387,7 +543,7 @@ class PageController(BaseUIController, SharableMixin, p.slug = p_slug p.user = user if p_annotation: - p_annotation = sanitize_html(p_annotation, 'utf-8', 'text/html') + p_annotation = sanitize_html(p_annotation) self.add_item_annotation(trans.sa_session, user, p, p_annotation) # And the first (empty) page revision p_revision = model.PageRevision() @@ -449,7 +605,7 @@ class PageController(BaseUIController, SharableMixin, p.title = p_title p.slug = p_slug if p_annotation: - p_annotation = sanitize_html(p_annotation, 'utf-8', 'text/html') + p_annotation = sanitize_html(p_annotation) self.add_item_annotation(trans.sa_session, user, p, p_annotation) trans.sa_session.add(p) trans.sa_session.flush() @@ -550,7 +706,7 @@ class PageController(BaseUIController, SharableMixin, assert page.user == trans.user # Sanitize content - content = sanitize_html(content, 'utf-8', 'text/html') + content = sanitize_html(content) # Add a new revision to the page with the provided content. page_revision = model.PageRevision() @@ -567,7 +723,7 @@ class PageController(BaseUIController, SharableMixin, item = trans.sa_session.query(item_class).filter_by(id=item_id).first() if not item: raise RuntimeError("cannot find annotated item") - text = sanitize_html(annotation_dict['text'], 'utf-8', 'text/html') + text = sanitize_html(annotation_dict['text']) # Add/update annotation. if item_id and item_class and text: @@ -615,8 +771,10 @@ class PageController(BaseUIController, SharableMixin, self.security_check(trans, page, False, True) # Process page content. - processor = _PageContentProcessor(trans, 'utf-8', 'text/html', self._get_embed_html) + processor = _PageContentProcessor(trans, self._get_embed_html) processor.feed(page.latest_revision.content) + # Output is string, so convert to unicode for display. + page_content = unicodify(processor.output(), 'utf-8') # Get rating data. user_item_rating = 0 @@ -628,8 +786,6 @@ class PageController(BaseUIController, SharableMixin, user_item_rating = 0 ave_item_rating, num_ratings = self.get_ave_item_rating_data(trans.sa_session, page) - # Output is string, so convert to unicode for display. - page_content = unicodify(processor.output(), 'utf-8') return trans.fill_template_mako("page/display.mako", item=page, item_data=page_content, user_item_rating=user_item_rating, diff --git a/lib/galaxy/webapps/galaxy/controllers/root.py b/lib/galaxy/webapps/galaxy/controllers/root.py index ca727ea6889..ec732b7066b 100644 --- a/lib/galaxy/webapps/galaxy/controllers/root.py +++ b/lib/galaxy/webapps/galaxy/controllers/root.py @@ -108,7 +108,6 @@ class RootController(controller.JSAppLauncher, UsesAnnotations): js_options = self._get_js_options(trans) config = js_options['config'] config.update(self._get_extended_config(trans)) - return self.template(trans, 'analysis', options=js_options) @web.expose diff --git a/lib/galaxy/webapps/galaxy/controllers/user.py b/lib/galaxy/webapps/galaxy/controllers/user.py index 638659cf031..96ba8b0a786 100644 --- a/lib/galaxy/webapps/galaxy/controllers/user.py +++ b/lib/galaxy/webapps/galaxy/controllers/user.py @@ -925,7 +925,9 @@ class User(BaseUIController, UsesFormDefinitionsMixin, CreatesUsersMixin, Create Check whether token fits the user and then activate the user's account. """ params = util.Params(kwd, sanitize=False) - email = unquote(params.get('email', None)) + email = params.get('email', None) + if email is not None: + email = unquote(email) activation_token = params.get('activation_token', None) if email is None or activation_token is None: @@ -934,6 +936,9 @@ class User(BaseUIController, UsesFormDefinitionsMixin, CreatesUsersMixin, Create else: # Find the user user = trans.sa_session.query(trans.app.model.User).filter(trans.app.model.User.table.c.email == email).first() + if not user: + # Probably wrong email address + return trans.show_error_message("You are using an invalid activation link. Try to log in and we will send you a new activation email.
Go to login page.") % web.url_for(controller="root", action="index") # If the user is active already don't try to activate if user.active is True: return trans.show_ok_message("Your account is already active. Nothing has changed.
Go to login page.") % web.url_for(controller='root', action='index') diff --git a/lib/galaxy/webapps/galaxy/controllers/visualization.py b/lib/galaxy/webapps/galaxy/controllers/visualization.py index 0232494646e..4d618b36057 100644 --- a/lib/galaxy/webapps/galaxy/controllers/visualization.py +++ b/lib/galaxy/webapps/galaxy/controllers/visualization.py @@ -620,7 +620,7 @@ class VisualizationController(BaseUIController, SharableMixin, UsesVisualization v.slug = v_slug v.dbkey = v_dbkey if v_annotation: - v_annotation = sanitize_html(v_annotation, 'utf-8', 'text/html') + v_annotation = sanitize_html(v_annotation) self.add_item_annotation(trans.sa_session, trans.get_user(), v, v_annotation) trans.sa_session.add(v) trans.sa_session.flush() diff --git a/lib/galaxy/webapps/galaxy/controllers/workflow.py b/lib/galaxy/webapps/galaxy/controllers/workflow.py index 06b75087eed..8aac37e9638 100644 --- a/lib/galaxy/webapps/galaxy/controllers/workflow.py +++ b/lib/galaxy/webapps/galaxy/controllers/workflow.py @@ -4,10 +4,10 @@ import base64 import json import logging import os -import sgmllib import requests from markupsafe import escape +from six.moves.html_parser import HTMLParser from six.moves.http_client import HTTPConnection from sqlalchemy import and_ from sqlalchemy.orm import eagerload, joinedload, lazyload, undefer @@ -153,16 +153,17 @@ class StoredWorkflowAllPublishedGrid(grids.Grid): self.model_class.deleted == expression.false()) -# Simple SGML parser to get all content in a single tag. -class SingleTagContentsParser(sgmllib.SGMLParser): +# Simple HTML parser to get all content in a single tag. +class SingleTagContentsParser(HTMLParser): def __init__(self, target_tag): - sgmllib.SGMLParser.__init__(self) + # Cannot use super() because HTMLParser is an old-style class in Python2 + HTMLParser.__init__(self) self.target_tag = target_tag self.cur_tag = None self.tag_content = "" - def unknown_starttag(self, tag, attrs): + def handle_starttag(self, tag, attrs): """ Called for each start tag. """ self.cur_tag = tag @@ -404,7 +405,7 @@ class WorkflowController(BaseUIController, SharableMixin, UsesStoredWorkflowMixi stored = self.get_stored_workflow(trans, id) if new_annotation: # Sanitize annotation before adding it. - new_annotation = sanitize_html(new_annotation, 'utf-8', 'text/html') + new_annotation = sanitize_html(new_annotation) self.add_item_annotation(trans.sa_session, trans.get_user(), stored, new_annotation) trans.sa_session.flush() return new_annotation @@ -547,7 +548,7 @@ class WorkflowController(BaseUIController, SharableMixin, UsesStoredWorkflowMixi workflow.stored_workflow = stored_workflow stored_workflow.latest_workflow = workflow # Add annotation. - workflow_annotation = sanitize_html(workflow_annotation, 'utf-8', 'text/html') + workflow_annotation = sanitize_html(workflow_annotation) self.add_item_annotation(trans.sa_session, trans.get_user(), stored_workflow, workflow_annotation) # Persist session = trans.sa_session @@ -573,7 +574,7 @@ class WorkflowController(BaseUIController, SharableMixin, UsesStoredWorkflowMixi workflow.stored_workflow = stored_workflow stored_workflow.latest_workflow = workflow # Add annotation. - workflow_annotation = sanitize_html(workflow_annotation, 'utf-8', 'text/html') + workflow_annotation = sanitize_html(workflow_annotation) self.add_item_annotation(trans.sa_session, trans.get_user(), stored_workflow, workflow_annotation) # Persist diff --git a/lib/galaxy/workflow/modules.py b/lib/galaxy/workflow/modules.py index 44abc129791..d090ef51280 100644 --- a/lib/galaxy/workflow/modules.py +++ b/lib/galaxy/workflow/modules.py @@ -825,6 +825,9 @@ class ToolModule(WorkflowModule): invocation = invocation_step.workflow_invocation step = invocation_step.workflow_step tool = trans.app.toolbox.get_tool(step.tool_id, tool_version=step.tool_version) + if not tool.is_workflow_compatible: + message = "Specified tool [%s] in workflow is not workflow-compatible." % tool.id + raise Exception(message) tool_state = step.state # Not strictly needed - but keep Tool state clean by stripping runtime # metadata parameters from it. @@ -843,7 +846,6 @@ class ToolModule(WorkflowModule): else: iteration_elements_iter = [None] - resource_parameters = invocation.resource_parameters for iteration_elements in iteration_elements_iter: execution_state = tool_state.copy() # TODO: Move next step into copy() @@ -916,8 +918,7 @@ class ToolModule(WorkflowModule): invocation_step=invocation_step, max_num_jobs=max_num_jobs, job_callback=lambda job: self._handle_post_job_actions(step, job, invocation.replacement_dict), - completed_jobs=completed_jobs, - workflow_resource_parameters=resource_parameters + completed_jobs=completed_jobs ) complete = True except PartialJobExecution as pje: diff --git a/lib/galaxy/workflow/run_request.py b/lib/galaxy/workflow/run_request.py index 2e58141a269..6d67d332146 100644 --- a/lib/galaxy/workflow/run_request.py +++ b/lib/galaxy/workflow/run_request.py @@ -7,7 +7,6 @@ from galaxy import ( ) from galaxy.managers import histories from galaxy.tools.parameters.meta import expand_workflow_inputs -from galaxy.workflow.resources import get_resource_mapper_function INPUT_STEP_TYPES = ['data_input', 'data_collection_input', 'parameter_input'] @@ -45,17 +44,15 @@ class WorkflowRunConfig(object): def __init__(self, target_history, replacement_dict, copy_inputs_to_history=False, - inputs={}, - param_map={}, + inputs=None, + param_map=None, allow_tool_state_corrections=False, - use_cached_job=False, - resource_params={}): + use_cached_job=False): self.target_history = target_history self.replacement_dict = replacement_dict self.copy_inputs_to_history = copy_inputs_to_history - self.inputs = inputs - self.param_map = param_map - self.resource_params = resource_params + self.inputs = inputs or {} + self.param_map = param_map or {} self.allow_tool_state_corrections = allow_tool_state_corrections self.use_cached_job = use_cached_job @@ -171,7 +168,8 @@ def _flatten_step_params(param_dict, prefix=""): return new_params -def _get_target_history(trans, workflow, payload, param_keys=[], index=0): +def _get_target_history(trans, workflow, payload, param_keys=None, index=0): + param_keys = param_keys or [] history_name = payload.get('new_history_name', None) history_id = payload.get('history_id', None) history_param = payload.get('history', None) @@ -307,35 +305,6 @@ def build_workflow_run_configs(trans, workflow, payload): normalized_inputs[key] = value['content'] else: normalized_inputs[key] = value - resource_params = payload.get('resource_params', {}) - if resource_params: - # quick attempt to validate parameters, just handle select options now since is what - # is needed for DTD - arbitrary plugins can define arbitrary logic at runtime in the - # destination function. In the future this should be extended to allow arbitrary - # pluggable validation. - resource_mapper_function = get_resource_mapper_function(trans.app) - # TODO: Do we need to do anything with the stored_workflow or can this be removed. - resource_parameters = resource_mapper_function(trans=trans, stored_workflow=None, workflow=workflow) - for resource_parameter in resource_parameters: - if resource_parameter.get("type") == "select": - name = resource_parameter.get("name") - if name in resource_params: - value = resource_params[name] - valid_option = False - # TODO: How should be handle the case where no selection is made by the user - # This can happen when there is a select on the page but the user has no options to select - # Here I have the validation pass it through. An alternative may be to remove the parameter if - # it is None. - if value is None: - valid_option = True - else: - for option_elem in resource_parameter.get('data'): - option_value = option_elem.get("value") - if value == option_value: - valid_option = True - if not valid_option: - raise exceptions.RequestParameterInvalidException("Invalid value for parameter '%s' found." % name) - run_configs.append(WorkflowRunConfig( target_history=history, replacement_dict=payload.get('replacement_params', {}), @@ -343,7 +312,6 @@ def build_workflow_run_configs(trans, workflow, payload): param_map=param_map, allow_tool_state_corrections=allow_tool_state_corrections, use_cached_job=use_cached_job, - resource_params=resource_params, )) return run_configs @@ -383,8 +351,7 @@ def workflow_run_config_to_request(trans, run_config, workflow): use_cached_job=run_config.use_cached_job, inputs={}, param_map={}, - allow_tool_state_corrections=run_config.allow_tool_state_corrections, - resource_params=run_config.resource_params + allow_tool_state_corrections=run_config.allow_tool_state_corrections ) subworkflow_invocation = workflow_run_config_to_request( trans, @@ -406,9 +373,6 @@ def workflow_run_config_to_request(trans, run_config, workflow): for step_id, content in run_config.inputs.items(): workflow_invocation.add_input(content, step_id) - resource_parameters = run_config.resource_params - for key, value in resource_parameters.items(): - add_parameter(key, value, param_types.RESOURCE_PARAMETERS) add_parameter("copy_inputs_to_history", "true" if run_config.copy_inputs_to_history else "false", param_types.META_PARAMETERS) add_parameter("use_cached_job", "true" if run_config.use_cached_job else "false", param_types.META_PARAMETERS) return workflow_invocation @@ -420,7 +384,6 @@ def workflow_request_to_run_config(work_request_context, workflow_invocation): replacement_dict = {} inputs = {} param_map = {} - resource_params = {} copy_inputs_to_history = None use_cached_job = False for parameter in workflow_invocation.input_parameters: @@ -433,8 +396,6 @@ def workflow_request_to_run_config(work_request_context, workflow_invocation): copy_inputs_to_history = (parameter.value == "true") if parameter.name == 'use_cached_job': use_cached_job = (parameter.value == 'true') - elif parameter_type == param_types.RESOURCE_PARAMETERS: - resource_params[parameter.name] = parameter.value for input_association in workflow_invocation.input_datasets: inputs[input_association.workflow_step_id] = input_association.dataset for input_association in workflow_invocation.input_dataset_collections: @@ -450,7 +411,6 @@ def workflow_request_to_run_config(work_request_context, workflow_invocation): param_map=param_map, copy_inputs_to_history=copy_inputs_to_history, use_cached_job=use_cached_job, - resource_params=resource_params, ) return workflow_run_config diff --git a/lib/tool_shed/galaxy_install/tool_dependencies/recipe/step_handler.py b/lib/tool_shed/galaxy_install/tool_dependencies/recipe/step_handler.py index f8dc0ee5cd7..917b2ead48c 100644 --- a/lib/tool_shed/galaxy_install/tool_dependencies/recipe/step_handler.py +++ b/lib/tool_shed/galaxy_install/tool_dependencies/recipe/step_handler.py @@ -16,6 +16,7 @@ from galaxy.util import ( asbool, download_to_file ) +from galaxy.util.compression_utils import CompressedFile from galaxy.util.template import fill_template from tool_shed.galaxy_install.tool_dependencies.env_manager import EnvManager from tool_shed.util import basic_util, tool_dependency_util @@ -25,124 +26,6 @@ log = logging.getLogger(__name__) VIRTUALENV_URL = 'https://pypi.python.org/packages/d4/0c/9840c08189e030873387a73b90ada981885010dd9aea134d6de30cd24cb8/virtualenv-15.1.0.tar.gz' -class CompressedFile(object): - - def __init__(self, file_path, mode='r'): - if tarfile.is_tarfile(file_path): - self.file_type = 'tar' - elif zipfile.is_zipfile(file_path) and not file_path.endswith('.jar'): - self.file_type = 'zip' - self.file_name = os.path.splitext(os.path.basename(file_path))[0] - if self.file_name.endswith('.tar'): - self.file_name = os.path.splitext(self.file_name)[0] - self.type = self.file_type - method = 'open_%s' % self.file_type - if hasattr(self, method): - self.archive = getattr(self, method)(file_path, mode) - else: - raise NameError('File type %s specified, no open method found.' % self.file_type) - - def extract(self, path): - '''Determine the path to which the archive should be extracted.''' - contents = self.getmembers() - extraction_path = path - common_prefix = '' - if len(contents) == 1: - # The archive contains a single file, return the extraction path. - if self.isfile(contents[0]): - extraction_path = os.path.join(path, self.file_name) - if not os.path.exists(extraction_path): - os.makedirs(extraction_path) - self.archive.extractall(extraction_path) - else: - # Get the common prefix for all the files in the archive. If the common prefix ends with a slash, - # or self.isdir() returns True, the archive contains a single directory with the desired contents. - # Otherwise, it contains multiple files and/or directories at the root of the archive. - common_prefix = os.path.commonprefix([self.getname(item) for item in contents]) - if len(common_prefix) >= 1 and not common_prefix.endswith(os.sep) and self.isdir(self.getmember(common_prefix)): - common_prefix += os.sep - if not common_prefix.endswith(os.sep): - common_prefix = '' - extraction_path = os.path.join(path, self.file_name) - if not os.path.exists(extraction_path): - os.makedirs(extraction_path) - self.archive.extractall(extraction_path) - # Since .zip files store unix permissions separately, we need to iterate through the zip file - # and set permissions on extracted members. - if self.file_type == 'zip': - for zipped_file in contents: - filename = self.getname(zipped_file) - absolute_filepath = os.path.join(extraction_path, filename) - external_attributes = self.archive.getinfo(filename).external_attr - # The 2 least significant bytes are irrelevant, the next two contain unix permissions. - unix_permissions = external_attributes >> 16 - if unix_permissions != 0: - if os.path.exists(absolute_filepath): - os.chmod(absolute_filepath, unix_permissions) - else: - log.warning("Unable to change permission on extracted file '%s' as it does not exist" % absolute_filepath) - return os.path.abspath(os.path.join(extraction_path, common_prefix)) - - def getmembers_tar(self): - return self.archive.getmembers() - - def getmembers_zip(self): - return self.archive.infolist() - - def getname_tar(self, item): - return item.name - - def getname_zip(self, item): - return item.filename - - def getmember(self, name): - for member in self.getmembers(): - if self.getname(member) == name: - return member - - def getmembers(self): - return getattr(self, 'getmembers_%s' % self.type)() - - def getname(self, member): - return getattr(self, 'getname_%s' % self.type)(member) - - def isdir(self, member): - return getattr(self, 'isdir_%s' % self.type)(member) - - def isdir_tar(self, member): - return member.isdir() - - def isdir_zip(self, member): - if member.filename.endswith(os.sep): - return True - return False - - def isfile(self, member): - if not self.isdir(member): - return True - return False - - def open_tar(self, filepath, mode): - return tarfile.open(filepath, mode, errorlevel=0) - - def open_zip(self, filepath, mode): - return zipfile.ZipFile(filepath, mode) - - def zipfile_ok(self, path_to_archive): - """ - This function is a bit pedantic and not functionally necessary. It checks whether there is - no file pointing outside of the extraction, because ZipFile.extractall() has some potential - security holes. See python zipfile documentation for more details. - """ - basename = os.path.realpath(os.path.dirname(path_to_archive)) - zip_archive = zipfile.ZipFile(path_to_archive) - for member in zip_archive.namelist(): - member_path = os.path.realpath(os.path.join(basename, member)) - if not member_path.startswith(basename): - return False - return True - - class Download(object): def url_download(self, install_dir, downloaded_file_name, download_url, extract=True, checksums={}): diff --git a/lib/tool_shed/util/tool_util.py b/lib/tool_shed/util/tool_util.py index 42dc7132d44..3a6ac2069cb 100644 --- a/lib/tool_shed/util/tool_util.py +++ b/lib/tool_shed/util/tool_util.py @@ -43,6 +43,7 @@ def copy_sample_file(app, filename, dest_path=None): """ Copies a sample file at `filename` to `the dest_path` directory and strips the '.sample' extensions from `filename`. + Returns the path to the copied file (with the .sample extension). """ if dest_path is None: dest_path = os.path.abspath(app.config.tool_data_path) @@ -56,8 +57,10 @@ def copy_sample_file(app, filename, dest_path=None): shutil.copy(full_source_path, full_destination_path) # Only create the .loc file if it does not yet exist. We don't overwrite it in case it # contains stuff proprietary to the local instance. - if not os.path.lexists(os.path.join(dest_path, copied_file)): + non_sample_path = os.path.join(dest_path, copied_file) + if not os.path.lexists(non_sample_path): shutil.copy(full_source_path, os.path.join(dest_path, copied_file)) + return non_sample_path def copy_sample_files(app, sample_files, tool_path=None, sample_files_copied=None, dest_path=None): @@ -159,10 +162,10 @@ def handle_missing_index_file(app, tool_path, sample_files, repository_tools_tup for sample_file in sample_files: sample_file_name = basic_util.strip_path(sample_file) if sample_file_name == '%s.sample' % missing_file_name: - copy_sample_file(app, os.path.join(tool_path, sample_file)) + target_path = copy_sample_file(app, os.path.join(tool_path, sample_file)) if options.tool_data_table and options.tool_data_table.missing_index_file: - options.tool_data_table.handle_found_index_file(options.missing_index_file) - sample_files_copied.append(options.missing_index_file) + options.tool_data_table.handle_found_index_file(target_path) + sample_files_copied.append(target_path) break return repository_tools_tups, sample_files_copied diff --git a/lib/tool_shed/util/workflow_util.py b/lib/tool_shed/util/workflow_util.py index 38a7edd8f3e..0604759c040 100644 --- a/lib/tool_shed/util/workflow_util.py +++ b/lib/tool_shed/util/workflow_util.py @@ -287,7 +287,7 @@ def get_workflow_from_dict(trans, workflow_dict, tools_metadata, repository_id, if trans.webapp.name == 'galaxy': annotation = step_dict.get('annotation', '') if annotation: - annotation = sanitize_html(annotation, 'utf-8', 'text/html') + annotation = sanitize_html(annotation) new_step_annotation = trans.model.WorkflowStepAnnotationAssociation() new_step_annotation.annotation = annotation new_step_annotation.user = trans.user @@ -398,7 +398,7 @@ def save_workflow(trans, workflow, workflow_dict=None): stored.latest_workflow = workflow stored.user = trans.user if workflow_dict and workflow_dict.get('annotation', ''): - annotation = sanitize_html(workflow_dict['annotation'], 'utf-8', 'text/html') + annotation = sanitize_html(workflow_dict['annotation']) new_annotation = trans.model.StoredWorkflowAnnotationAssociation() new_annotation.annotation = annotation new_annotation.user = trans.user diff --git a/scripts/api/fetch_to_library.py b/scripts/api/fetch_to_library.py new file mode 100644 index 00000000000..6c497bcb402 --- /dev/null +++ b/scripts/api/fetch_to_library.py @@ -0,0 +1,33 @@ +import argparse +import json + +import requests +import yaml + + +def main(): + parser = argparse.ArgumentParser(description='Upload a directory into a data library') + parser.add_argument("-u", "--url", dest="url", required=True, help="Galaxy URL") + parser.add_argument("-a", "--api", dest="api_key", required=True, help="API Key") + parser.add_argument('target', metavar='FILE', type=str, + help='file describing data library to fetch') + args = parser.parse_args() + with open(args.target, "r") as f: + target = yaml.load(f) + + histories_url = args.url + "/api/histories" + new_history_response = requests.post(histories_url, data={'key': args.api_key}) + + fetch_url = args.url + '/api/tools/fetch' + payload = { + 'key': args.api_key, + 'targets': json.dumps([target]), + 'history_id': new_history_response.json()["id"] + } + + response = requests.post(fetch_url, data=payload) + print(response.content) + + +if __name__ == '__main__': + main() diff --git a/scripts/api/fetch_to_library_example.yml b/scripts/api/fetch_to_library_example.yml new file mode 100644 index 00000000000..44bc35ef43b --- /dev/null +++ b/scripts/api/fetch_to_library_example.yml @@ -0,0 +1,42 @@ +destination: + type: library + name: Training Material + description: Data for selected tutorials from https://training.galaxyproject.org. +items: + - name: Quality Control + description: | + Data for sequence quality control tutorial at http://galaxyproject.github.io/training-material/topics/sequence-analysis/tutorials/quality-control/tutorial.html. + + 10.5281/zenodo.61771 + items: + - src: url + url: https://zenodo.org/record/61771/files/GSM461178_untreat_paired_subset_1.fastq + name: GSM461178_untreat_paired_subset_1 + ext: fastqsanger + info: Untreated subseq of GSM461178 from 10.1186/s12864-017-3692-8 + - src: url + url: https://zenodo.org/record/61771/files/GSM461182_untreat_single_subset.fastq + name: GSM461182_untreat_single_subset + ext: fastqsanger + info: Untreated subseq of GSM461182 from 10.1186/s12864-017-3692-8 + - name: Small RNA-Seq + description: | + Data for small RNA-seq tutorial available at http://galaxyproject.github.io/training-material/topics/transcriptomics/tutorials/srna/tutorial.html + + 10.5281/zenodo.826906 + items: + - src: url + url: https://zenodo.org/record/826906/files/Symp_RNAi_sRNA-seq_rep1_downsampled.fastqsanger.gz + name: Symp RNAi sRNA Rep1 + ext: fastqsanger.gz + info: Downsample rep1 from 10.1186/s12864-017-3692-8 + - src: url + url: https://zenodo.org/record/826906/files/Symp_RNAi_sRNA-seq_rep2_downsampled.fastqsanger.gz + name: Symp RNAi sRNA Rep2 + ext: fastqsanger.gz + info: Downsample rep2 from 10.1186/s12864-017-3692-8 + - src: url + url: https://zenodo.org/record/826906/files/Symp_RNAi_sRNA-seq_rep3_downsampled.fastqsanger.gz + name: Symp RNAi sRNA Rep3 + ext: fastqsanger.gz + info: Downsample rep3 from 10.1186/s12864-017-3692-8 diff --git a/scripts/common_startup.sh b/scripts/common_startup.sh index 6c460f4a69b..a123df666c1 100755 --- a/scripts/common_startup.sh +++ b/scripts/common_startup.sh @@ -197,10 +197,8 @@ if [ $SKIP_CLIENT_BUILD -eq 0 ]; then if [ "$GIT_BRANCH" = "0" ]; then SKIP_CLIENT_BUILD=1 else - # Compare hash. - githash=$(git rev-parse HEAD) - statichash=$(cat static/client_build_hash.txt) - if [ "$githash" = "$statichash" ]; then + # Check if anything has changed in client/ since the last build + if git diff --quiet $(cat static/client_build_hash.txt) -- client/; then SKIP_CLIENT_BUILD=1 else echo "The Galaxy client is out of date and will be built now." diff --git a/scripts/microbes/BeautifulSoup.py b/scripts/microbes/BeautifulSoup.py deleted file mode 100644 index a49381ae217..00000000000 --- a/scripts/microbes/BeautifulSoup.py +++ /dev/null @@ -1,1804 +0,0 @@ -"""Beautiful Soup -Elixir and Tonic -"The Screen-Scraper's Friend" -https://www.crummy.com/software/BeautifulSoup/ - -Beautiful Soup parses a (possibly invalid) XML or HTML document into a -tree representation. It provides methods and Pythonic idioms that make -it easy to navigate, search, and modify the tree. - -A well-structured XML/HTML document yields a well-behaved data -structure. An ill-structured XML/HTML document yields a -correspondingly ill-behaved data structure. If your document is only -locally well-structured, you can use this library to find and process -the well-structured part of it. - -Beautiful Soup works with Python 2.2 and up. It has no external -dependencies, but you'll have more success at converting data to UTF-8 -if you also install these three packages: - -* chardet, for auto-detecting character encodings - http://chardet.feedparser.org/ -* cjkcodecs and iconv_codec, which add more encodings to the ones supported - by stock Python. - http://cjkpython.i18n.org/ - -Beautiful Soup defines classes for two main parsing strategies: - - * BeautifulStoneSoup, for parsing XML, SGML, or your domain-specific - language that kind of looks like XML. - - * BeautifulSoup, for parsing run-of-the-mill HTML code, be it valid - or invalid. This class has web browser-like heuristics for - obtaining a sensible parse tree in the face of common HTML errors. - -Beautiful Soup also defines a class (UnicodeDammit) for autodetecting -the encoding of an HTML or XML document, and converting it to -Unicode. Much of this code is taken from Mark Pilgrim's Universal Feed -Parser. - -For more than you ever wanted to know about Beautiful Soup, see the -documentation: -http://www.crummy.com/software/BeautifulSoup/documentation.html -""" -from __future__ import ( - generators, - print_function -) - -import codecs -import re -import sgmllib -import string -import sys -from sgmllib import ( - SGMLParseError, - SGMLParser -) - -import six -from six.moves.html_entities import name2codepoint - -__author__ = "Leonard Richardson (crummy.com)" -__contributors__ = ["Sam Ruby (intertwingly.net)", - "the unwitting Mark Pilgrim (diveintomark.org)", - "http://www.crummy.com/software/BeautifulSoup/AUTHORS.html"] -__version__ = "3.0.3" -__copyright__ = "Copyright (c) 2004-2006 Leonard Richardson" -__license__ = "PSF" - -# This RE makes Beautiful Soup able to parse XML with namespaces. -sgmllib.tagfind = re.compile('[a-zA-Z][-_.:a-zA-Z0-9]*') - -# This RE makes Beautiful Soup capable of recognizing numeric character -# references that use hexadecimal. -sgmllib.charref = re.compile('&#(\d+|x[0-9a-fA-F]+);') - -DEFAULT_OUTPUT_ENCODING = "utf-8" - - -# First, the classes that represent markup elements. -class PageElement: - """Contains the navigational information for some part of the page - (either a tag or a piece of text)""" - - def setup(self, parent=None, previous=None): - """Sets up the initial relations between this element and - other elements.""" - self.parent = parent - self.previous = previous - self.next = None - self.previousSibling = None - self.nextSibling = None - if self.parent and self.parent.contents: - self.previousSibling = self.parent.contents[-1] - self.previousSibling.nextSibling = self - - def replaceWith(self, replaceWith): - oldParent = self.parent - myIndex = self.parent.contents.index(self) - if hasattr(replaceWith, 'parent') and replaceWith.parent == self.parent: - # We're replacing this element with one of its siblings. - index = self.parent.contents.index(replaceWith) - if index and index < myIndex: - # Furthermore, it comes before this element. That - # means that when we extract it, the index of this - # element will change. - myIndex = myIndex - 1 - self.extract() - oldParent.insert(myIndex, replaceWith) - - def extract(self): - """Destructively rips this element out of the tree.""" - if self.parent: - try: - self.parent.contents.remove(self) - except ValueError: - pass - - # Find the two elements that would be next to each other if - # this element (and any children) hadn't been parsed. Connect - # the two. - lastChild = self._lastRecursiveChild() - nextElement = lastChild.next - - if self.previous: - self.previous.next = nextElement - if nextElement: - nextElement.previous = self.previous - self.previous = None - lastChild.next = None - - self.parent = None - if self.previousSibling: - self.previousSibling.nextSibling = self.nextSibling - if self.nextSibling: - self.nextSibling.previousSibling = self.previousSibling - self.previousSibling = self.nextSibling = None - - def _lastRecursiveChild(self): - "Finds the last element beneath this object to be parsed." - lastChild = self - while hasattr(lastChild, 'contents') and lastChild.contents: - lastChild = lastChild.contents[-1] - return lastChild - - def insert(self, position, newChild): - if isinstance(newChild, six.string_types) and \ - not isinstance(newChild, NavigableString): - newChild = NavigableString(newChild) - - position = min(position, len(self.contents)) - if hasattr(newChild, 'parent') and newChild.parent is not None: - # We're 'inserting' an element that's already one - # of this object's children. - if newChild.parent == self: - index = self.find(newChild) - if index and index < position: - # Furthermore we're moving it further down the - # list of this object's children. That means that - # when we extract this element, our target index - # will jump down one. - position = position - 1 - newChild.extract() - - newChild.parent = self - previousChild = None - if position == 0: - newChild.previousSibling = None - newChild.previous = self - else: - previousChild = self.contents[position - 1] - newChild.previousSibling = previousChild - newChild.previousSibling.nextSibling = newChild - newChild.previous = previousChild._lastRecursiveChild() - if newChild.previous: - newChild.previous.next = newChild - - newChildsLastElement = newChild._lastRecursiveChild() - - if position >= len(self.contents): - newChild.nextSibling = None - - parent = self - parentsNextSibling = None - while not parentsNextSibling: - parentsNextSibling = parent.nextSibling - parent = parent.parent - if not parent: # This is the last element in the document. - break - if parentsNextSibling: - newChildsLastElement.next = parentsNextSibling - else: - newChildsLastElement.next = None - else: - nextChild = self.contents[position] - newChild.nextSibling = nextChild - if newChild.nextSibling: - newChild.nextSibling.previousSibling = newChild - newChildsLastElement.next = nextChild - - if newChildsLastElement.next: - newChildsLastElement.next.previous = newChildsLastElement - self.contents.insert(position, newChild) - - def findNext(self, name=None, attrs={}, text=None, **kwargs): - """Returns the first item that matches the given criteria and - appears after this Tag in the document.""" - return self._findOne(self.findAllNext, name, attrs, text, **kwargs) - - def findAllNext(self, name=None, attrs={}, text=None, limit=None, - **kwargs): - """Returns all items that match the given criteria and appear - before after Tag in the document.""" - return self._findAll(name, attrs, text, limit, self.nextGenerator) - - def findNextSibling(self, name=None, attrs={}, text=None, **kwargs): - """Returns the closest sibling to this Tag that matches the - given criteria and appears after this Tag in the document.""" - return self._findOne(self.findNextSiblings, name, attrs, text, - **kwargs) - - def findNextSiblings(self, name=None, attrs={}, text=None, limit=None, - **kwargs): - """Returns the siblings of this Tag that match the given - criteria and appear after this Tag in the document.""" - return self._findAll(name, attrs, text, limit, - self.nextSiblingGenerator, **kwargs) - fetchNextSiblings = findNextSiblings # Compatibility with pre-3.x - - def findPrevious(self, name=None, attrs={}, text=None, **kwargs): - """Returns the first item that matches the given criteria and - appears before this Tag in the document.""" - return self._findOne(self.findAllPrevious, name, attrs, text, **kwargs) - - def findAllPrevious(self, name=None, attrs={}, text=None, limit=None, - **kwargs): - """Returns all items that match the given criteria and appear - before this Tag in the document.""" - return self._findAll(name, attrs, text, limit, self.previousGenerator, - **kwargs) - fetchPrevious = findAllPrevious # Compatibility with pre-3.x - - def findPreviousSibling(self, name=None, attrs={}, text=None, **kwargs): - """Returns the closest sibling to this Tag that matches the - given criteria and appears before this Tag in the document.""" - return self._findOne(self.findPreviousSiblings, name, attrs, text, - **kwargs) - - def findPreviousSiblings(self, name=None, attrs={}, text=None, - limit=None, **kwargs): - """Returns the siblings of this Tag that match the given - criteria and appear before this Tag in the document.""" - return self._findAll(name, attrs, text, limit, - self.previousSiblingGenerator, **kwargs) - fetchPreviousSiblings = findPreviousSiblings # Compatibility with pre-3.x - - def findParent(self, name=None, attrs={}, **kwargs): - """Returns the closest parent of this Tag that matches the given - criteria.""" - # NOTE: We can't use _findOne because findParents takes a different - # set of arguments. - r = None - l = self.findParents(name, attrs, 1) - if l: - r = l[0] - return r - - def findParents(self, name=None, attrs={}, limit=None, **kwargs): - """Returns the parents of this Tag that match the given - criteria.""" - - return self._findAll(name, attrs, None, limit, self.parentGenerator, - **kwargs) - fetchParents = findParents # Compatibility with pre-3.x - - # These methods do the real heavy lifting. - - def _findOne(self, method, name, attrs, text, **kwargs): - r = None - l = method(name, attrs, text, 1, **kwargs) - if l: - r = l[0] - return r - - def _findAll(self, name, attrs, text, limit, generator, **kwargs): - "Iterates over a generator looking for things that match." - - if isinstance(name, SoupStrainer): - strainer = name - else: - # Build a SoupStrainer - strainer = SoupStrainer(name, attrs, text, **kwargs) - results = ResultSet(strainer) - g = generator() - while True: - try: - i = next(g) - except StopIteration: - break - if i: - found = strainer.search(i) - if found: - results.append(found) - if limit and len(results) >= limit: - break - return results - - # These Generators can be used to navigate starting from both - # NavigableStrings and Tags. - def nextGenerator(self): - i = self - while i: - i = i.next - yield i - - def nextSiblingGenerator(self): - i = self - while i: - i = i.nextSibling - yield i - - def previousGenerator(self): - i = self - while i: - i = i.previous - yield i - - def previousSiblingGenerator(self): - i = self - while i: - i = i.previousSibling - yield i - - def parentGenerator(self): - i = self - while i: - i = i.parent - yield i - - # Utility methods - def substituteEncoding(self, str, encoding=None): - encoding = encoding or "utf-8" - return str.replace("%SOUP-ENCODING%", encoding) - - def toEncoding(self, s, encoding=None): - """Encodes an object to a string in some encoding, or to Unicode. - .""" - if not isinstance(s, six.string_types) and not isinstance(s, six.binary_type): - s = str(s) - if isinstance(s, six.text_type): - if encoding: - s = s.encode(encoding) - else: - if encoding: - s = s.encode(encoding) - else: - s = six.text_type(s) - return s - - -class NavigableString(six.text_type, PageElement): - - def __getattr__(self, attr): - """text.string gives you text. This is for backwards - compatibility for Navigable*String, but for CData* it lets you - get the string without the CData wrapper.""" - if attr == 'string': - return self - else: - raise AttributeError("'%s' object has no attribute '%s'" % (self.__class__.__name__, attr)) - - def __unicode__(self): - return self.__str__() - - def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING): - if encoding: - return self.encode(encoding) - else: - return self - - -class CData(NavigableString): - - def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING): - return "" % NavigableString.__str__(self, encoding) - - -class ProcessingInstruction(NavigableString): - def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING): - output = self - if "%SOUP-ENCODING%" in output: - output = self.substituteEncoding(output, encoding) - return "" % self.toEncoding(output, encoding) - - -class Comment(NavigableString): - def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING): - return "" % NavigableString.__str__(self, encoding) - - -class Declaration(NavigableString): - def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING): - return "" % NavigableString.__str__(self, encoding) - - -class Tag(PageElement): - """Represents a found HTML tag with its attributes and contents.""" - - XML_ENTITIES_TO_CHARS = {'apos': "'", - "quot": '"', - "amp": "&", - "lt": "<", - "gt": ">"} - # An RE for finding ampersands that aren't the start of of a - # numeric entity. - BARE_AMPERSAND = re.compile("&(?!#\d+;|#x[0-9a-fA-F]+;|\w+;)") - - def __init__(self, parser, name, attrs=None, parent=None, - previous=None): - "Basic constructor." - - # We don't actually store the parser object: that lets extracted - # chunks be garbage-collected - self.parserClass = parser.__class__ - self.isSelfClosing = parser.isSelfClosingTag(name) - self.convertHTMLEntities = parser.convertHTMLEntities - self.name = name - if attrs is None: - attrs = [] - self.attrs = attrs - self.contents = [] - self.setup(parent, previous) - self.hidden = False - self.containsSubstitutions = False - - def get(self, key, default=None): - """Returns the value of the 'key' attribute for the tag, or - the value given for 'default' if it doesn't have that - attribute.""" - return self._getAttrMap().get(key, default) - - def has_key(self, key): - return key in self._getAttrMap() - - def __getitem__(self, key): - """tag[key] returns the value of the 'key' attribute for the tag, - and throws an exception if it's not there.""" - return self._getAttrMap()[key] - - def __iter__(self): - "Iterating over a tag iterates over its contents." - return iter(self.contents) - - def __len__(self): - "The length of a tag is the length of its list of contents." - return len(self.contents) - - def __contains__(self, x): - return x in self.contents - - def __bool__(self): - "A tag is non-None even if it has no contents." - return True - __nonzero__ = __bool__ - - def __setitem__(self, key, value): - """Setting tag[key] sets the value of the 'key' attribute for the - tag.""" - self._getAttrMap() - self.attrMap[key] = value - found = False - for i in range(0, len(self.attrs)): - if self.attrs[i][0] == key: - self.attrs[i] = (key, value) - found = True - if not found: - self.attrs.append((key, value)) - self._getAttrMap()[key] = value - - def __delitem__(self, key): - "Deleting tag[key] deletes all 'key' attributes for the tag." - for item in self.attrs: - if item[0] == key: - self.attrs.remove(item) - # We don't break because bad HTML can define the same - # attribute multiple times. - self._getAttrMap() - if key in self.attrMap: - del self.attrMap[key] - - def __call__(self, *args, **kwargs): - """Calling a tag like a function is the same as calling its - findAll() method. Eg. tag('a') returns a list of all the A tags - found within this tag.""" - return self.findAll(*args, **kwargs) - - def __getattr__(self, tag): - if len(tag) > 3 and tag.rfind('Tag') == len(tag) - 3: - return self.find(tag[:-3]) - elif tag.find('__') != 0: - return self.find(tag) - - def __eq__(self, other): - """Returns true iff this tag has the same name, the same attributes, - and the same contents (recursively) as the given tag. - - NOTE: right now this will return false if two tags have the - same attributes in a different order. Should this be fixed?""" - if not hasattr(other, 'name') or not hasattr(other, 'attrs') or not hasattr(other, 'contents') or self.name != other.name or self.attrs != other.attrs or len(self) != len(other): - return False - for i in range(0, len(self.contents)): - if self.contents[i] != other.contents[i]: - return False - return True - - def __ne__(self, other): - """Returns true iff this tag is not identical to the other tag, - as defined in __eq__.""" - return not self == other - - def __repr__(self, encoding=DEFAULT_OUTPUT_ENCODING): - """Renders this tag as a string.""" - return self.__str__(encoding) - - def __unicode__(self): - return self.__str__(None) - - def _convertEntities(self, match): - x = match.group(1) - if x in name2codepoint: - return six.unichr(name2codepoint[x]) - elif "&" + x + ";" in self.XML_ENTITIES_TO_CHARS: - return '&%s;' % x - else: - return '&%s;' % x - - def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING, - prettyPrint=False, indentLevel=0): - """Returns a string or Unicode representation of this tag and - its contents. To get Unicode, pass None for encoding. - - NOTE: since Python's HTML parser consumes whitespace, this - method is not certain to reproduce the whitespace present in - the original string.""" - - encodedName = self.toEncoding(self.name, encoding) - - attrs = [] - if self.attrs: - for key, val in self.attrs: - fmt = '%s="%s"' - if isString(val): - if self.containsSubstitutions and '%SOUP-ENCODING%' in val: - val = self.substituteEncoding(val, encoding) - - # The attribute value either: - # - # * Contains no embedded double quotes or single quotes. - # No problem: we enclose it in double quotes. - # * Contains embedded single quotes. No problem: - # double quotes work here too. - # * Contains embedded double quotes. No problem: - # we enclose it in single quotes. - # * Embeds both single _and_ double quotes. This - # can't happen naturally, but it can happen if - # you modify an attribute value after parsing - # the document. Now we have a bit of a - # problem. We solve it by enclosing the - # attribute in single quotes, and escaping any - # embedded single quotes to XML entities. - if '"' in val: - # This can't happen naturally, but it can happen - # if you modify an attribute value after parsing. - if "'" in val: - val = val.replace('"', """) - else: - fmt = "%s='%s'" - - # Optionally convert any HTML entities - if self.convertHTMLEntities: - val = re.sub("&(\w+);", self._convertEntities, val) - - # Now we're okay w/r/t quotes. But the attribute - # value might also contain angle brackets, or - # ampersands that aren't part of entities. We need - # to escape those to XML entities too. - val = val.replace("<", "<").replace(">", ">") - val = self.BARE_AMPERSAND.sub("&", val) - - attrs.append(fmt % (self.toEncoding(key, encoding), - self.toEncoding(val, encoding))) - close = '' - closeTag = '' - if self.isSelfClosing: - close = ' /' - else: - closeTag = '' % encodedName - - indentTag, indentContents = 0, 0 - if prettyPrint: - indentTag = indentLevel - space = (' ' * (indentTag - 1)) - indentContents = indentTag + 1 - contents = self.renderContents(encoding, prettyPrint, indentContents) - if self.hidden: - s = contents - else: - s = [] - attributeString = '' - if attrs: - attributeString = ' ' + ' '.join(attrs) - if prettyPrint: - s.append(space) - s.append('<%s%s%s>' % (encodedName, attributeString, close)) - if prettyPrint: - s.append("\n") - s.append(contents) - if prettyPrint and contents and contents[-1] != "\n": - s.append("\n") - if prettyPrint and closeTag: - s.append(space) - s.append(closeTag) - if prettyPrint and closeTag and self.nextSibling: - s.append("\n") - s = ''.join(s) - return s - - def prettify(self, encoding=DEFAULT_OUTPUT_ENCODING): - return self.__str__(encoding, True) - - def renderContents(self, encoding=DEFAULT_OUTPUT_ENCODING, - prettyPrint=False, indentLevel=0): - """Renders the contents of this tag as a string in the given - encoding. If encoding is None, returns a Unicode string..""" - s = [] - for c in self: - text = None - if isinstance(c, NavigableString): - text = c.__str__(encoding) - elif isinstance(c, Tag): - s.append(c.__str__(encoding, prettyPrint, indentLevel)) - if text and prettyPrint: - text = text.strip() - if text: - if prettyPrint: - s.append(" " * (indentLevel - 1)) - s.append(text) - if prettyPrint: - s.append("\n") - return ''.join(s) - - # Soup methods - - def find(self, name=None, attrs={}, recursive=True, text=None, - **kwargs): - """Return only the first child of this Tag matching the given - criteria.""" - r = None - l = self.findAll(name, attrs, recursive, text, 1, **kwargs) - if l: - r = l[0] - return r - findChild = find - - def findAll(self, name=None, attrs={}, recursive=True, text=None, - limit=None, **kwargs): - """Extracts a list of Tag objects that match the given - criteria. You can specify the name of the Tag and any - attributes you want the Tag to have. - - The value of a key-value pair in the 'attrs' map can be a - string, a list of strings, a regular expression object, or a - callable that takes a string and returns whether or not the - string matches for some custom definition of 'matches'. The - same is true of the tag name.""" - generator = self.recursiveChildGenerator - if not recursive: - generator = self.childGenerator - return self._findAll(name, attrs, text, limit, generator, **kwargs) - findChildren = findAll - - # Pre-3.x compatibility methods - first = find - fetch = findAll - - def fetchText(self, text=None, recursive=True, limit=None): - return self.findAll(text=text, recursive=recursive, limit=limit) - - def firstText(self, text=None, recursive=True): - return self.find(text=text, recursive=recursive) - - # Utility methods - - def append(self, tag): - """Appends the given tag to the contents of this tag.""" - self.contents.append(tag) - - # Private methods - - def _getAttrMap(self): - """Initializes a map representation of this tag's attributes, - if not already initialized.""" - if not getattr(self, 'attrMap'): - self.attrMap = {} - for (key, value) in self.attrs: - self.attrMap[key] = value - return self.attrMap - - # Generator methods - def childGenerator(self): - for i in range(0, len(self.contents)): - yield self.contents[i] - raise StopIteration - - def recursiveChildGenerator(self): - stack = [(self, 0)] - while stack: - tag, start = stack.pop() - if isinstance(tag, Tag): - for i in range(start, len(tag.contents)): - a = tag.contents[i] - yield a - if isinstance(a, Tag) and tag.contents: - if i < len(tag.contents) - 1: - stack.append((tag, i + 1)) - stack.append((a, 0)) - break - raise StopIteration - - -# Next, a couple classes to represent queries and their results. -class SoupStrainer: - """Encapsulates a number of ways of matching a markup element (tag or - text).""" - - def __init__(self, name=None, attrs={}, text=None, **kwargs): - self.name = name - if isString(attrs): - kwargs['class'] = attrs - attrs = None - if kwargs: - if attrs: - attrs = attrs.copy() - attrs.update(kwargs) - else: - attrs = kwargs - self.attrs = attrs - self.text = text - - def __str__(self): - if self.text: - return self.text - else: - return "%s|%s" % (self.name, self.attrs) - - def searchTag(self, markupName=None, markupAttrs={}): - found = None - markup = None - if isinstance(markupName, Tag): - markup = markupName - markupAttrs = markup - callFunctionWithTagData = callable(self.name) and \ - not isinstance(markupName, Tag) - - if not self.name or callFunctionWithTagData or \ - (markup and self._matches(markup, self.name)) or \ - (not markup and self._matches(markupName, self.name)): - if callFunctionWithTagData: - match = self.name(markupName, markupAttrs) - else: - match = True - markupAttrMap = None - for attr, matchAgainst in self.attrs.items(): - if not markupAttrMap: - if hasattr(markupAttrs, 'get'): - markupAttrMap = markupAttrs - else: - markupAttrMap = {} - for k, v in markupAttrs: - markupAttrMap[k] = v - attrValue = markupAttrMap.get(attr) - if not self._matches(attrValue, matchAgainst): - match = False - break - if match: - if markup: - found = markup - else: - found = markupName - return found - - def search(self, markup): - found = None - # If given a list of items, scan it for a text element that - # matches. - if isList(markup) and not isinstance(markup, Tag): - for element in markup: - if isinstance(element, NavigableString) and \ - self.search(element): - found = element - break - # If it's a Tag, make sure its name or attributes match. - # Don't bother with Tags if we're searching for text. - elif isinstance(markup, Tag): - if not self.text: - found = self.searchTag(markup) - # If it's text, make sure the text matches. - elif isinstance(markup, NavigableString) or isString(markup): - if self._matches(markup, self.text): - found = markup - else: - raise Exception("I don't know how to match against a %s" % - markup.__class__) - return found - - def _matches(self, markup, matchAgainst): - result = False - if matchAgainst is True: - result = markup is not None - elif callable(matchAgainst): - result = matchAgainst(markup) - else: - # Custom match methods take the tag as an argument, but all - # other ways of matching match the tag name as a string. - if isinstance(markup, Tag): - markup = markup.name - if markup and not isString(markup): - markup = six.text_type(markup) - # Now we know that chunk is either a string, or None. - if hasattr(matchAgainst, 'match'): - # It's a regexp object. - result = markup and matchAgainst.search(markup) - elif isList(matchAgainst): - result = markup in matchAgainst - elif hasattr(matchAgainst, 'items'): - result = matchAgainst in markup - elif matchAgainst and isString(markup): - if isinstance(markup, six.text_type): - matchAgainst = six.text_type(matchAgainst) - else: - matchAgainst = str(matchAgainst) - - if not result: - result = matchAgainst == markup - return result - - -class ResultSet(list): - """A ResultSet is just a list that keeps track of the SoupStrainer - that created it.""" - - def __init__(self, source): - list.__init__([]) - self.source = source - -# Now, some helper functions. - - -def isList(l): - """Convenience method to determine whether or not something is listlike.""" - return hasattr(l, '__iter__') or \ - type(l) in (list, tuple) - - -def isString(s): - """Convenience method to determine whether or not something is stringlike.""" - return isinstance(s, six.string_types) - - -def buildTagMap(default, *args): - """Turns a list of maps, lists, or scalars into a single map. - Used to build the SELF_CLOSING_TAGS, NESTABLE_TAGS, and - NESTING_RESET_TAGS maps out of lists and partial maps.""" - built = {} - for portion in args: - if hasattr(portion, 'items'): - # It's a map. Merge it. - for k, v in portion.items(): - built[k] = v - elif isList(portion): - # It's a list. Map each item to the default. - for k in portion: - built[k] = default - else: - # It's a scalar. Map it to the default. - built[portion] = default - return built - -# Now, the parser classes. - - -class BeautifulStoneSoup(Tag, SGMLParser): - - """This class contains the basic parser and search code. It defines - a parser that knows nothing about tag behavior except for the - following: - - You can't close a tag without closing all the tags it encloses. - That is, "" actually means - "". - - [Another possible explanation is "", but since - this class defines no SELF_CLOSING_TAGS, it will never use that - explanation.] - - This class is useful for parsing XML or made-up markup languages, - or when BeautifulSoup makes an assumption counter to what you were - expecting.""" - - SELF_CLOSING_TAGS = {} - NESTABLE_TAGS = {} - RESET_NESTING_TAGS = {} - QUOTE_TAGS = {} - - MARKUP_MASSAGE = [(re.compile('(<[^<>]*)/>'), - lambda x: x.group(1) + ' />'), - (re.compile(']*)>'), - lambda x: '') - ] - - ROOT_TAG_NAME = u'[document]' - - HTML_ENTITIES = "html" - XML_ENTITIES = "xml" - ALL_ENTITIES = [HTML_ENTITIES, XML_ENTITIES] - - def __init__(self, markup="", parseOnlyThese=None, fromEncoding=None, - markupMassage=True, smartQuotesTo=XML_ENTITIES, - convertEntities=None, selfClosingTags=None): - """The Soup object is initialized as the 'root tag', and the - provided markup (which can be a string or a file-like object) - is fed into the underlying parser. - - sgmllib will process most bad HTML, and the BeautifulSoup - class has some tricks for dealing with some HTML that kills - sgmllib, but Beautiful Soup can nonetheless choke or lose data - if your data uses self-closing tags or declarations - incorrectly. - - By default, Beautiful Soup uses regexes to sanitize input, - avoiding the vast majority of these problems. If the problems - don't apply to you, pass in False for markupMassage, and - you'll get better performance. - - The default parser massage techniques fix the two most common - instances of invalid HTML that choke sgmllib: - -
(No space between name of closing tag and tag close) - (Extraneous whitespace in declaration) - - You can pass in a custom list of (RE object, replace method) - tuples to get Beautiful Soup to scrub your input the way you - want.""" - - self.parseOnlyThese = parseOnlyThese - self.fromEncoding = fromEncoding - self.smartQuotesTo = smartQuotesTo - - if convertEntities: - # It doesn't make sense to convert encoded characters to - # entities even while you're converting entities to Unicode. - # Just convert it all to Unicode. - self.smartQuotesTo = None - - if isList(convertEntities): - self.convertHTMLEntities = self.HTML_ENTITIES in convertEntities - self.convertXMLEntities = self.XML_ENTITIES in convertEntities - else: - self.convertHTMLEntities = self.HTML_ENTITIES == convertEntities - self.convertXMLEntities = self.XML_ENTITIES == convertEntities - - self.instanceSelfClosingTags = buildTagMap(None, selfClosingTags) - SGMLParser.__init__(self) - - if hasattr(markup, 'read'): # It's a file-type object. - markup = markup.read() - self.markup = markup - self.markupMassage = markupMassage - try: - self._feed() - except StopParsing: - pass - self.markup = None # The markup can now be GCed - - def _feed(self, inDocumentEncoding=None): - # Convert the document to Unicode. - markup = self.markup - if isinstance(markup, six.text_type): - if not hasattr(self, 'originalEncoding'): - self.originalEncoding = None - else: - dammit = UnicodeDammit(markup, - [self.fromEncoding, inDocumentEncoding], - smartQuotesTo=self.smartQuotesTo) - markup = dammit.unicode - self.originalEncoding = dammit.originalEncoding - if markup: - if self.markupMassage: - if not isList(self.markupMassage): - self.markupMassage = self.MARKUP_MASSAGE - for fix, m in self.markupMassage: - markup = fix.sub(m, markup) - self.reset() - - SGMLParser.feed(self, markup or "") - SGMLParser.close(self) - # Close out any unfinished strings and close all the open tags. - self.endData() - while self.currentTag.name != self.ROOT_TAG_NAME: - self.popTag() - - def __getattr__(self, methodName): - """This method routes method call requests to either the SGMLParser - superclass or the Tag superclass, depending on the method name.""" - if methodName.find('start_') == 0 or methodName.find('end_') == 0 or \ - methodName.find('do_') == 0: - return SGMLParser.__getattr__(self, methodName) - elif methodName.find('__') != 0: - return Tag.__getattr__(self, methodName) - else: - raise AttributeError - - def isSelfClosingTag(self, name): - """Returns true iff the given string is the name of a - self-closing tag according to this parser.""" - return name in self.SELF_CLOSING_TAGS or \ - name in self.instanceSelfClosingTags - - def reset(self): - Tag.__init__(self, self, self.ROOT_TAG_NAME) - self.hidden = 1 - SGMLParser.reset(self) - self.currentData = [] - self.currentTag = None - self.tagStack = [] - self.quoteStack = [] - self.pushTag(self) - - def popTag(self): - self.tagStack.pop() - # Tags with just one string-owning child get the child as a - # 'string' property, so that soup.tag.string is shorthand for - # soup.tag.contents[0] - if len(self.currentTag.contents) == 1 and \ - isinstance(self.currentTag.contents[0], NavigableString): - self.currentTag.string = self.currentTag.contents[0] - - if self.tagStack: - self.currentTag = self.tagStack[-1] - return self.currentTag - - def pushTag(self, tag): - if self.currentTag: - self.currentTag.append(tag) - self.tagStack.append(tag) - self.currentTag = self.tagStack[-1] - - def endData(self, containerClass=NavigableString): - if self.currentData: - currentData = ''.join(self.currentData) - if currentData.endswith('<') and self.convertHTMLEntities: - currentData = currentData[:-1] + '<' - if not currentData.strip(): - if '\n' in currentData: - currentData = '\n' - else: - currentData = ' ' - self.currentData = [] - if self.parseOnlyThese and len(self.tagStack) <= 1 and \ - (not self.parseOnlyThese.text or - not self.parseOnlyThese.search(currentData)): - return - o = containerClass(currentData) - o.setup(self.currentTag, self.previous) - if self.previous: - self.previous.next = o - self.previous = o - self.currentTag.contents.append(o) - - def _popToTag(self, name, inclusivePop=True): - """Pops the tag stack up to and including the most recent - instance of the given tag. If inclusivePop is false, pops the tag - stack up to but *not* including the most recent instqance of - the given tag.""" - if name == self.ROOT_TAG_NAME: - return - - numPops = 0 - mostRecentTag = None - for i in range(len(self.tagStack) - 1, 0, -1): - if name == self.tagStack[i].name: - numPops = len(self.tagStack) - i - break - if not inclusivePop: - numPops = numPops - 1 - - for i in range(0, numPops): - mostRecentTag = self.popTag() - return mostRecentTag - - def _smartPop(self, name): - """We need to pop up to the previous tag of this type, unless - one of this tag's nesting reset triggers comes between this - tag and the previous tag of this type, OR unless this tag is a - generic nesting trigger and another generic nesting trigger - comes between this tag and the previous tag of this type. - - Examples: -

FooBar

should pop to 'p', not 'b'. -

FooBar

should pop to 'table', not 'p'. -

Foo

Bar

should pop to 'tr', not 'p'. -

FooBar

should pop to 'p', not 'b'. - -

    • *
    • * should pop to 'ul', not the first 'li'. -
  • ** should pop to 'table', not the first 'tr' - tag should - implicitly close the previous tag within the same
    ** should pop to 'tr', not the first 'td' - """ - - nestingResetTriggers = self.NESTABLE_TAGS.get(name) - isNestable = nestingResetTriggers is not None - isResetNesting = name in self.RESET_NESTING_TAGS - popTo = None - inclusive = True - for i in range(len(self.tagStack) - 1, 0, -1): - p = self.tagStack[i] - if (not p or p.name == name) and not isNestable: - # Non-nestable tags get popped to the top or to their - # last occurance. - popTo = name - break - if (nestingResetTriggers is not None and - p.name in nestingResetTriggers) or \ - (nestingResetTriggers is None and isResetNesting and - p.name in self.RESET_NESTING_TAGS): - # If we encounter one of the nesting reset triggers - # peculiar to this tag, or we encounter another tag - # that causes nesting to reset, pop up to but not - # including that tag. - popTo = p.name - inclusive = False - break - p = p.parent - if popTo: - self._popToTag(popTo, inclusive) - - def unknown_starttag(self, name, attrs, selfClosing=0): - if self.quoteStack: - # This is not a real tag. - attrs = ''.join(' %s="%s"' % (x, y) for x, y in attrs) - self.currentData.append('<%s%s>' % (name, attrs)) - return - self.endData() - - if not self.isSelfClosingTag(name) and not selfClosing: - self._smartPop(name) - - if self.parseOnlyThese and len(self.tagStack) <= 1 and \ - (self.parseOnlyThese.text or not self.parseOnlyThese.searchTag(name, attrs)): - return - - tag = Tag(self, name, attrs, self.currentTag, self.previous) - if self.previous: - self.previous.next = tag - self.previous = tag - self.pushTag(tag) - if selfClosing or self.isSelfClosingTag(name): - self.popTag() - if name in self.QUOTE_TAGS: - self.quoteStack.append(name) - self.literal = 1 - return tag - - def unknown_endtag(self, name): - if self.quoteStack and self.quoteStack[-1] != name: - # This is not a real end tag. - self.currentData.append('' % name) - return - self.endData() - self._popToTag(name) - if self.quoteStack and self.quoteStack[-1] == name: - self.quoteStack.pop() - self.literal = (len(self.quoteStack) > 0) - - def handle_data(self, data): - if self.convertHTMLEntities: - if data[0] == '&': - data = self.BARE_AMPERSAND.sub("&", data) - else: - data = data.replace('&', '&') \ - .replace('<', '<') \ - .replace('>', '>') - self.currentData.append(data) - - def _toStringSubclass(self, text, subclass): - """Adds a certain piece of text to the tree as a NavigableString - subclass.""" - self.endData() - self.handle_data(text) - self.endData(subclass) - - def handle_pi(self, text): - """Handle a processing instruction as a ProcessingInstruction - object, possibly one with a %SOUP-ENCODING% slot into which an - encoding will be plugged later.""" - if text[:3] == "xml": - text = "xml version='1.0' encoding='%SOUP-ENCODING%'" - self._toStringSubclass(text, ProcessingInstruction) - - def handle_comment(self, text): - "Handle comments as Comment objects." - self._toStringSubclass(text, Comment) - - def handle_charref(self, ref): - "Handle character references as data." - if ref[0] == 'x': - data = six.unichr(int(ref[1:], 16)) - else: - data = six.unichr(int(ref)) - - if u'\x80' <= data <= u'\x9F': - data = UnicodeDammit.subMSChar(chr(ord(data)), self.smartQuotesTo) - elif not self.convertHTMLEntities and not self.convertXMLEntities: - data = '&#%s;' % ref - - self.handle_data(data) - - def handle_entityref(self, ref): - """Handle entity references as data, possibly converting known - HTML entity references to the corresponding Unicode - characters.""" - replaceWithXMLEntity = self.convertXMLEntities and \ - ref in self.XML_ENTITIES_TO_CHARS - if self.convertHTMLEntities or replaceWithXMLEntity: - try: - data = six.unichr(name2codepoint[ref]) - except KeyError: - if replaceWithXMLEntity: - data = self.XML_ENTITIES_TO_CHARS.get(ref) - else: - data = "&%s" % ref - else: - data = '&%s;' % ref - self.handle_data(data) - - def handle_decl(self, data): - "Handle DOCTYPEs and the like as Declaration objects." - self._toStringSubclass(data, Declaration) - - def parse_declaration(self, i): - """Treat a bogus SGML declaration as raw data. Treat a CDATA - declaration as a CData object.""" - j = None - if self.rawdata[i:i + 9] == '', i) - if k == -1: - k = len(self.rawdata) - data = self.rawdata[i + 9:k] - j = k + 3 - self._toStringSubclass(data, CData) - else: - try: - j = SGMLParser.parse_declaration(self, i) - except SGMLParseError: - toHandle = self.rawdata[i:] - self.handle_data(toHandle) - j = i + len(toHandle) - return j - - -class BeautifulSoup(BeautifulStoneSoup): - - """This parser knows the following facts about HTML: - - * Some tags have no closing tag and should be interpreted as being - closed as soon as they are encountered. - - * The text inside some tags (ie. 'script') may contain tags which - are not really part of the document and which should be parsed - as text, not tags. If you want to parse the text as tags, you can - always fetch it and parse it explicitly. - - * Tag nesting rules: - - Most tags can't be nested at all. For instance, the occurance of - a

    tag should implicitly close the previous

    tag. - -

    Para1

    Para2 - should be transformed into: -

    Para1

    Para2 - - Some tags can be nested arbitrarily. For instance, the occurance - of a

    tag should _not_ implicitly close the previous -
    tag. - - Alice said:
    Bob said:
    Blah - should NOT be transformed into: - Alice said:
    Bob said:
    Blah - - Some tags can be nested, but the nesting is reset by the - interposition of other tags. For instance, a
    , - but not close a tag in another table. - -
    BlahBlah - should be transformed into: -
    BlahBlah - but, - Blah
    Blah - should NOT be transformed into - Blah
    Blah - - Differing assumptions about tag nesting rules are a major source - of problems with the BeautifulSoup class. If BeautifulSoup is not - treating as nestable a tag your page author treats as nestable, - try ICantBelieveItsBeautifulSoup, MinimalSoup, or - BeautifulStoneSoup before writing your own subclass.""" - - def __init__(self, *args, **kwargs): - if 'smartQuotesTo' not in kwargs: - kwargs['smartQuotesTo'] = self.HTML_ENTITIES - BeautifulStoneSoup.__init__(self, *args, **kwargs) - - SELF_CLOSING_TAGS = buildTagMap(None, - ['br', 'hr', 'input', 'img', 'meta', - 'spacer', 'link', 'frame', 'base']) - - QUOTE_TAGS = {'script': None} - - # According to the HTML standard, each of these inline tags can - # contain another tag of the same type. Furthermore, it's common - # to actually use these tags this way. - NESTABLE_INLINE_TAGS = ['span', 'font', 'q', 'object', 'bdo', 'sub', 'sup', - 'center'] - - # According to the HTML standard, these block tags can contain - # another tag of the same type. Furthermore, it's common - # to actually use these tags this way. - NESTABLE_BLOCK_TAGS = ['blockquote', 'div', 'fieldset', 'ins', 'del'] - - # Lists can contain other lists, but there are restrictions. - NESTABLE_LIST_TAGS = {'ol': [], - 'ul': [], - 'li': ['ul', 'ol'], - 'dl': [], - 'dd': ['dl'], - 'dt': ['dl']} - - # Tables can contain other tables, but there are restrictions. - NESTABLE_TABLE_TAGS = {'table': [], - 'tr': ['table', 'tbody', 'tfoot', 'thead'], - 'td': ['tr'], - 'th': ['tr'], - 'thead': ['table'], - 'tbody': ['table'], - 'tfoot': ['table'], - } - - NON_NESTABLE_BLOCK_TAGS = ['address', 'form', 'p', 'pre'] - - # If one of these tags is encountered, all tags up to the next tag of - # this type are popped. - RESET_NESTING_TAGS = buildTagMap(None, NESTABLE_BLOCK_TAGS, 'noscript', - NON_NESTABLE_BLOCK_TAGS, - NESTABLE_LIST_TAGS, - NESTABLE_TABLE_TAGS) - - NESTABLE_TAGS = buildTagMap([], NESTABLE_INLINE_TAGS, NESTABLE_BLOCK_TAGS, - NESTABLE_LIST_TAGS, NESTABLE_TABLE_TAGS) - - # Used to detect the charset in a META tag; see start_meta - CHARSET_RE = re.compile("((^|;)\s*charset=)([^;]*)") - - def start_meta(self, attrs): - """Beautiful Soup can detect a charset included in a META tag, - try to convert the document to that charset, and re-parse the - document from the beginning.""" - httpEquiv = None - contentType = None - contentTypeIndex = None - tagNeedsEncodingSubstitution = False - - for i in range(0, len(attrs)): - key, value = attrs[i] - key = key.lower() - if key == 'http-equiv': - httpEquiv = value - elif key == 'content': - contentType = value - contentTypeIndex = i - - if httpEquiv and contentType: # It's an interesting meta tag. - match = self.CHARSET_RE.search(contentType) - if match: - if getattr(self, 'declaredHTMLEncoding') or \ - self.originalEncoding == self.fromEncoding: - # This is our second pass through the document, or - # else an encoding was specified explicitly and it - # worked. Rewrite the meta tag. - newAttr = self.CHARSET_RE.sub( - lambda match: match.group(1) + "%SOUP-ENCODING%", - value) - attrs[contentTypeIndex] = (attrs[contentTypeIndex][0], - newAttr) - tagNeedsEncodingSubstitution = True - else: - # This is our first pass through the document. - # Go through it again with the new information. - newCharset = match.group(3) - if newCharset and newCharset != self.originalEncoding: - self.declaredHTMLEncoding = newCharset - self._feed(self.declaredHTMLEncoding) - raise StopParsing - tag = self.unknown_starttag("meta", attrs) - if tag and tagNeedsEncodingSubstitution: - tag.containsSubstitutions = True - - -class StopParsing(Exception): - pass - - -class ICantBelieveItsBeautifulSoup(BeautifulSoup): - - """The BeautifulSoup class is oriented towards skipping over - common HTML errors like unclosed tags. However, sometimes it makes - errors of its own. For instance, consider this fragment: - - FooBar - - This is perfectly valid (if bizarre) HTML. However, the - BeautifulSoup class will implicitly close the first b tag when it - encounters the second 'b'. It will think the author wrote - "FooBar", and didn't close the first 'b' tag, because - there's no real-world reason to bold something that's already - bold. When it encounters '' it will close two more 'b' - tags, for a grand total of three tags closed instead of two. This - can throw off the rest of your document structure. The same is - true of a number of other tags, listed below. - - It's much more common for someone to forget to close a 'b' tag - than to actually use nested 'b' tags, and the BeautifulSoup class - handles the common case. This class handles the not-co-common - case: where you can't believe someone wrote what they did, but - it's valid HTML and BeautifulSoup screwed up by assuming it - wouldn't be.""" - - I_CANT_BELIEVE_THEYRE_NESTABLE_INLINE_TAGS = ['em', 'big', 'i', 'small', - 'tt', 'abbr', 'acronym', 'strong', 'cite', 'code', 'dfn', 'kbd', 'samp', - 'strong', 'var', 'b', 'big'] - - I_CANT_BELIEVE_THEYRE_NESTABLE_BLOCK_TAGS = ['noscript'] - - NESTABLE_TAGS = buildTagMap([], BeautifulSoup.NESTABLE_TAGS, - I_CANT_BELIEVE_THEYRE_NESTABLE_BLOCK_TAGS, - I_CANT_BELIEVE_THEYRE_NESTABLE_INLINE_TAGS) - - -class MinimalSoup(BeautifulSoup): - """The MinimalSoup class is for parsing HTML that contains - pathologically bad markup. It makes no assumptions about tag - nesting, but it does know which tags are self-closing, that -