diff --git a/lib/galaxy/tool_util/parameters/factory.py b/lib/galaxy/tool_util/parameters/factory.py index ea149e4b7668..cd872e43a974 100644 --- a/lib/galaxy/tool_util/parameters/factory.py +++ b/lib/galaxy/tool_util/parameters/factory.py @@ -1,8 +1,10 @@ from typing import ( Any, + cast, Dict, List, Optional, + Union, ) from galaxy.tool_util.parser.cwl import CwlInputSource @@ -27,7 +29,9 @@ CwlStringParameterModel, CwlUnionParameterModel, DataCollectionParameterModel, + DataColumnParameterModel, DataParameterModel, + DrillDownParameterModel, FloatParameterModel, HiddenParameterModel, IntegerParameterModel, @@ -129,14 +133,9 @@ def _from_input_source_galaxy(input_source: InputSource) -> ToolParameterT: elif param_type == "select": # Function... example in devteam cummeRbund. optional = input_source.parse_optional() - dynamic_options = input_source.get("dynamic_options", None) dynamic_options_config = input_source.parse_dynamic_options() - if dynamic_options_config: - dynamic_options_elem = dynamic_options.elem() - else: - dynamic_options_elem = None + is_static = dynamic_options_config is None multiple = input_source.get_bool("multiple", False) - is_static = dynamic_options is None and dynamic_options_elem is None options: Optional[List[LabelValue]] = None if is_static: options = [] @@ -148,15 +147,40 @@ 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, + ) + elif param_type == "data_column": + return DataColumnParameterModel( + name=input_source.parse_name(), + ) else: raise Exception(f"Unknown Galaxy parameter type {param_type}") elif input_type == "conditional": test_param_input_source = input_source.parse_test_input_source() - test_parameter = _from_input_source_galaxy(test_param_input_source) + test_parameter = cast( + Union[BooleanParameterModel, SelectParameterModel], _from_input_source_galaxy(test_param_input_source) + ) whens = [] default_value = object() if isinstance(test_parameter, BooleanParameterModel): default_value = test_parameter.value + elif isinstance(test_parameter, SelectParameterModel): + select_parameter = cast(SelectParameterModel, test_parameter) + select_default_value = select_parameter.default_value + if select_default_value is not None: + default_value = select_default_value + # TODO: handle select parameter model... for value, case_inputs_sources in input_source.parse_when_input_sources(): if isinstance(test_parameter, BooleanParameterModel): diff --git a/lib/galaxy/tool_util/parameters/models.py b/lib/galaxy/tool_util/parameters/models.py index 49635c4e7187..27efc38feee2 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, @@ -478,9 +479,112 @@ def pydantic_template(self, state_representation: StateRepresentationT) -> Dynam def has_selected_static_option(self): return self.options is not None and any(o.selected for o in self.options) + @property + def default_value(self) -> Optional[str]: + if self.options: + for option in self.options: + if option.selected: + return option.value + # single value pick up first value + if not self.optional: + return self.options[0].value + + return None + + @property + def request_requires_value(self) -> bool: + # API will allow an empty value and just grab the first static option + # see API Tests -> test_tools.py -> test_select_first_by_default + # so only require a value in the multiple case if optional is False + 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: - return not self.optional and not self.has_selected_static_option + options = self.options + if options: + # if any of these are selected, they seem to serve as defaults - check out test_tools -> test_drill_down_first_by_default + return not any_drill_down_options_selected(options) + else: + # I'm not sure how to handle dynamic options... they might or might not be required? + # do we need to default to assuming they're not required? + return False + + +def any_drill_down_options_selected(options: List[DrillDownOptionsDict]) -> bool: + for option in options: + selected = option.get("selected") + if selected: + return True + child_options = option.get("options", []) + if any_drill_down_options_selected(child_options): + return True + + return False + + +class DataColumnParameterModel(BaseGalaxyToolParameterModelDefinition): + parameter_type: Literal["gx_data_column"] = "gx_data_column" + + @property + def py_type(self) -> Type: + return StrictInt + + 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: + return False DiscriminatorType = Union[bool, str] @@ -586,18 +690,31 @@ def pydantic_template(self, state_representation: StateRepresentationT) -> Dynam self.parameters, f"Repeat_{self.name}", state_representation ) + initialize_repeat: Any + if self.request_requires_value: + initialize_repeat = ... + else: + initialize_repeat = None + class RepeatType(RootModel): - root: List[instance_class] = Field(..., min_length=self.min, max_length=self.max) # type: ignore[valid-type] + root: List[instance_class] = Field(initialize_repeat, min_length=self.min, max_length=self.max) # type: ignore[valid-type] return DynamicModelInformation( self.name, - (RepeatType, ...), + (RepeatType, initialize_repeat), {}, ) @property def request_requires_value(self) -> bool: - return True # TODO: + if self.min is None or self.min == 0: + return False + # so we know we need at least one value, but maybe none of the parameters in the list + # are required + for parameter in self.parameters: + if parameter.request_requires_value: + return True + return False class SectionParameterModel(BaseGalaxyToolParameterModelDefinition): @@ -799,8 +916,10 @@ def request_requires_value(self) -> bool: SelectParameterModel, DataParameterModel, DataCollectionParameterModel, + DataColumnParameterModel, 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..89b17698353e 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,22 @@ 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 +697,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 598d5714213e..ef9b8e5d2b48 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, @@ -1326,6 +1330,77 @@ 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 + options_elem = filter.find("options") + assert options_elem + _recurse_drill_down_elems( + _filters[data_ref][meta_key][meta_value], + options_elem.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] = [] + options_elem = elem.find("options") + assert options_elem, "Non-dynamic drilldown parameters must supply an options element" + _recurse_drill_down_elems(root_options, options_elem.findall("option")) + return root_options + def parse_optional(self, default=None): """Return boolean indicating whether parameter is optional.""" elem = self.input_elem @@ -1455,3 +1530,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/tool_util/unittest_utils/parameters.py b/lib/galaxy/tool_util/unittest_utils/parameters.py index 71738b5f4694..704a2ba15091 100644 --- a/lib/galaxy/tool_util/unittest_utils/parameters.py +++ b/lib/galaxy/tool_util/unittest_utils/parameters.py @@ -1,11 +1,15 @@ import os from galaxy.tool_util.parameters import ( - from_input_source, + input_models_for_tool_source, ToolParameterBundle, + ToolParameterBundleModel, ToolParameterT, ) -from galaxy.tool_util.parser.factory import get_tool_source +from galaxy.tool_util.parser import ( + get_tool_source, + ToolSource, +) from galaxy.util import galaxy_directory @@ -19,22 +23,12 @@ def parameter_bundle(parameter: ToolParameterT) -> ParameterBundle: return ParameterBundle(parameter) -def parameter_bundle_for_file(filename: str) -> ParameterBundle: - return parameter_bundle(tool_parameter(filename)) - - -def tool_parameter(filename: str) -> ToolParameterT: - return from_input_source(parameter_source(filename)) - - -def parameter_source(filename: str): +def parameter_bundle_for_file(filename: str) -> ToolParameterBundleModel: tool_source = parameter_tool_source(filename) - input_sources = tool_source.parse_input_pages().page_sources[0].parse_input_sources() - assert len(input_sources) == 1 - return input_sources[0] + return input_models_for_tool_source(tool_source) -def parameter_tool_source(basename: str): +def parameter_tool_source(basename: str) -> ToolSource: path_prefix = os.path.join(galaxy_directory(), "test/functional/tools/parameters", basename) if os.path.exists(f"{path_prefix}.xml"): path = f"{path_prefix}.xml" diff --git a/lib/galaxy/tool_util/verify/__init__.py b/lib/galaxy/tool_util/verify/__init__.py index 2bcb31106bfb..4096c4c9d865 100644 --- a/lib/galaxy/tool_util/verify/__init__.py +++ b/lib/galaxy/tool_util/verify/__init__.py @@ -48,6 +48,10 @@ from galaxy.tool_util.parser.yaml import to_test_assert_list from galaxy.util import unicodify from galaxy.util.compression_utils import get_fileobj +from ._types import ( + ExpandedToolInputsJsonified, + ToolTestDescriptionDict, +) from .asserts import verify_assertions from .test_data import TestDataResolver @@ -641,3 +645,14 @@ def verify_file_contents_against_dict( keep_outputs_dir=test_data_target_dir, verify_extra_files=None, ) + + +__all__ = [ + "DEFAULT_TEST_DATA_RESOLVER", + "ExpandedToolInputsJsonified", + "GetFilenameT", + "GetLocationT", + "ToolTestDescriptionDict", + "verify", + "verify_file_contents_against_dict", +] diff --git a/lib/galaxy/tool_util/verify/_types.py b/lib/galaxy/tool_util/verify/_types.py index 26bb7161715d..e5aa85f1ddb7 100644 --- a/lib/galaxy/tool_util/verify/_types.py +++ b/lib/galaxy/tool_util/verify/_types.py @@ -4,9 +4,21 @@ Any, Dict, List, + Optional, Tuple, ) +from typing_extensions import ( + NotRequired, + TypedDict, +) + +from galaxy.tool_util.parser.interface import ( + AssertionList, + TestSourceTestOutputColllection, + ToolSourceTestOutputs, +) + # inputs that have been processed with parse.py and expanded out ExpandedToolInputs = Dict[str, Any] # ExpandedToolInputs where any model objects have been json-ified with to_dict() @@ -16,3 +28,27 @@ RequiredFilesT = List[RequiredFileTuple] RequiredDataTablesT = List[str] RequiredLocFileT = List[str] + + +class ToolTestDescriptionDict(TypedDict): + tool_id: str + tool_version: Optional[str] + name: str + test_index: int + inputs: ExpandedToolInputsJsonified + outputs: ToolSourceTestOutputs + output_collections: List[TestSourceTestOutputColllection] + stdout: Optional[AssertionList] + stderr: Optional[AssertionList] + expect_exit_code: Optional[int] + expect_failure: bool + expect_test_failure: bool + num_outputs: Optional[int] + command_line: Optional[AssertionList] + command_version: Optional[AssertionList] + required_files: List[Any] + required_data_tables: List[Any] + required_loc_files: List[str] + error: bool + exception: Optional[str] + maxseconds: NotRequired[Optional[int]] diff --git a/lib/galaxy/tool_util/verify/asserts/json.py b/lib/galaxy/tool_util/verify/asserts/json.py index 1ba151ba26c0..9b475f35d980 100644 --- a/lib/galaxy/tool_util/verify/asserts/json.py +++ b/lib/galaxy/tool_util/verify/asserts/json.py @@ -30,8 +30,8 @@ def assert_has_json_property_with_value( value: str, ): """Assert JSON tree contains the specified property with specified JSON-ified value.""" - output_json = json.loads(output) - expected_value = json.loads(value) + output_json = assert_json_and_load(output) + expected_value = assert_json_and_load(value) def is_property(key, value): return key == property and value == expected_value @@ -45,9 +45,16 @@ def assert_has_json_property_with_text( text: str, ): """Assert JSON tree contains the specified property with specified JSON-ified value.""" - output_json = json.loads(output) + output_json = assert_json_and_load(output) def is_property(key, value): return key == property and value == text assert any_in_tree(is_property, output_json), f"Failed to find property [{property}] with text [{text}]" + + +def assert_json_and_load(json_str: str): + try: + return json.loads(json_str) + except Exception: + raise AssertionError(f"Failed to parse JSON from {json_str[0:1024]}.") diff --git a/lib/galaxy/tool_util/verify/interactor.py b/lib/galaxy/tool_util/verify/interactor.py index e403d08fa1aa..e8971a0ec4f5 100644 --- a/lib/galaxy/tool_util/verify/interactor.py +++ b/lib/galaxy/tool_util/verify/interactor.py @@ -55,6 +55,7 @@ RequiredDataTablesT, RequiredFilesT, RequiredLocFileT, + ToolTestDescriptionDict, ) from .asserts import verify_assertions from .wait import wait_on @@ -234,7 +235,7 @@ def get_tests_summary(self): assert response.status_code == 200, f"Non 200 response from tool tests available API. [{response.content}]" return response.json() - def get_tool_tests(self, tool_id: str, tool_version: Optional[str] = None) -> List["ToolTestDescriptionDict"]: + def get_tool_tests(self, tool_id: str, tool_version: Optional[str] = None) -> List[ToolTestDescriptionDict]: url = f"tools/{tool_id}/test_data" params = {"tool_version": tool_version} if tool_version else None response = self._get(url, data=params) @@ -1314,7 +1315,7 @@ def verify_tool( client_test_config: Optional[TestConfig] = None, skip_with_reference_data: bool = False, skip_on_dynamic_param_errors: bool = False, - _tool_test_dicts: Optional[List["ToolTestDescriptionDict"]] = None, # extension point only for tests + _tool_test_dicts: Optional[List[ToolTestDescriptionDict]] = None, # extension point only for tests ): if resource_parameters is None: resource_parameters = {} @@ -1633,30 +1634,6 @@ def __init__(self, output_exceptions, job_stdio): self.output_exceptions = output_exceptions -class ToolTestDescriptionDict(TypedDict): - tool_id: str - tool_version: Optional[str] - name: str - test_index: int - inputs: ExpandedToolInputsJsonified - outputs: ToolSourceTestOutputs - output_collections: List[TestSourceTestOutputColllection] - stdout: Optional[AssertionList] - stderr: Optional[AssertionList] - expect_exit_code: Optional[int] - expect_failure: bool - expect_test_failure: bool - num_outputs: Optional[int] - command_line: Optional[AssertionList] - command_version: Optional[AssertionList] - required_files: List[Any] - required_data_tables: List[Any] - required_loc_files: List[str] - error: bool - exception: Optional[str] - maxseconds: NotRequired[Optional[int]] - - DEFAULT_NUM_OUTPUTS: Optional[int] = None DEFAULT_OUTPUT_COLLECTIONS: List[TestSourceTestOutputColllection] = [] DEFAULT_REQUIRED_FILES: RequiredFilesT = [] @@ -1673,7 +1650,7 @@ class ToolTestDescriptionDict(TypedDict): DEFAULT_EXCEPTION: Optional[str] = None -def adapt_tool_source_dict(processed_dict: ToolTestDict) -> "ToolTestDescriptionDict": +def adapt_tool_source_dict(processed_dict: ToolTestDict) -> ToolTestDescriptionDict: """Convert the dictionaries parsed from tool sources (ToolTestDict) to a ToolTestDescriptionDict. ToolTestDescription is used inside and outside of Galaxy, so convert the dictionaries to the format diff --git a/lib/galaxy/tool_util/verify/script.py b/lib/galaxy/tool_util/verify/script.py index fbb67fc11b37..fca086cf1325 100644 --- a/lib/galaxy/tool_util/verify/script.py +++ b/lib/galaxy/tool_util/verify/script.py @@ -23,10 +23,10 @@ import yaml +from galaxy.tool_util.verify import ToolTestDescriptionDict from galaxy.tool_util.verify.interactor import ( DictClientTestConfig, GalaxyInteractorApi, - ToolTestDescriptionDict, verify_tool, ) 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/lib/galaxy/webapps/galaxy/api/tools.py b/lib/galaxy/webapps/galaxy/api/tools.py index 59ad93ef5f54..f5dacdc64541 100644 --- a/lib/galaxy/webapps/galaxy/api/tools.py +++ b/lib/galaxy/webapps/galaxy/api/tools.py @@ -31,7 +31,7 @@ FetchDataFormPayload, FetchDataPayload, ) -from galaxy.tool_util.verify.interactor import ToolTestDescriptionDict +from galaxy.tool_util.verify import ToolTestDescriptionDict from galaxy.tools.evaluation import global_tool_errors from galaxy.util.zipstream import ZipstreamWrapper from galaxy.web import ( diff --git a/lib/galaxy_test/api/test_tools.py b/lib/galaxy_test/api/test_tools.py index efd2266ffb5a..031939e7064a 100644 --- a/lib/galaxy_test/api/test_tools.py +++ b/lib/galaxy_test/api/test_tools.py @@ -879,6 +879,58 @@ def test_dataset_hidden_after_job_finish(self): output_details = self.dataset_populator.get_history_dataset_details(history_id, dataset=output, wait=True) assert not output_details["visible"] + @skip_without_tool("gx_select") + def test_select_first_by_default(self): + # we have a tool test for this but I wanted to verify it wasn't just the + # tool test framework filling in a default. Creating a raw request here + # verifies that currently select parameters don't require a selection. + with self.dataset_populator.test_history(require_new=False) as history_id: + inputs: Dict[str, Any] = {} + response = self._run("gx_select", history_id, inputs, assert_ok=True) + output = response["outputs"][0] + output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output) + assert output1_content.strip() == "--ex1" + + inputs = { + "parameter": None, + } + response = self._run("gx_select", history_id, inputs, assert_ok=False) + self._assert_status_code_is(response, 400) + assert "an invalid option" in response.text + + @skip_without_tool("gx_drill_down_exact") + @skip_without_tool("gx_drill_down_exact_multiple") + @skip_without_tool("gx_drill_down_recurse") + @skip_without_tool("gx_drill_down_recurse_multiple") + def test_drill_down_first_by_default(self): + # we have a tool test for this but I wanted to verify it wasn't just the + # tool test framework filling in a default. Creating a raw request here + # verifies that currently select parameters don't require a selection. + with self.dataset_populator.test_history(require_new=False) as history_id: + inputs: Dict[str, Any] = {} + response = self._run("gx_drill_down_exact", history_id, inputs, assert_ok=False) + self._assert_status_code_is(response, 400) + assert "an invalid option" in response.text + + response = self._run("gx_drill_down_exact_multiple", history_id, inputs, assert_ok=False) + self._assert_status_code_is(response, 400) + assert "an invalid option" in response.text + + response = self._run("gx_drill_down_recurse", history_id, inputs, assert_ok=False) + self._assert_status_code_is(response, 400) + assert "an invalid option" in response.text + + response = self._run("gx_drill_down_recurse_multiple", history_id, inputs, assert_ok=False) + self._assert_status_code_is(response, 400) + assert "an invalid option" in response.text + + # having an initially selected value - is useful for the UI but doesn't serve + # as a default and doesn't make the drill down optional in a someway. + response = self._run("gx_drill_down_exact_with_selection", history_id, inputs, assert_ok=True) + output = response["outputs"][0] + output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output) + assert output1_content.strip() == "parameter: aba" + @skip_without_tool("multi_select") def test_multi_select_as_list(self): with self.dataset_populator.test_history(require_new=False) as history_id: @@ -905,6 +957,19 @@ def test_multi_select_optional(self): assert output1_content.strip() == "--ex1" assert output2_content.strip() == "None", output2_content + @skip_without_tool("gx_repeat_boolean_min") + def test_optional_repeats_with_mins_filled_id(self): + # we have a tool test for this but I wanted to verify it wasn't just the + # tool test framework filling in a default. Creating a raw request here + # verifies that currently select parameters don't require a selection. + with self.dataset_populator.test_history(require_new=False) as history_id: + inputs: Dict[str, Any] = {} + response = self._run("gx_repeat_boolean_min", history_id, inputs, assert_ok=True) + output = response["outputs"][0] + output1_content = self.dataset_populator.get_history_dataset_content(history_id, dataset=output) + assert "false" in output1_content + assert "length: 2" in output1_content + @skip_without_tool("library_data") def test_library_data_param(self): with self.dataset_populator.test_history(require_new=False) as history_id: diff --git a/lib/tool_shed/webapp/frontend/src/schema/schema.ts b/lib/tool_shed/webapp/frontend/src/schema/schema.ts index 6e53a87611b5..8122e6ae0647 100644 --- a/lib/tool_shed/webapp/frontend/src/schema/schema.ts +++ b/lib/tool_shed/webapp/frontend/src/schema/schema.ts @@ -453,8 +453,10 @@ export interface components { | components["schemas"]["SelectParameterModel"] | components["schemas"]["DataParameterModel"] | components["schemas"]["DataCollectionParameterModel"] + | components["schemas"]["DataColumnParameterModel"] | components["schemas"]["DirectoryUriParameterModel"] | components["schemas"]["RulesParameterModel"] + | components["schemas"]["DrillDownParameterModel"] | components["schemas"]["ColorParameterModel"] | components["schemas"]["ConditionalParameterModel"] | components["schemas"]["RepeatParameterModel"] @@ -689,6 +691,39 @@ export interface components { */ parameter_type?: "gx_data_collection" } + /** DataColumnParameterModel */ + DataColumnParameterModel: { + /** Argument */ + argument?: string | null + /** Help */ + help?: string | null + /** + * Hidden + * @default false + */ + hidden?: boolean + /** + * Is Dynamic + * @default false + */ + is_dynamic?: boolean + /** Label */ + label?: string | null + /** Name */ + name: string + /** + * Optional + * @default false + */ + optional?: boolean + /** + * Parameter Type + * @default gx_data_column + * @constant + * @enum {string} + */ + parameter_type?: "gx_data_column" + } /** DataParameterModel */ DataParameterModel: { /** Argument */ @@ -815,6 +850,59 @@ export interface components { /** Value */ value: string | null } + /** DrillDownOptionsDict */ + DrillDownOptionsDict: { + /** Name */ + name: string | null + /** Options */ + options: components["schemas"]["DrillDownOptionsDict"][] + /** Selected */ + selected: boolean + /** Value */ + value: string + } + /** DrillDownParameterModel */ + DrillDownParameterModel: { + /** Argument */ + argument?: string | null + /** Help */ + help?: string | null + /** + * Hidden + * @default false + */ + hidden?: boolean + /** + * Hierarchy + * @enum {string} + */ + hierarchy: "recurse" | "exact" + /** + * Is Dynamic + * @default false + */ + is_dynamic?: boolean + /** Label */ + label?: string | null + /** Multiple */ + multiple: boolean + /** Name */ + name: string + /** + * Optional + * @default false + */ + optional?: boolean + /** Options */ + options?: components["schemas"]["DrillDownOptionsDict"][] | null + /** + * Parameter Type + * @default gx_drill_down + * @constant + * @enum {string} + */ + parameter_type?: "gx_drill_down" + } /** FailedRepositoryUpdateMessage */ FailedRepositoryUpdateMessage: { /** Err Msg */ @@ -1062,8 +1150,10 @@ export interface components { | components["schemas"]["SelectParameterModel"] | components["schemas"]["DataParameterModel"] | components["schemas"]["DataCollectionParameterModel"] + | components["schemas"]["DataColumnParameterModel"] | components["schemas"]["DirectoryUriParameterModel"] | components["schemas"]["RulesParameterModel"] + | components["schemas"]["DrillDownParameterModel"] | components["schemas"]["ColorParameterModel"] | components["schemas"]["ConditionalParameterModel"] | components["schemas"]["RepeatParameterModel"] @@ -1143,8 +1233,10 @@ export interface components { | components["schemas"]["SelectParameterModel"] | components["schemas"]["DataParameterModel"] | components["schemas"]["DataCollectionParameterModel"] + | components["schemas"]["DataColumnParameterModel"] | components["schemas"]["DirectoryUriParameterModel"] | components["schemas"]["RulesParameterModel"] + | components["schemas"]["DrillDownParameterModel"] | components["schemas"]["ColorParameterModel"] | components["schemas"]["ConditionalParameterModel"] | components["schemas"]["RepeatParameterModel"] @@ -1489,8 +1581,10 @@ export interface components { | components["schemas"]["SelectParameterModel"] | components["schemas"]["DataParameterModel"] | components["schemas"]["DataCollectionParameterModel"] + | components["schemas"]["DataColumnParameterModel"] | components["schemas"]["DirectoryUriParameterModel"] | components["schemas"]["RulesParameterModel"] + | components["schemas"]["DrillDownParameterModel"] | components["schemas"]["ColorParameterModel"] | components["schemas"]["ConditionalParameterModel"] | components["schemas"]["RepeatParameterModel"] diff --git a/test/functional/tools/multiple_versions_changes_v01.xml b/test/functional/tools/multiple_versions_changes_v01.xml index da49bbcda794..a29a4388f198 100644 --- a/test/functional/tools/multiple_versions_changes_v01.xml +++ b/test/functional/tools/multiple_versions_changes_v01.xml @@ -15,7 +15,7 @@ - + diff --git a/test/functional/tools/multiple_versions_changes_v02.xml b/test/functional/tools/multiple_versions_changes_v02.xml index 14ef9dafeebc..8441a2e01b45 100644 --- a/test/functional/tools/multiple_versions_changes_v02.xml +++ b/test/functional/tools/multiple_versions_changes_v02.xml @@ -20,7 +20,7 @@ - + diff --git a/test/functional/tools/multiple_versions_v01galaxy6.xml b/test/functional/tools/multiple_versions_v01galaxy6.xml index 030b6bc70a9f..2ee1b05f34e7 100644 --- a/test/functional/tools/multiple_versions_v01galaxy6.xml +++ b/test/functional/tools/multiple_versions_v01galaxy6.xml @@ -10,7 +10,7 @@ - + diff --git a/test/functional/tools/parameters/gx_conditional_select.xml b/test/functional/tools/parameters/gx_conditional_select.xml new file mode 100644 index 000000000000..51ab24b008b9 --- /dev/null +++ b/test/functional/tools/parameters/gx_conditional_select.xml @@ -0,0 +1,85 @@ + + + macros.xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/functional/tools/parameters/gx_data_column.xml b/test/functional/tools/parameters/gx_data_column.xml new file mode 100644 index 000000000000..19e44530caf3 --- /dev/null +++ b/test/functional/tools/parameters/gx_data_column.xml @@ -0,0 +1,36 @@ + + + macros.xml + + > '$output' + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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_exact_with_selection.xml b/test/functional/tools/parameters/gx_drill_down_exact_with_selection.xml new file mode 100644 index 000000000000..7e6bb9364e1b --- /dev/null +++ b/test/functional/tools/parameters/gx_drill_down_exact_with_selection.xml @@ -0,0 +1,57 @@ + + + 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..22224f92312d --- /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/gx_repeat_boolean_min.xml b/test/functional/tools/parameters/gx_repeat_boolean_min.xml index 7356a6f2ce75..ac30097e095d 100644 --- a/test/functional/tools/parameters/gx_repeat_boolean_min.xml +++ b/test/functional/tools/parameters/gx_repeat_boolean_min.xml @@ -1,6 +1,8 @@ - + > '$output' +#set $repeat_length = len($parameter) +echo 'length: $repeat_length' >> '$output' ]]> @@ -11,5 +13,13 @@ echo '$parameter[0].boolean_parameter' >> '$output' + + + + + + + + diff --git a/test/functional/tools/parameters/gx_select.xml b/test/functional/tools/parameters/gx_select.xml index a4f095b7f3cd..6b325da2550c 100644 --- a/test/functional/tools/parameters/gx_select.xml +++ b/test/functional/tools/parameters/gx_select.xml @@ -23,5 +23,15 @@ echo '$parameter' >> '$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 8064a6505e37..639f711a8825 100644 --- a/test/unit/tool_util/parameter_specification.yml +++ b/test/unit/tool_util/parameter_specification.yml @@ -82,6 +82,8 @@ gx_select: request_valid: - parameter: "--ex1" - parameter: "ex2" + # see API Tests -> test_tools.py -> test_select_first_by_default + - {} request_invalid: # Not allowing selecting booleans by truevalue/falsevalue - don't allow selecting # selects by label. @@ -91,7 +93,18 @@ gx_select: - parameter: null - parameter: {} - parameter: 5 + request_internal_valid: + - parameter: "--ex1" + - parameter: "ex2" + request_internal_invalid: + - parameter: {} + test_case_valid: + - parameter: 'ex2' + - parameter: '--ex1' - {} + test_case_invalid: + - parameter: {} + - parameter: null gx_select_optional: request_valid: @@ -456,6 +469,45 @@ gx_conditional_conditional_boolean: inner_test_parameter: true integer_parameter: true +gx_conditional_select: + request_valid: + - conditional_parameter: + test_parameter: a + integer_parameter: 1 + - conditional_parameter: + test_parameter: a + integer_parameter: 2 + - conditional_parameter: + test_parameter: b + boolean_parameter: true + # Test parameter has default and so does it "case" - so this should be fine + - {} + # # The boolean_parameter is optional so just setting a test_parameter is fine + # - conditional_parameter: + # test_parameter: b + # - conditional_parameter: + # test_parameter: a + # # if test parameter is missing, it should be 'a' in this case + # - conditional_parameter: + # integer_parameter: 4 + # - conditional_parameter: {} + request_invalid: + - conditional_parameter: + test_parameter: b + integer_parameter: 1 + - conditional_parameter: + test_parameter: null + - conditional_parameter: + test_parameter: a + integer_parameter: "1" + - conditional_parameter: + test_parameter: b + integer_parameter: null + # if test parameter is missing, it should be false in this case + # in that case having an integer_parameter is not acceptable. + - conditional_parameter: + boolean_parameter: true + gx_repeat_boolean: request_valid: - parameter: @@ -483,6 +535,10 @@ gx_repeat_boolean_min: - { boolean_parameter: true } - { boolean_parameter: false } - parameter: [{}, {}] + # even though a minimum is set here - each instance of the repeat does not require a value + # so we can skip the repeat all together and Galaxy I think will just use the defaults at + # each stage - see API tests -> test_tools.py -> test_optional_repeats_with_mins_filled_id + - {} request_invalid: - parameter: [] - parameter: [{}] @@ -506,6 +562,8 @@ gx_repeat_data: - parameter: [] - parameter: - { data_parameter: {src: hda, id: abcdabcd} } + # an empty repeat is fine + - {} request_invalid: - parameter: [{}, {}] - parameter: [{}] @@ -523,6 +581,8 @@ gx_repeat_data_min: - { data_parameter: {src: hda, id: abcdabcd} } - { data_parameter: {src: hda, id: abcdabcd} } request_invalid: + # data isn't optional and so we need at least one list element + - {} - parameter: [] - parameter: - { data_parameter: {src: hda, id: abcdabcd} } @@ -560,6 +620,43 @@ 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: {} + # no implicit default currently - see test_drill_down_first_by_default in API test test_tools.py. + - {} + - parameter: null + +gx_drill_down_exact_with_selection: + request_valid: + - parameter: aa + - parameter: bbb + - parameter: ba + # - {} + request_invalid: + # not multiple so cannot choose a non-leaf + - parameter: a + - parameter: c + - parameter: {} + - parameter: null + +gx_data_column: + request_valid: + - { ref_parameter: {src: hda, id: abcdabcd}, parameter: 0 } + request_invalid: + - { ref_parameter: {src: hda, id: abcdabcd}, parameter: "0" } + request_internal_valid: + - { ref_parameter: {src: hda, id: 123}, parameter: 0 } + request_internal_invalid: + - { ref_parameter: {src: hda, id: 123}, parameter: "0" } + cwl_int: request_valid: - parameter: 5 diff --git a/test/unit/tool_util/test_parameter_specification.py b/test/unit/tool_util/test_parameter_specification.py index 81ebc33b3017..52fd285a9b8a 100644 --- a/test/unit/tool_util/test_parameter_specification.py +++ b/test/unit/tool_util/test_parameter_specification.py @@ -14,20 +14,18 @@ encode, RequestInternalToolState, RequestToolState, + ToolParameterBundleModel, validate_internal_job, validate_internal_request, validate_request, validate_test_case, ) from galaxy.tool_util.parameters.json import to_json_schema_string -from galaxy.tool_util.parameters.models import ToolParameterT -from galaxy.tool_util.unittest_utils.parameters import ( - parameter_bundle, - parameter_bundle_for_file, - tool_parameter, -) +from galaxy.tool_util.unittest_utils.parameters import parameter_bundle_for_file from galaxy.util.resources import resource_string +RawStateDict = Dict[str, Any] + def specification_object(): try: @@ -59,105 +57,105 @@ def test_single(): def _test_file(file: str, specification=None): spec = specification or specification_object() combos = spec[file] - tool_parameter_model = tool_parameter(file) + parameter_bundle: ToolParameterBundleModel = parameter_bundle_for_file(file) + + assertion_functions = { + "request_valid": _assert_requests_validate, + "request_invalid": _assert_requests_invalid, + "request_internal_valid": _assert_internal_requests_validate, + "request_internal_invalid": _assert_internal_requests_invalid, + "job_internal_valid": _assert_internal_jobs_validate, + "job_internal_invalid": _assert_internal_jobs_invalid, + "test_case_valid": _assert_test_cases_validate, + "test_case_invalid": _assert_test_cases_invalid, + } + for valid_or_invalid, tests in combos.items(): - if valid_or_invalid == "request_valid": - _assert_requests_validate(tool_parameter_model, tests) - elif valid_or_invalid == "request_invalid": - _assert_requests_invalid(tool_parameter_model, tests) - elif valid_or_invalid == "request_internal_valid": - _assert_internal_requests_validate(tool_parameter_model, tests) - elif valid_or_invalid == "request_internal_invalid": - _assert_internal_requests_invalid(tool_parameter_model, tests) - elif valid_or_invalid == "job_internal_valid": - _assert_internal_jobs_validate(tool_parameter_model, tests) - elif valid_or_invalid == "job_internal_invalid": - _assert_internal_jobs_invalid(tool_parameter_model, tests) - elif valid_or_invalid == "test_case_valid": - _assert_test_cases_validate(tool_parameter_model, tests) - elif valid_or_invalid == "test_case_invalid": - _assert_test_cases_invalid(tool_parameter_model, tests) + assertion_function = assertion_functions[valid_or_invalid] + assertion_function(parameter_bundle, tests) # Assume request validation will work here. if "request_internal_valid" not in combos and "request_valid" in combos: - _assert_internal_requests_validate(tool_parameter_model, combos["request_valid"]) + _assert_internal_requests_validate(parameter_bundle, combos["request_valid"]) if "request_internal_invalid" not in combos and "request_invalid" in combos: - _assert_internal_requests_invalid(tool_parameter_model, combos["request_invalid"]) + _assert_internal_requests_invalid(parameter_bundle, combos["request_invalid"]) -def _for_each(test: Callable, parameter: ToolParameterT, requests: List[Dict[str, Any]]) -> None: +def _for_each(test: Callable, parameters: ToolParameterBundleModel, requests: List[RawStateDict]) -> None: for request in requests: - test(parameter, request) + test(parameters, request) -def _assert_request_validates(parameter: ToolParameterT, request: Dict[str, Any]) -> None: +def _assert_request_validates(parameters: ToolParameterBundleModel, request: RawStateDict) -> None: try: - validate_request(parameter_bundle(parameter), request) + validate_request(parameters, request) except RequestParameterInvalidException as e: - raise AssertionError(f"Parameter {parameter} failed to validate request {request}. {e}") + raise AssertionError(f"Parameters {parameters} failed to validate request {request}. {e}") -def _assert_request_invalid(parameter, request) -> None: +def _assert_request_invalid(parameters: ToolParameterBundleModel, request: RawStateDict) -> None: exc = None try: - validate_request(parameter_bundle(parameter), request) + validate_request(parameters, request) except RequestParameterInvalidException as e: exc = e - assert exc is not None, f"Parameter {parameter} didn't result in validation error on request {request} as expected." + assert ( + exc is not None + ), f"Parameters {parameters} didn't result in validation error on request {request} as expected." -def _assert_internal_request_validates(parameter, request) -> None: +def _assert_internal_request_validates(parameters: ToolParameterBundleModel, request: RawStateDict) -> None: try: - validate_internal_request(parameter_bundle(parameter), request) + validate_internal_request(parameters, request) except RequestParameterInvalidException as e: - raise AssertionError(f"Parameter {parameter} failed to validate internal request {request}. {e}") + raise AssertionError(f"Parameters {parameters} failed to validate internal request {request}. {e}") -def _assert_internal_request_invalid(parameter, request) -> None: +def _assert_internal_request_invalid(parameters: ToolParameterBundleModel, request: RawStateDict) -> None: exc = None try: - validate_internal_request(parameter_bundle(parameter), request) + validate_internal_request(parameters, request) except RequestParameterInvalidException as e: exc = e assert ( exc is not None - ), f"Parameter {parameter} didn't result in validation error on internal request {request} as expected." + ), f"Parameters {parameters} didn't result in validation error on internal request {request} as expected." -def _assert_internal_job_validates(parameter, request) -> None: +def _assert_internal_job_validates(parameters: ToolParameterBundleModel, request: RawStateDict) -> None: try: - validate_internal_job(parameter_bundle(parameter), request) + validate_internal_job(parameters, request) except RequestParameterInvalidException as e: - raise AssertionError(f"Parameter {parameter} failed to validate internal job description {request}. {e}") + raise AssertionError(f"Parameters {parameters} failed to validate internal job description {request}. {e}") -def _assert_internal_job_invalid(parameter, request) -> None: +def _assert_internal_job_invalid(parameters: ToolParameterBundleModel, request: RawStateDict) -> None: exc = None try: - validate_internal_job(parameter_bundle(parameter), request) + validate_internal_job(parameters, request) except RequestParameterInvalidException as e: exc = e assert ( exc is not None - ), f"Parameter {parameter} didn't result in validation error on internal job description {request} as expected." + ), f"Parameters {parameters} didn't result in validation error on internal job description {request} as expected." -def _assert_test_case_validates(parameter, test_case) -> None: +def _assert_test_case_validates(parameters: ToolParameterBundleModel, test_case: RawStateDict) -> None: try: - validate_test_case(parameter_bundle(parameter), test_case) + validate_test_case(parameters, test_case) except RequestParameterInvalidException as e: - raise AssertionError(f"Parameter {parameter} failed to validate test_case {test_case}. {e}") + raise AssertionError(f"Parameters {parameters} failed to validate test_case {test_case}. {e}") -def _assert_test_case_invalid(parameter, test_case) -> None: +def _assert_test_case_invalid(parameters: ToolParameterBundleModel, test_case: RawStateDict) -> None: exc = None try: - validate_test_case(parameter_bundle(parameter), test_case) + validate_test_case(parameters, test_case) except RequestParameterInvalidException as e: exc = e assert ( exc is not None - ), f"Parameter {parameter} didn't result in validation error on test_case {test_case} as expected." + ), f"Parameters {parameters} didn't result in validation error on test_case {test_case} as expected." _assert_requests_validate = partial(_for_each, _assert_request_validates) @@ -215,7 +213,7 @@ def encode_val(val: int) -> str: parameter_spec = specification_object() parameter_models_json = {} for file in parameter_spec.keys(): - tool_parameter_model = tool_parameter(file) + tool_parameter_model = parameter_bundle_for_file(file) parameter_models_json[file] = tool_parameter_model.dict() yaml_str = yaml.safe_dump(parameter_models_json) with open("client/src/components/Tool/parameter_models.yml", "w") as f: