From b919110deb08917436497b6e4127c6dee3b6152f Mon Sep 17 00:00:00 2001 From: John Chilton Date: Mon, 19 Aug 2024 10:51:18 -0400 Subject: [PATCH] Improvements to help terms & tool help. - Allow tools to declare markdown help. - Allow tools to reference help terms from their Markdown tools. - Auto generate help terms for each datatype from values in datatypes_conf.xml - Add permenant links for each help term - both the auto generated ones and the YAML specified ones. - Add some initial help terms for collection operations. - Use new help term for "map over" from tool form. --- .../Form/Elements/FormData/FormData.vue | 7 +- client/src/components/Help/HelpPopover.vue | 18 +++ client/src/components/Help/HelpTerm.vue | 31 ++++ client/src/components/Help/HelpText.vue | 27 +--- client/src/components/Help/terms.yml | 25 ++++ client/src/components/Tool/ToolCard.vue | 2 +- client/src/components/Tool/ToolHelp.vue | 54 ++----- .../src/components/Tool/ToolHelpMarkdown.vue | 63 ++++++++ .../{ToolHelp.test.js => ToolHelpRst.test.js} | 6 +- client/src/components/Tool/ToolHelpRst.vue | 42 ++++++ client/src/entry/analysis/router.js | 6 + client/src/stores/helpTermsStore.ts | 134 ++++++++++++++++++ lib/galaxy/tool_util/parser/cwl.py | 7 +- lib/galaxy/tool_util/parser/interface.py | 13 +- lib/galaxy/tool_util/parser/xml.py | 10 +- lib/galaxy/tool_util/parser/yaml.py | 6 +- lib/galaxy/tool_util/xsd/galaxy.xsd | 63 ++++++++ lib/galaxy/tools/__init__.py | 60 +++++--- lib/galaxy/webapps/galaxy/buildapp.py | 1 + lib/galaxy/workflow/modules.py | 2 +- 20 files changed, 480 insertions(+), 97 deletions(-) create mode 100644 client/src/components/Help/HelpPopover.vue create mode 100644 client/src/components/Help/HelpTerm.vue create mode 100644 client/src/components/Tool/ToolHelpMarkdown.vue rename client/src/components/Tool/{ToolHelp.test.js => ToolHelpRst.test.js} (87%) create mode 100644 client/src/components/Tool/ToolHelpRst.vue create mode 100644 client/src/stores/helpTermsStore.ts diff --git a/client/src/components/Form/Elements/FormData/FormData.vue b/client/src/components/Form/Elements/FormData/FormData.vue index f6054f28cd00..d537016f6cb4 100644 --- a/client/src/components/Form/Elements/FormData/FormData.vue +++ b/client/src/components/Form/Elements/FormData/FormData.vue @@ -17,6 +17,7 @@ import { BATCH, SOURCE, VARIANTS } from "./variants"; import FormSelection from "../FormSelection.vue"; import FormSelect from "@/components/Form/Elements/FormSelect.vue"; +import HelpText from "@/components/Help/HelpText.vue"; library.add(faCopy, faFile, faFolder, faCaretDown, faCaretUp, faExclamation, faLink, faUnlink); @@ -650,7 +651,11 @@ const noOptionsWarningMessage = computed(() => {
- + + The supplied input will be this + tool. + + This is a batch mode input field. Individual jobs will be triggered for each dataset.
diff --git a/client/src/components/Help/HelpPopover.vue b/client/src/components/Help/HelpPopover.vue new file mode 100644 index 000000000000..11ded1f423de --- /dev/null +++ b/client/src/components/Help/HelpPopover.vue @@ -0,0 +1,18 @@ + + + diff --git a/client/src/components/Help/HelpTerm.vue b/client/src/components/Help/HelpTerm.vue new file mode 100644 index 000000000000..92db6966591d --- /dev/null +++ b/client/src/components/Help/HelpTerm.vue @@ -0,0 +1,31 @@ + + + diff --git a/client/src/components/Help/HelpText.vue b/client/src/components/Help/HelpText.vue index f3f1b4740af1..f5f6a7006295 100644 --- a/client/src/components/Help/HelpText.vue +++ b/client/src/components/Help/HelpText.vue @@ -1,41 +1,24 @@ diff --git a/client/src/components/Help/terms.yml b/client/src/components/Help/terms.yml index a6df4b852a63..6391c7979e18 100644 --- a/client/src/components/Help/terms.yml +++ b/client/src/components/Help/terms.yml @@ -29,6 +29,31 @@ unix: More information on stack traces can be found on [Wikipedia](https://en.wikipedia.org/wiki/Stack_trace). galaxy: + collections: + flatList: | + A flat list is just a simple dataset collection of type ``list`` that contains only datasets and not + other collections. + mapOver: | + When a tool consumes a dataset but is run with a collection, the collection *maps over* the collection. + This means instead of just running the tool once - the tool will be run once for each element of the + provided collection. Additionally, the outputs of the tool will be collected into a collection that + matches the structure of the provided collection. This matching structure means the output collections + will have the same element identifiers as the provided collection and they will appear in the same order. + + It is easiest to visualize "mapping over" a collection is in the context of a tool that consumes a dataset + and produces a dataset, but the semantics apply rather naturally to tools that consume collections or + produce collections as well. + + For instance, consider a tool that consumes a ``paired`` collection and produces an output dataset. + If a list of paired collections (collection type ``list:paired``) is passed to the tool - it will + will produce a flat list (collection type ``list``) of output datasets with the same number of elements + in the same order as the provided list of ``paired`` collections. + + In the case of outputs, consider a tool that takes in a dataset and produces a flat list. If this tool + is run over a flat list of datasets - that list will be "mapped over" and each element will produce a list. + These lists will be gathered together in a nested list structured (collection type ``list:list``) where + the outer element count and structure matches that of the input and the inner list for each of those + is just the outputs of the tool for the corresponding element of the input. jobs: states: # upload, waiting, failed, paused, deleting, deleted, stop, stopped, skipped. diff --git a/client/src/components/Tool/ToolCard.vue b/client/src/components/Tool/ToolCard.vue index d28e4d46ad96..6c0876d3ac8c 100644 --- a/client/src/components/Tool/ToolCard.vue +++ b/client/src/components/Tool/ToolCard.vue @@ -186,7 +186,7 @@ const showHelpForum = computed(() => isConfigLoaded.value && config.value.enable
Help - +
-import { useFormattedToolHelp } from "composables/formattedToolHelp"; - -const props = defineProps({ - content: { - type: String, - required: true, - }, -}); - -const { formattedContent } = useFormattedToolHelp(props.content); + - - - diff --git a/client/src/components/Tool/ToolHelpMarkdown.vue b/client/src/components/Tool/ToolHelpMarkdown.vue new file mode 100644 index 000000000000..f7dcf424633b --- /dev/null +++ b/client/src/components/Tool/ToolHelpMarkdown.vue @@ -0,0 +1,63 @@ + + + diff --git a/client/src/components/Tool/ToolHelp.test.js b/client/src/components/Tool/ToolHelpRst.test.js similarity index 87% rename from client/src/components/Tool/ToolHelp.test.js rename to client/src/components/Tool/ToolHelpRst.test.js index afbd19d4ba92..40f8e95f2d1e 100644 --- a/client/src/components/Tool/ToolHelp.test.js +++ b/client/src/components/Tool/ToolHelpRst.test.js @@ -1,7 +1,7 @@ import { mount } from "@vue/test-utils"; import { getLocalVue } from "tests/jest/helpers"; -import ToolHelp from "./ToolHelp"; +import ToolHelpRst from "./ToolHelpRst"; const localVue = getLocalVue(); @@ -25,9 +25,9 @@ const expectedHelpText = `
h4 Heading
empty link`; -describe("ToolHelp", () => { +describe("ToolHelp RST", () => { it("modifies help text", () => { - const wrapper = mount(ToolHelp, { + const wrapper = mount(ToolHelpRst, { propsData: { content: inputHelpText, }, diff --git a/client/src/components/Tool/ToolHelpRst.vue b/client/src/components/Tool/ToolHelpRst.vue new file mode 100644 index 000000000000..88420b8b2ee6 --- /dev/null +++ b/client/src/components/Tool/ToolHelpRst.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/client/src/entry/analysis/router.js b/client/src/entry/analysis/router.js index e3bf947b2ce6..0128829043b9 100644 --- a/client/src/entry/analysis/router.js +++ b/client/src/entry/analysis/router.js @@ -7,6 +7,7 @@ import DatasetAttributes from "components/DatasetInformation/DatasetAttributes"; import DatasetDetails from "components/DatasetInformation/DatasetDetails"; import DatasetError from "components/DatasetInformation/DatasetError"; import FormGeneric from "components/Form/FormGeneric"; +import HelpTerm from "components/Help/HelpTerm"; import HistoryExportTasks from "components/History/Export/HistoryExport"; import HistoryPublished from "components/History/HistoryPublished"; import HistoryView from "components/History/HistoryView"; @@ -195,6 +196,11 @@ export function getRouter(Galaxy) { path: "about", component: AboutGalaxy, }, + { + path: "help/terms/:term", + component: HelpTerm, + props: true, + }, { path: "carbon_emissions_calculations", component: CarbonEmissionsCalculations, diff --git a/client/src/stores/helpTermsStore.ts b/client/src/stores/helpTermsStore.ts new file mode 100644 index 000000000000..135749fa6c56 --- /dev/null +++ b/client/src/stores/helpTermsStore.ts @@ -0,0 +1,134 @@ +import { defineStore } from "pinia"; +import { computed, type Ref, ref } from "vue"; + +import { hasHelp as hasHelpTextFromYaml, help as helpTextFromYaml } from "@/components/Help/terms"; + +import { useDatatypeStore } from "./datatypeStore"; + +interface DatatypeDescription { + ext: string; + description: string | null; + descriptionUrl: string | null; +} + +interface RawDatatypeDescription { + id: string; + text: string; + description: string | unknown | null; + description_url: string | unknown | null; + upload_warning: string | unknown | null; +} + +export const useHelpTermsStore = defineStore("helpTermsStore", () => { + const initialized = ref(false); + const datatypeDescriptions = ref(null as DatatypeDescription[] | null); + const datatypeStore = useDatatypeStore(); + + function datatypeDescriptionForExtension(extension: string): DatatypeDescription | null { + if (!datatypeDescriptions.value) { + return null; + } + for (const description of datatypeDescriptions.value) { + if (description.ext == extension) { + return description; + } + } + return null; + } + + function moreInformationFromUrlMarkdown(ext: string, url: string) { + return `More information on the datatype ${ext} can be found at [${url}](${url}).`; + } + + function datatypeDescriptionToMarkdown(datatypeDescription: DatatypeDescription): string { + const ext = datatypeDescription.ext; + let description = datatypeDescription.description?.trimEnd(); + const url = datatypeDescription.descriptionUrl; + if (!description && !url) { + return `${ext} is a registered Galaxy datatype.`; + } else if (!description) { + return moreInformationFromUrlMarkdown(ext, url as string); + } else if (!url) { + return description; + } else { + if (description.charAt(description.length - 1) != ".") { + description += "."; + } + return `${description}\n\n${moreInformationFromUrlMarkdown(ext, url)}`; + } + } + + async function ensureInitialized() { + console.log("in ensure..."); + if (!initialized.value) { + await datatypeStore.fetchUploadDatatypes(); + console.log("done with await..."); + const rawDatatypes = datatypeStore.getUploadDatatypes as RawDatatypeDescription[]; + datatypeDescriptions.value = rawDatatypes.map((datatype: RawDatatypeDescription) => { + return { + ext: datatype.id, + description: datatype.description || null, + descriptionUrl: datatype.description_url || null, + } as DatatypeDescription; + }); + console.log("in here...."); + initialized.value = true; + } + } + + const loading = computed(() => { + return !initialized.value; + }); + + function hasHelpText(term: string): boolean { + console.log("now in here...."); + if (term.startsWith("galaxy.datatypes.extensions.")) { + const extension = term.substring("galaxy.datatypes.extensions.".length); + return datatypeDescriptionForExtension(extension) != null; + } else { + return hasHelpTextFromYaml(term); + } + } + + function helpText(term: string): string | null { + console.log("in here...."); + if (term.startsWith("galaxy.datatypes.extensions.")) { + const extension = term.substring("galaxy.datatypes.extensions.".length); + const description = datatypeDescriptionForExtension(extension); + if (!description) { + return null; + } + return datatypeDescriptionToMarkdown(description); + } else { + return helpTextFromYaml(term); + } + } + + return { + ensureInitialized, + hasHelpText, + helpText, + loading, + }; +}); + +export function useHelpForTerm(uri: Ref) { + const termsStore = useHelpTermsStore(); + termsStore.ensureInitialized(); + + const loading = computed(() => { + return termsStore.loading; + }); + const hasHelp = computed(() => { + return termsStore.hasHelpText(uri.value); + }); + const help = computed(() => { + return termsStore.helpText(uri.value); + }); + + return { + loading, + hasHelp, + help, + }; +} diff --git a/lib/galaxy/tool_util/parser/cwl.py b/lib/galaxy/tool_util/parser/cwl.py index 571a7faa4172..6891dd9590c6 100644 --- a/lib/galaxy/tool_util/parser/cwl.py +++ b/lib/galaxy/tool_util/parser/cwl.py @@ -11,6 +11,7 @@ ) from galaxy.tool_util.deps import requirements from .interface import ( + HelpContent, PageSource, PagesSource, ToolSource, @@ -80,7 +81,11 @@ def parse_edam_topics(self): return [] def parse_help(self): - return self.tool_proxy.doc() + doc = self.tool_proxy.doc() + if doc: + return HelpContent(type="plain_text", content=doc) + else: + return None def parse_sanitize(self): return False diff --git a/lib/galaxy/tool_util/parser/interface.py b/lib/galaxy/tool_util/parser/interface.py index a7b3896c289f..f0f06103e80d 100644 --- a/lib/galaxy/tool_util/parser/interface.py +++ b/lib/galaxy/tool_util/parser/interface.py @@ -121,6 +121,11 @@ class Citation(BaseModel): content: str +class HelpContent(BaseModel): + type: Literal["restructuredtext", "plain_text", "markdown"] + content: str + + class ToolSource(metaclass=ABCMeta): """This interface represents an abstract source to parse tool information from. @@ -333,9 +338,11 @@ def parse_stdio(self): return [], [] @abstractmethod - def parse_help(self) -> Optional[str]: - """Return RST definition of help text for tool or None if the tool - doesn't define help text. + def parse_help(self) -> Optional[HelpContent]: + """Return help text for tool or None if the tool doesn't define help text. + + The returned object contains the help text and an indication if it ``rst`` + or ``markdown``. """ @abstractmethod diff --git a/lib/galaxy/tool_util/parser/xml.py b/lib/galaxy/tool_util/parser/xml.py index 9ab4a30f65b6..a84ece1156c0 100644 --- a/lib/galaxy/tool_util/parser/xml.py +++ b/lib/galaxy/tool_util/parser/xml.py @@ -41,6 +41,7 @@ DrillDownDynamicOptions, DrillDownOptionsDict, DynamicOptions, + HelpContent, InputSource, PageSource, PagesSource, @@ -649,9 +650,14 @@ def parse_strict_shell(self): else: return string_as_bool(default) - def parse_help(self): + def parse_help(self) -> Optional[HelpContent]: help_elem = self.root.find("help") - return help_elem.text if help_elem is not None else None + if help_elem is None: + return None + + help_type = help_elem.get("type", "restructuredtext") + content = help_elem.text + return HelpContent(type=help_type, content=content) @property def macro_paths(self): diff --git a/lib/galaxy/tool_util/parser/yaml.py b/lib/galaxy/tool_util/parser/yaml.py index 6813db0f5211..1f3f7976e724 100644 --- a/lib/galaxy/tool_util/parser/yaml.py +++ b/lib/galaxy/tool_util/parser/yaml.py @@ -125,7 +125,11 @@ def parse_stdio(self): return error_on_exit_code() def parse_help(self): - return self.root_dict.get("help", None) + content = self.root_dict.get("help", None) + if content: + return HelpContent(type="markdown", content=content) + else: + return None def parse_outputs(self, tool): outputs = self.root_dict.get("outputs", {}) diff --git a/lib/galaxy/tool_util/xsd/galaxy.xsd b/lib/galaxy/tool_util/xsd/galaxy.xsd index 7c17312c4f0b..9c7225d79e2a 100644 --- a/lib/galaxy/tool_util/xsd/galaxy.xsd +++ b/lib/galaxy/tool_util/xsd/galaxy.xsd @@ -783,6 +783,69 @@ Read more about configuring Galaxy to run Docker jobs + + + Document type of tool help + + + + + + + + + + +.. class:: warningmark + +'''TIP''' This tool requires *fasta* format. + +---- + +'''Example''' + +Query sequence:: + >seq1 + ATCG... + +.. image:: my_figure.png + :height: 500 + :width: 600 + + +``` + +]]> + + + + + + Valid values are ``restructuredtext`` and ``markdown`` + + + + + diff --git a/lib/galaxy/tools/__init__.py b/lib/galaxy/tools/__init__.py index 7193fd6ea618..81d6940db560 100644 --- a/lib/galaxy/tools/__init__.py +++ b/lib/galaxy/tools/__init__.py @@ -78,6 +78,7 @@ ToolOutputCollectionPart, ) from galaxy.tool_util.parser.interface import ( + HelpContent, InputSource, PageSource, ToolSource, @@ -1678,9 +1679,10 @@ def populate_tool_shed_info(self, tool_shed_repository): @property def help(self) -> Template: + assert self.raw_help.type == "restructuredtext" try: return Template( - rst_to_html(self.raw_help), + rst_to_html(self.raw_help.content), input_encoding="utf-8", default_filters=["decode.utf8"], encoding_errors="replace", @@ -1697,23 +1699,25 @@ def biotools_reference(self) -> Optional[str]: """ return biotools_reference(self.xrefs) - def __get_help_with_images(self, raw_help: Optional[str]): - help_text = raw_help or "" - try: - if help_text.find(".. image:: ") >= 0 and (self.tool_shed_repository or self.repository_id): - return set_image_paths( - self.app, - help_text, - encoded_repository_id=self.repository_id, - tool_shed_repository=self.tool_shed_repository, - tool_id=self.old_id, - tool_version=self.version, + def __get_help_with_images(self, help_content: Optional[HelpContent]) -> Optional[HelpContent]: + if help_content and help_content.type == "restructuredtext": + help_text = help_content.content or "" + try: + if help_text.find(".. image:: ") >= 0 and (self.tool_shed_repository or self.repository_id): + help_text = set_image_paths( + self.app, + help_text, + encoded_repository_id=self.repository_id, + tool_shed_repository=self.tool_shed_repository, + tool_id=self.old_id, + tool_version=self.version, + ) + except Exception: + log.exception( + "Exception in parse_help, so images may not be properly displayed for tool with id '%s'", self.id ) - except Exception: - log.exception( - "Exception in parse_help, so images may not be properly displayed for tool with id '%s'", self.id - ) - return help_text + help_content = HelpContent(type="restructuredtext", content=help_text) + return help_content def find_output_def(self, name): # name is JobToOutputDatasetAssociation name. @@ -2505,12 +2509,17 @@ def to_dict(self, trans, link_details=False, io_details=False, tool_help=False): if tool_help: # create tool help help_txt = "" - if self.help: - help_txt = self.help.render( - static_path=self.app.url_for("/static"), host_url=self.app.url_for("/", qualified=True) - ) - help_txt = unicodify(help_txt) + help_type = "restructuredtext" + help_content = self.raw_help + if help_content: + if help_content.type == "restructuredtext": + help_txt = self.help.render( + static_path=self.app.url_for("/static"), host_url=self.app.url_for("/", qualified=True) + ) + help_txt = unicodify(help_txt) + tool_dict["help"] = help_txt + tool_dict["help_type"] = help_content.type return tool_dict @@ -2570,11 +2579,15 @@ def to_json(self, trans, kwd=None, job=None, workflow_building_mode=False, histo # create tool help tool_help = "" - if self.help: + tool_help_type = "restructuredtext" + if self.raw_help and self.raw_help.type == "restructuredtext": tool_help = self.help.render( static_path=self.app.url_for("/static"), host_url=self.app.url_for("/", qualified=True) ) tool_help = unicodify(tool_help, "utf-8") + elif self.raw_help: + tool_help = self.raw_help.content + tool_help_type = self.raw_help.type if isinstance(self.action, tuple): action = self.action[0] + self.app.url_for(self.action[1]) @@ -2586,6 +2599,7 @@ def to_json(self, trans, kwd=None, job=None, workflow_building_mode=False, histo { "id": self.id, "help": tool_help, + "help_type": tool_help_type, "citations": bool(self.citations), "sharable_url": self.sharable_url, "message": tool_message, diff --git a/lib/galaxy/webapps/galaxy/buildapp.py b/lib/galaxy/webapps/galaxy/buildapp.py index 8b13885b45d6..1e40c414f740 100644 --- a/lib/galaxy/webapps/galaxy/buildapp.py +++ b/lib/galaxy/webapps/galaxy/buildapp.py @@ -222,6 +222,7 @@ def app_pair(global_conf, load_app_kwds=None, wsgi_preflight=True, **kwargs): webapp.add_client_route("/admin/form/{form_id}") webapp.add_client_route("/admin/api_keys") webapp.add_client_route("/carbon_emissions_calculations") + webapp.add_client_route("/help/terms/{term_id}") webapp.add_client_route("/datatypes") webapp.add_client_route("/login/start") webapp.add_client_route("/tools/list") diff --git a/lib/galaxy/workflow/modules.py b/lib/galaxy/workflow/modules.py index eeece34fe554..a74a59923114 100644 --- a/lib/galaxy/workflow/modules.py +++ b/lib/galaxy/workflow/modules.py @@ -1854,7 +1854,7 @@ def get_version(self): return self.tool.version if self.tool else self.tool_version def get_tooltip(self, static_path=None): - if self.tool and self.tool.help: + if self.tool and self.tool.raw_help and self.tool.raw_help.type == "restructuredtext": host_url = self.trans.url_builder("/") static_path = self.trans.url_builder(static_path) if static_path else "" return self.tool.help.render(host_url=host_url, static_path=static_path)