diff --git a/lib/galaxy/tool_util/parameters/factory.py b/lib/galaxy/tool_util/parameters/factory.py index b73dde4ab52e..b98bef854fd8 100644 --- a/lib/galaxy/tool_util/parameters/factory.py +++ b/lib/galaxy/tool_util/parameters/factory.py @@ -30,6 +30,7 @@ CwlUnionParameterModel, DataCollectionParameterModel, DataParameterModel, + DrillDownParameterModel, FloatParameterModel, HiddenParameterModel, IntegerParameterModel, @@ -145,6 +146,19 @@ def _from_input_source_galaxy(input_source: InputSource) -> ToolParameterT: options=options, multiple=multiple, ) + elif param_type == "drill_down": + multiple = input_source.get_bool("multiple", False) + hierarchy = input_source.get("hierarchy", "exact") + dynamic_options = input_source.parse_drill_down_dynamic_options() + static_options = None + if dynamic_options is None: + static_options = input_source.parse_drill_down_static_options() + return DrillDownParameterModel( + name=input_source.parse_name(), + multiple=multiple, + hierarchy=hierarchy, + options=static_options, + ) else: raise Exception(f"Unknown Galaxy parameter type {param_type}") elif input_type == "conditional": diff --git a/lib/galaxy/tool_util/parameters/models.py b/lib/galaxy/tool_util/parameters/models.py index 157037c5a5e0..f3abe7f4c199 100644 --- a/lib/galaxy/tool_util/parameters/models.py +++ b/lib/galaxy/tool_util/parameters/models.py @@ -37,6 +37,7 @@ ) from galaxy.exceptions import RequestParameterInvalidException +from galaxy.tool_util.parser.interface import DrillDownOptionsDict from ._types import ( cast_as_type, is_optional, @@ -498,6 +499,61 @@ def request_requires_value(self) -> bool: return self.multiple and not self.optional +DrillDownHierarchyT = Literal["recurse", "exact"] + + +def drill_down_possible_values(options: List[DrillDownOptionsDict], multiple: bool) -> List[str]: + possible_values = [] + + def add_value(option: str, is_leaf: bool): + if not multiple and not is_leaf: + return + possible_values.append(option) + + def walk_selection(option: DrillDownOptionsDict): + child_options = option["options"] + is_leaf = not child_options + add_value(option["value"], is_leaf) + if not is_leaf: + for child_option in child_options: + walk_selection(child_option) + + for option in options: + walk_selection(option) + + return possible_values + + +class DrillDownParameterModel(BaseGalaxyToolParameterModelDefinition): + parameter_type: Literal["gx_drill_down"] = "gx_drill_down" + options: Optional[List[DrillDownOptionsDict]] = None + multiple: bool + hierarchy: DrillDownHierarchyT + + @property + def py_type(self) -> Type: + if self.options is not None: + literal_options: List[Type] = [ + cast_as_type(Literal[o]) for o in drill_down_possible_values(self.options, self.multiple) + ] + py_type = union_type(literal_options) + else: + py_type = StrictStr + + if self.multiple: + py_type = list_type(py_type) + + return py_type + + def pydantic_template(self, state_representation: StateRepresentationT) -> DynamicModelInformation: + return dynamic_model_information_from_py_type(self, self.py_type) + + @property + def request_requires_value(self) -> bool: + # TODO: retest this logic in the API for drill downs + return self.multiple and not self.optional + + DiscriminatorType = Union[bool, str] @@ -829,6 +885,7 @@ def request_requires_value(self) -> bool: DataCollectionParameterModel, DirectoryUriParameterModel, RulesParameterModel, + DrillDownParameterModel, ColorParameterModel, ConditionalParameterModel, RepeatParameterModel, diff --git a/lib/galaxy/tool_util/parser/interface.py b/lib/galaxy/tool_util/parser/interface.py index 0d23dfb1d94e..7b6f7e4a3ed8 100644 --- a/lib/galaxy/tool_util/parser/interface.py +++ b/lib/galaxy/tool_util/parser/interface.py @@ -424,6 +424,20 @@ def get_index_file_name(self) -> Optional[str]: """If dynamic options are loaded from an index file, return the name.""" +DrillDownDynamicFilters = Dict[str, Dict[str, dict]] # {input key: {metadata_key: metadata values}} + + +class DrillDownDynamicOptions(metaclass=ABCMeta): + + @abstractmethod + def from_code_block(self) -> Optional[str]: + """Get a code block to do an eval on.""" + + @abstractmethod + def from_filters(self) -> Optional[DrillDownDynamicFilters]: + """Get filters to apply to target datasets.""" + + class InputSource(metaclass=ABCMeta): default_optional = False @@ -491,12 +505,20 @@ def parse_dynamic_options(self) -> Optional[DynamicOptions]: """ return None + def parse_drill_down_dynamic_options( + self, tool_data_path: Optional[str] = None + ) -> Optional["DrillDownDynamicOptions"]: + return None + def parse_static_options(self) -> List[Tuple[str, str, bool]]: """Return list of static options if this is a select type without defining a dynamic options. """ return [] + def parse_drill_down_static_options(self, tool_data_path: Optional[str = None]) -> Optional[List["DrillDownOptionsDict"]]: + return None + def parse_conversion_tuples(self): """Return list of (name, extension) to describe explicit conversions.""" return [] @@ -673,3 +695,10 @@ def from_dict(as_dict): def to_dict(self): return dict(name=self.name, attributes=self.attrib, element_tests=self.element_tests) + + +class DrillDownOptionsDict(TypedDict): + name: Optional[str] + value: str + options: List["DrillDownOptionsDict"] + selected: bool diff --git a/lib/galaxy/tool_util/parser/xml.py b/lib/galaxy/tool_util/parser/xml.py index 04c28a8297c6..7d9b22a2effa 100644 --- a/lib/galaxy/tool_util/parser/xml.py +++ b/lib/galaxy/tool_util/parser/xml.py @@ -30,12 +30,16 @@ Element, ElementTree, string_as_bool, + XML, xml_text, xml_to_string, ) from .interface import ( AssertionList, Citation, + DrillDownDynamicFilters, + DrillDownDynamicOptions, + DrillDownOptionsDict, DynamicOptions, InputSource, PageSource, @@ -1321,6 +1325,71 @@ def parse_static_options(self) -> List[Tuple[str, str, bool]]: deduplicated_static_options[value] = (text, value, selected) return list(deduplicated_static_options.values()) + def parse_drill_down_dynamic_options( + self, tool_data_path: Optional[str] = None + ) -> Optional[DrillDownDynamicOptions]: + from_file = self.input_elem.get("from_file", None) + if from_file: + if not os.path.isabs(from_file): + assert tool_data_path, "This tool cannot be parsed outside of a Galaxy context" + from_file = os.path.join(tool_data_path, from_file) + elem = XML(f"{open(from_file).read()}") + else: + elem = self.input_elem + + dynamic_options_raw = elem.get("dynamic_options", None) + dynamic_options: Optional[str] = str(dynamic_options_raw) if dynamic_options_raw else None + filters: Optional[DrillDownDynamicFilters] = None + if elem.find("filter"): + _filters: DrillDownDynamicFilters = {} + for filter in elem.findall("filter"): + # currently only filtering by metadata key matching input file is allowed + filter_type = filter.get("type") + if filter_type == "data_meta": + data_ref = filter.get("data_ref") + assert data_ref + if data_ref not in _filters: + _filters[data_ref] = {} + meta_key = filter.get("meta_key") + assert meta_key + if meta_key not in _filters[data_ref]: + _filters[data_ref][meta_key] = {} + meta_value = filter.get("value") + if meta_value not in _filters[data_ref][meta_key]: + _filters[data_ref][meta_key][meta_value] = [] + assert meta_value + _recurse_drill_down_elems( + _filters[data_ref][meta_key][meta_value], + filter.find("options").findall("option"), + ) + filters = _filters + if filters is None and dynamic_options is None: + return None + else: + return XmlDrillDownDynamicOptions( + code_block=dynamic_options, + filters=filters, + ) + + def parse_drill_down_static_options(self, tool_data_path: Optional[str] = None) -> Optional[List[DrillDownOptionsDict]]: + from_file = self.input_elem.get("from_file", None) + if from_file: + if not os.path.isabs(from_file): + assert tool_data_path, "This tool cannot be parsed outside of a Galaxy context" + from_file = os.path.join(tool_data_path, from_file) + elem = XML(f"{open(from_file).read()}") + else: + elem = self.input_elem + + dynamic_options_elem = elem.get("dynamic_options", None) + filter_elem = elem.get("filter", None) + if dynamic_options_elem is not None and filter_elem is not None: + return None + + root_options: List[DrillDownOptionsDict] = [] + _recurse_drill_down_elems(root_options, elem.find("options").findall("option")) + return root_options + def parse_optional(self, default=None): """Return boolean indicating whether parameter is optional.""" elem = self.input_elem @@ -1450,3 +1519,35 @@ def parse_citation_elem(citation_elem: Element) -> Optional[Citation]: type=citation_type, content=content, ) + + +class XmlDrillDownDynamicOptions(DrillDownDynamicOptions): + + def __init__(self, code_block: Optional[str], filters: Optional[DrillDownDynamicFilters]): + self._code_block = code_block + self._filters = filters + + def from_code_block(self) -> Optional[str]: + """Get a code block to do an eval on.""" + return self._code_block + + def from_filters(self) -> Optional[DrillDownDynamicFilters]: + return self._filters + + +def _recurse_drill_down_elems(options: List[DrillDownOptionsDict], option_elems: List[Element]): + for option_elem in option_elems: + selected = string_as_bool(option_elem.get("selected", False)) + nested_options: List[DrillDownOptionsDict] = [] + value = option_elem.get("value") + assert value + current_option: DrillDownOptionsDict = DrillDownOptionsDict( + { + "name": option_elem.get("name"), + "value": value, + "options": nested_options, + "selected": selected, + } + ) + _recurse_drill_down_elems(nested_options, option_elem.findall("option")) + options.append(current_option) diff --git a/lib/galaxy/tools/parameters/basic.py b/lib/galaxy/tools/parameters/basic.py index 5e84b7782fc6..5eeba8aaecaa 100644 --- a/lib/galaxy/tools/parameters/basic.py +++ b/lib/galaxy/tools/parameters/basic.py @@ -53,7 +53,6 @@ string_as_bool, string_as_bool_or_none, unicodify, - XML, ) from galaxy.util.dictifiable import UsesDictVisibleKeys from galaxy.util.expressions import ExpressionContext @@ -168,6 +167,7 @@ class ToolParameter(UsesDictVisibleKeys): of valid choices, validation logic, ...) >>> from galaxy.util.bunch import Bunch + >>> from galaxy.util import XML >>> trans = Bunch(app=None) >>> p = ToolParameter(None, XML('')) >>> assert p.name == 'parameter_name' @@ -272,6 +272,7 @@ def to_text(self, value) -> str: """ Convert a value to a text representation suitable for displaying to the user + >>> from galaxy.util import XML >>> p = ToolParameter(None, XML('')) >>> print(p.to_text(None)) Not available. @@ -390,6 +391,7 @@ class TextToolParameter(SimpleTextToolParameter): Parameter that can take on any text value. >>> from galaxy.util.bunch import Bunch + >>> from galaxy.util import XML >>> trans = Bunch(app=None) >>> p = TextToolParameter(None, XML('')) >>> print(p.name) @@ -430,6 +432,7 @@ class IntegerToolParameter(TextToolParameter): Parameter that takes an integer value. >>> from galaxy.util.bunch import Bunch + >>> from galaxy.util import XML >>> trans = Bunch(app=None, history=Bunch(), workflow_building_mode=True) >>> p = IntegerToolParameter(None, XML('')) >>> print(p.name) @@ -502,6 +505,7 @@ class FloatToolParameter(TextToolParameter): Parameter that takes a real number value. >>> from galaxy.util.bunch import Bunch + >>> from galaxy.util import XML >>> trans = Bunch(app=None, history=Bunch(), workflow_building_mode=True) >>> p = FloatToolParameter(None, XML('')) >>> print(p.name) @@ -576,6 +580,7 @@ class BooleanToolParameter(ToolParameter): Parameter that takes one of two values. >>> from galaxy.util.bunch import Bunch + >>> from galaxy.util import XML >>> trans = Bunch(app=None, history=Bunch()) >>> p = BooleanToolParameter(None, XML('')) >>> print(p.name) @@ -648,6 +653,7 @@ class FileToolParameter(ToolParameter): Parameter that takes an uploaded file as a value. >>> from galaxy.util.bunch import Bunch + >>> from galaxy.util import XML >>> trans = Bunch(app=None, history=Bunch()) >>> p = FileToolParameter(None, XML('')) >>> print(p.name) @@ -721,6 +727,7 @@ class FTPFileToolParameter(ToolParameter): Parameter that takes a file uploaded via FTP as a value. >>> from galaxy.util.bunch import Bunch + >>> from galaxy.util import XML >>> trans = Bunch(app=None, history=Bunch(), user=None) >>> p = FTPFileToolParameter(None, XML('')) >>> print(p.name) @@ -794,6 +801,7 @@ class HiddenToolParameter(ToolParameter): Parameter that takes one of two values. >>> from galaxy.util.bunch import Bunch + >>> from galaxy.util import XML >>> trans = Bunch(app=None, history=Bunch()) >>> p = HiddenToolParameter(None, XML('')) >>> print(p.name) @@ -818,6 +826,7 @@ class ColorToolParameter(ToolParameter): Parameter that stores a color. >>> from galaxy.util.bunch import Bunch + >>> from galaxy.util import XML >>> trans = Bunch(app=None, history=Bunch()) >>> p = ColorToolParameter(None, XML('')) >>> print(p.name) @@ -860,6 +869,7 @@ class BaseURLToolParameter(HiddenToolParameter): current server base url. Used in all redirects. >>> from galaxy.util.bunch import Bunch + >>> from galaxy.util import XML >>> trans = Bunch(app=None, history=Bunch()) >>> p = BaseURLToolParameter(None, XML('')) >>> print(p.name) @@ -901,6 +911,7 @@ class SelectToolParameter(ToolParameter): Parameter that takes on one (or many) or a specific set of values. >>> from galaxy.util.bunch import Bunch + >>> from galaxy.util import XML >>> trans = Bunch(app=None, history=Bunch(), workflow_building_mode=False) >>> p = SelectToolParameter(None, XML( ... ''' @@ -1187,6 +1198,7 @@ class GenomeBuildParameter(SelectToolParameter): >>> # Create a mock transaction with 'hg17' as the current build >>> from galaxy.util.bunch import Bunch + >>> from galaxy.util import XML >>> trans = Bunch(app=None, history=Bunch(genome_build='hg17'), db_builds=read_dbnames(None)) >>> p = GenomeBuildParameter(None, XML('')) >>> print(p.name) @@ -1359,6 +1371,7 @@ class ColumnListParameter(SelectToolParameter): >>> # Mock up a history (not connected to database) >>> from galaxy.model import History, HistoryDatasetAssociation >>> from galaxy.util.bunch import Bunch + >>> from galaxy.util import XML >>> from galaxy.model.mapping import init >>> sa_session = init("/tmp", "sqlite:///:memory:", create_tables=True).session >>> hist = History() @@ -1579,9 +1592,12 @@ class DrillDownSelectToolParameter(SelectToolParameter): Parameter that takes on one (or many) of a specific set of values. Creating a hierarchical select menu, which allows users to 'drill down' a tree-like set of options. + >>> from galaxy.util import XML >>> from galaxy.util.bunch import Bunch - >>> trans = Bunch(app=None, history=Bunch(genome_build='hg17'), db_builds=read_dbnames(None)) - >>> p = DrillDownSelectToolParameter(None, XML( + >>> app = Bunch(config=Bunch(tool_data_path=None)) + >>> tool = Bunch(app=app) + >>> trans = Bunch(app=app, history=Bunch(genome_build='hg17'), db_builds=read_dbnames(None)) + >>> p = DrillDownSelectToolParameter(tool, XML( ... ''' ... ... @@ -1619,54 +1635,24 @@ class DrillDownSelectToolParameter(SelectToolParameter): """ def __init__(self, tool, input_source, context=None): - def recurse_option_elems(cur_options, option_elems): - for option_elem in option_elems: - selected = string_as_bool(option_elem.get("selected", False)) - cur_options.append( - { - "name": option_elem.get("name"), - "value": option_elem.get("value"), - "options": [], - "selected": selected, - } - ) - recurse_option_elems(cur_options[-1]["options"], option_elem.findall("option")) - input_source = ensure_input_source(input_source) ToolParameter.__init__(self, tool, input_source) - # TODO: abstract XML out of here - so non-XML InputSources can - # specify DrillDown parameters. - elem = input_source.elem() - self.multiple = string_as_bool(elem.get("multiple", False)) - self.display = elem.get("display", None) - self.hierarchy = elem.get("hierarchy", "exact") # exact or recurse - self.separator = elem.get("separator", ",") - if from_file := elem.get("from_file", None): - if not os.path.isabs(from_file): - from_file = os.path.join(tool.app.config.tool_data_path, from_file) - elem = XML(f"{open(from_file).read()}") - self.dynamic_options = elem.get("dynamic_options", None) - if self.dynamic_options: - self.is_dynamic = True - self.options = [] - self.filtered: Dict[str, Any] = {} - if elem.find("filter"): + self.multiple = input_source.get_bool("multiple", False) + self.display = input_source.get("display", None) + self.hierarchy = input_source.get("hierarchy", "exact") # exact or recurse + self.separator = input_source.get("separator", ",") + tool_data_path = tool.app.config.tool_data_path + drill_down_dynamic_options = input_source.parse_drill_down_dynamic_options(tool_data_path) + if drill_down_dynamic_options is not None: self.is_dynamic = True - for filter in elem.findall("filter"): - # currently only filtering by metadata key matching input file is allowed - if filter.get("type") == "data_meta": - if filter.get("data_ref") not in self.filtered: - self.filtered[filter.get("data_ref")] = {} - if filter.get("meta_key") not in self.filtered[filter.get("data_ref")]: - self.filtered[filter.get("data_ref")][filter.get("meta_key")] = {} - if filter.get("value") not in self.filtered[filter.get("data_ref")][filter.get("meta_key")]: - self.filtered[filter.get("data_ref")][filter.get("meta_key")][filter.get("value")] = [] - recurse_option_elems( - self.filtered[filter.get("data_ref")][filter.get("meta_key")][filter.get("value")], - filter.find("options").findall("option"), - ) - elif not self.dynamic_options: - recurse_option_elems(self.options, elem.find("options").findall("option")) + self.dynamic_options = drill_down_dynamic_options.code_block + self.filtered = drill_down_dynamic_options.filters + self.options = [] + else: + self.is_dynamic = False + self.dynamic_options = None + self.filtered = {} + self.options = input_source.parse_drill_down_static_options(tool_data_path) def _get_options_from_code(self, trans=None, other_values=None): assert self.dynamic_options, Exception("dynamic_options was not specifed") diff --git a/test/functional/tools/parameters/gx_drill_down_exact.xml b/test/functional/tools/parameters/gx_drill_down_exact.xml new file mode 100644 index 000000000000..42603cc7c4c9 --- /dev/null +++ b/test/functional/tools/parameters/gx_drill_down_exact.xml @@ -0,0 +1,35 @@ + + + macros.xml + + '$output' + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/functional/tools/parameters/gx_drill_down_exact_multiple.xml b/test/functional/tools/parameters/gx_drill_down_exact_multiple.xml new file mode 100644 index 000000000000..303a81089d4b --- /dev/null +++ b/test/functional/tools/parameters/gx_drill_down_exact_multiple.xml @@ -0,0 +1,35 @@ + + + macros.xml + + '$output' + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/functional/tools/parameters/gx_drill_down_recurse.xml b/test/functional/tools/parameters/gx_drill_down_recurse.xml new file mode 100644 index 000000000000..561bc04a955f --- /dev/null +++ b/test/functional/tools/parameters/gx_drill_down_recurse.xml @@ -0,0 +1,32 @@ + + + macros.xml + + '$output' + ]]> + + + + + + + + + + + + + + + + + + diff --git a/test/functional/tools/parameters/gx_drill_down_recurse_multiple.xml b/test/functional/tools/parameters/gx_drill_down_recurse_multiple.xml new file mode 100644 index 000000000000..23c13303db11 --- /dev/null +++ b/test/functional/tools/parameters/gx_drill_down_recurse_multiple.xml @@ -0,0 +1,35 @@ + + + macros.xml + + '$output' + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/functional/tools/parameters/macros.xml b/test/functional/tools/parameters/macros.xml index e47d243f75c4..7b00d8004b34 100644 --- a/test/functional/tools/parameters/macros.xml +++ b/test/functional/tools/parameters/macros.xml @@ -1,4 +1,9 @@ + + + + + > '$output'; @@ -30,4 +35,22 @@ cat '$inputs' >> '$inputs_json'; + + + + + + + + diff --git a/test/unit/tool_util/parameter_specification.yml b/test/unit/tool_util/parameter_specification.yml index 7ada0a8aab89..39b663888642 100644 --- a/test/unit/tool_util/parameter_specification.yml +++ b/test/unit/tool_util/parameter_specification.yml @@ -620,6 +620,17 @@ gx_section_data: request_internal_invalid: - parameter: { data_parameter: { src: hda, id: abcdabcd } } +gx_drill_down_exact: + request_valid: + - parameter: aa + - parameter: bbb + - parameter: ba + request_invalid: + # not multiple so cannot choose a non-leaf + - parameter: a + - parameter: c + - parameter: {} + cwl_int: request_valid: - parameter: 5