diff --git a/lib/galaxy/tool_util/linters/inputs.py b/lib/galaxy/tool_util/linters/inputs.py index f8a843fb11a..437efe5e76f 100644 --- a/lib/galaxy/tool_util/linters/inputs.py +++ b/lib/galaxy/tool_util/linters/inputs.py @@ -19,12 +19,12 @@ FILTER_TYPES = [ ATTRIB_VALIDATOR_COMPATIBILITY = { "check": ["metadata"], - "expression": ["regex"], + "expression": ["regex", "substitute_value_in_message"], "table_name": ["dataset_metadata_in_data_table", "dataset_metadata_not_in_data_table", "value_in_data_table", "value_not_in_data_table"], "filename": ["dataset_metadata_in_file"], "metadata_name": ["dataset_metadata_in_data_table", "dataset_metadata_not_in_data_table", "dataset_metadata_in_file"], "metadata_column": ["dataset_metadata_in_data_table", "dataset_metadata_not_in_data_table", "value_in_data_table", "value_not_in_data_table", "dataset_metadata_in_file options"], - "line_startswith": ["dataset_metadata_in_file", "dataset_metadata_in_data_table", "dataset_metadata_not_in_data_table", "value_in_data_table", "value_not_in_data_table"], + "line_startswith": ["dataset_metadata_in_file"], "min": ["in_range", "length"], "max": ["in_range", "length"], "exclude_min": ["in_range"], diff --git a/lib/galaxy/tool_util/xsd/galaxy.xsd b/lib/galaxy/tool_util/xsd/galaxy.xsd index 2ecbbf6eec5..1e4ddd10e17 100644 --- a/lib/galaxy/tool_util/xsd/galaxy.xsd +++ b/lib/galaxy/tool_util/xsd/galaxy.xsd @@ -3726,10 +3726,14 @@ parameters a ``metadata`` validator is added automatically. - ``dataset_ok_validator``: Check if the data set is in state OK. - ``dataset_metadata_in_range``: Check if a numeric metadata value is within a given range. -- ``dataset_metadata_in_file``: Check if a metadata value is contained in a -specific column of another data set. -- ``dataset_metadata_in_data_table`` (``dataset_metadata_not_in_data_table``): -Check if a metadata value is contained in a column of a data table. +- ``dataset_metadata_in_data_table``: Check if a metadata value is contained in a column of a data table. +- ``dataset_metadata_not_in_data_table``: Equivalent to ``dataset_metadata_in_data_table`` with ``negate="true"``. + +Deprecated data validators: + +- ``dataset_metadata_in_file``: Use data tables with ``dataset_metadata_in_data_table``. +Check if a metadata value is contained in a specific column of a file in the ``tool_data_path`` +(which is set in Galaxy's config). ### Validators for textual inputs (``text``, ``select``, ...) @@ -3749,8 +3753,9 @@ For ``text`` inputs the following validators are useful: - ``length``: Check if the length of the value is within a range. - ``empty_field``: Check if the string is not empty -- ``value_in_data_table`` (``value_not_in_data_table``): Check if the value is +- ``value_in_data_table``: Check if the value is contained in a column of a given data table. +- ``value_not_in_data_table``: Equivalent to ``value_in_data_table`` with ``negate="true"``. ### Validators for numeric inputs (``integer``, ``float``) @@ -3808,21 +3813,29 @@ use in filenames may not contain ``..``. - +``dataset_ok_validator``, ``dataset_metadata_in_range``. +Deprecated validator: ``dataset_metadata_in_file``. +The list of supported +validators is in the ``validator_types`` dictionary in +[/lib/galaxy/tools/parameters/validation.py](https://github.com/galaxyproject/galaxy/blob/dev/lib/galaxy/tools/parameters/validation.py). +]]> -The error message displayed on the tool form if validation fails. +The error message displayed on the tool form if validation fails. A placeholder string ``%s`` will be repaced by the ``value`` + + + + + +Negates the result of the validator. @@ -3844,7 +3857,7 @@ more information. - Tool data filename to check against + Deprecated: use ``dataset_metadata_in_data_table``. Tool data filename to check against if ``type`` is ``dataset_metadata_in_file``. File should be present Galaxy's ``tool-data`` directory. @@ -3862,13 +3875,6 @@ in ``dataset_metadata_in_data_table``, ``dataset_metadata_not_in_data_table``, ` This can be an integer index to the column or a column name. - - - Used to indicate lines in the file -being used for validation start with a this attribute value. -For use with validators of type ``dataset_metadata_in_file``, ``dataset_metadata_in_data_table``, ``dataset_metadata_not_in_data_table``, ``value_in_data_table``, ``value_not_in_data_tabl`` - - When the ``type`` attribute value is @@ -3908,6 +3914,18 @@ fields to skip if type is ``metadata``. If not specified, all non-optional metadata fields will be checked unless ``check`` attribute is specified. + + + Deprecated. Used to indicate lines in the file +being used for validation start with a this attribute value. +For use with validator ``dataset_metadata_in_file`` + + + + + Deprecated. This is now always done. + + diff --git a/lib/galaxy/tools/parameters/dynamic_options.py b/lib/galaxy/tools/parameters/dynamic_options.py index a0614a47f5a..759457f4a6a 100644 --- a/lib/galaxy/tools/parameters/dynamic_options.py +++ b/lib/galaxy/tools/parameters/dynamic_options.py @@ -586,6 +586,9 @@ class DynamicOptions: @property def tool_data_table(self): if self.tool_data_table_name: + # this is needed for the validator unit tests and should not happen in real life + if self.tool_param.tool is None: + return None tool_data_table = self.tool_param.tool.app.tool_data_tables.get(self.tool_data_table_name, None) if tool_data_table: # Column definitions are optional, but if provided override those from the table diff --git a/lib/galaxy/tools/parameters/test/1.tabular b/lib/galaxy/tools/parameters/test/1.tabular new file mode 120000 index 00000000000..929b6a0e404 --- /dev/null +++ b/lib/galaxy/tools/parameters/test/1.tabular @@ -0,0 +1 @@ +../../../../../test-data/1.tabular \ No newline at end of file diff --git a/lib/galaxy/tools/parameters/test/empty.txt b/lib/galaxy/tools/parameters/test/empty.txt new file mode 120000 index 00000000000..c37ecd1e239 --- /dev/null +++ b/lib/galaxy/tools/parameters/test/empty.txt @@ -0,0 +1 @@ +../../../../../test-data/empty.txt \ No newline at end of file diff --git a/lib/galaxy/tools/parameters/validation.py b/lib/galaxy/tools/parameters/validation.py index 9b4d86e226d..3b5e322e0d9 100644 --- a/lib/galaxy/tools/parameters/validation.py +++ b/lib/galaxy/tools/parameters/validation.py @@ -1,7 +1,9 @@ """ Classes related to parameter validation. """ +import abc import logging +import os.path import re @@ -13,7 +15,14 @@ from galaxy import ( log = logging.getLogger(__name__) -class Validator: +def get_test_fname(fname): + """Returns test data filename""" + path, name = os.path.split(__file__) + full_path = os.path.join(path, 'test', fname) + return full_path + + +class Validator(abc.ABC): """ A validator checks that a value meets some conditions OR raises ValueError """ @@ -35,17 +44,43 @@ class Validator: param elem the validator element return an object of a Validator subclass that corresponds to the type attribute of the validator element """ - type = elem.get('type', None) - assert type is not None, "Required 'type' attribute missing from validator" - return validator_types[type].from_element(param, elem) + _type = elem.get('type', None) + assert _type is not None, "Required 'type' attribute missing from validator" + return validator_types[_type].from_element(param, elem) - def validate(self, value, trans=None): + def __init__(self, message, negate=False): + self.message = message + self.negate = util.asbool(negate) + super().__init__() + + @abc.abstractmethod + def validate(self, value, trans=None, message=None, value_to_show=None): """ validate a value + needs to be implemented in classes derived from validator. + the implementation needs to call `super().validate()` + giving result as a bool (which should be true if the + validation is positive and false otherwise) and the value + that is validated. + + the Validator.validate function will then negate the value + depending on `self.negate` and return None if + - value is True and negate is False + - value is False and negate is True + and raise a ValueError otherwise. + return None if positive validation, otherwise a ValueError is raised """ - raise TypeError("Abstract Method") + assert isinstance(value, bool), 'value must be boolean' + if message is None: + message = self.message + if value_to_show and "%s" in message: + message = message % value_to_show + if (not self.negate and value) or (self.negate and not value): + return + else: + raise ValueError(message) class RegexValidator(Validator): @@ -56,7 +91,7 @@ class RegexValidator(Validator): >>> from galaxy.tools.parameters.basic import ToolParameter >>> p = ToolParameter.build(None, XML(''' ... - ... [Ff]oo + ... [Ff]oo ... ... ''')) >>> t = p.validate("Foo") @@ -64,20 +99,42 @@ class RegexValidator(Validator): >>> t = p.validate("Fop") Traceback (most recent call last): ... - ValueError: Not gonna happen + ValueError: Value 'Fop' does not match regular expression '[Ff]oo' >>> t = p.validate(["Foo", "foo"]) >>> t = p.validate(["Foo", "Fop"]) Traceback (most recent call last): ... - ValueError: Not gonna happen + ValueError: Value 'Fop' does not match regular expression '[Ff]oo' + >>> + >>> p = ToolParameter.build(None, XML(''' + ... + ... [Ff]oo + ... + ... ''')) + >>> t = p.validate("Foo") + Traceback (most recent call last): + ... + ValueError: Value 'Foo' does match regular expression '[Ff]oo' + >>> t = p.validate("foo") + Traceback (most recent call last): + ... + ValueError: Value 'foo' does match regular expression '[Ff]oo' + >>> t = p.validate("Fop") + >>> t = p.validate(["Fop", "foo"]) + Traceback (most recent call last): + ... + ValueError: Value 'foo' does match regular expression '[Ff]oo' + >>> t = p.validate(["Fop", "Fop"]) """ @classmethod def from_element(cls, param, elem): - return cls(elem.get('message'), elem.text) + return cls(elem.get('message', None), elem.text, elem.get('negate', 'false')) - def __init__(self, message, expression): - self.message = message + def __init__(self, message, expression, negate): + if message is None: + message = f"Value '%s' does {'not ' if negate == 'false' else ''}match regular expression '{expression}'" + super().__init__(message, negate) # Compile later. RE objects used to not be thread safe. Not sure about # the sre module. self.expression = expression @@ -86,8 +143,8 @@ class RegexValidator(Validator): if not isinstance(value, list): value = [value] for val in value: - if re.match(self.expression, val or '') is None: - raise ValueError(self.message) + match = re.match(self.expression, val or '') + super().validate(match is not None, value_to_show=val) class ExpressionValidator(Validator): @@ -107,32 +164,46 @@ class ExpressionValidator(Validator): Traceback (most recent call last): ... ValueError: Not gonna happen + >>> + >>> p = ToolParameter.build(None, XML(''' + ... + ... value.lower() == "foo" + ... + ... ''')) + >>> t = p.validate("Foo") + Traceback (most recent call last): + ... + ValueError: Not gonna happen + >>> t = p.validate("foo") + Traceback (most recent call last): + ... + ValueError: Not gonna happen + >>> t = p.validate("Fop") """ @classmethod def from_element(cls, param, elem): - return cls(elem.get('message'), elem.text, elem.get('substitute_value_in_message')) + return cls(elem.get('message', None), elem.text, elem.get('negate', 'false')) - def __init__(self, message, expression, substitute_value_in_message): - self.message = message - self.substitute_value_in_message = substitute_value_in_message + def __init__(self, message, expression, negate): + if message is None: + message = f"Value '%s' does not evaluate to {'True' if negate == 'false' else 'False'} for '{expression}'" + super().__init__(message, negate) # Save compiled expression, code objects are thread safe (right?) + log.error(f"ExpressionValidator expression {expression}") self.expression = compile(expression, '', 'eval') def validate(self, value, trans=None): - message = self.message - if self.substitute_value_in_message: - message = message % value + log.error(f"ExpressionValidator.validate value {value} expression {self.expression}") try: evalresult = eval(self.expression, dict(value=value)) except Exception: - log.debug(f"Validator {self.expression} could not be evaluated on {str(value)}", exc_info=True) - raise ValueError(message) - if not(evalresult): - raise ValueError(message) + log.debug(f"Validator '{self.expression}' could not be evaluated on '{str(value)}'", exc_info=True) + super().validate(False, value, f"Validator '{self.expression}' could not be evaluated on '%s'") + super().validate(evalresult, value_to_show=value) -class InRangeValidator(Validator): +class InRangeValidator(ExpressionValidator): """ Validator that ensures a number is in a specified range @@ -140,28 +211,45 @@ class InRangeValidator(Validator): >>> from galaxy.tools.parameters.basic import ToolParameter >>> p = ToolParameter.build(None, XML(''' ... - ... + ... ... ... ''')) >>> t = p.validate(10) Traceback (most recent call last): ... - ValueError: Not gonna happen + ValueError: Doh!! 10 not in range >>> t = p.validate(15) >>> t = p.validate(20) >>> t = p.validate(21) Traceback (most recent call last): ... - ValueError: Not gonna happen + ValueError: Doh!! 21 not in range + >>> + >>> p = ToolParameter.build(None, XML(''' + ... + ... + ... + ... ''')) + >>> t = p.validate(10) + >>> t = p.validate(15) + Traceback (most recent call last): + ... + ValueError: Value ('15') must not fulfill float('10') < value <= float('20') + >>> t = p.validate(20) + Traceback (most recent call last): + ... + ValueError: Value ('20') must not fulfill float('10') < value <= float('20') + >>> t = p.validate(21) """ @classmethod def from_element(cls, param, elem): return cls(elem.get('message', None), elem.get('min'), elem.get('max'), elem.get('exclude_min', 'false'), - elem.get('exclude_max', 'false')) + elem.get('exclude_max', 'false'), + elem.get('negate', 'false')) - def __init__(self, message, range_min, range_max, exclude_min=False, exclude_max=False): + def __init__(self, message, range_min, range_max, exclude_min=False, exclude_max=False, negate=False): """ When the optional exclude_min and exclude_max attributes are set to true, the range excludes the end points (i.e., min < value < max), @@ -169,38 +257,25 @@ class InRangeValidator(Validator): (1.e., min <= value <= max). Combinations of exclude_min and exclude_max values are allowed. """ - self.min = float(range_min if range_min is not None else '-inf') + self.min = range_min if range_min is not None else '-inf' self.exclude_min = util.asbool(exclude_min) - self.max = float(range_max if range_max is not None else 'inf') + self.max = range_max if range_max is not None else 'inf' self.exclude_max = util.asbool(exclude_max) - assert self.min <= self.max, 'min must be less than or equal to max' + assert float(self.min) <= float(self.max), 'min must be less than or equal to max' # Remove unneeded 0s and decimal from floats to make message pretty. - self_min_str = str(self.min).rstrip('0').rstrip('.') - self_max_str = str(self.max).rstrip('0').rstrip('.') - op1 = '>=' + op1 = '<=' op2 = '<=' if self.exclude_min: - op1 = '>' + op1 = '<' if self.exclude_max: op2 = '<' - self.message = message or f"Value must be {op1} {self_min_str} and {op2} {self_max_str}" - - def validate(self, value, trans=None): - if self.exclude_min: - if not self.min < float(value): - raise ValueError(self.message) - else: - if not self.min <= float(value): - raise ValueError(self.message) - if self.exclude_max: - if not float(value) < self.max: - raise ValueError(self.message) - else: - if not float(value) <= self.max: - raise ValueError(self.message) + expression = f"float('{self.min}') {op1} value {op2} float('{self.max}')" + if message is None: + message = f"Value ('%s') must {'not ' if negate == 'true' else ''}fulfill {expression}" + super().__init__(message, expression, negate) -class LengthValidator(Validator): +class LengthValidator(InRangeValidator): """ Validator that ensures the length of the provided string (value) is in a specific range @@ -216,176 +291,442 @@ class LengthValidator(Validator): >>> t = p.validate("f") Traceback (most recent call last): ... - ValueError: Must have length of at least 2 + ValueError: Must have length of at least 2 and at most 8 >>> t = p.validate("foobarbaz") Traceback (most recent call last): ... - ValueError: Must have length no more than 8 + ValueError: Must have length of at least 2 and at most 8 + >>> + >>> p = ToolParameter.build(None, XML(''' + ... + ... + ... + ... ''')) + >>> t = p.validate("foo") + Traceback (most recent call last): + ... + ValueError: Must not have length of at least 2 and at most 8 + >>> t = p.validate("bar") + Traceback (most recent call last): + ... + ValueError: Must not have length of at least 2 and at most 8 + >>> t = p.validate("f") + >>> t = p.validate("foobarbaz") """ @classmethod def from_element(cls, param, elem): - return cls(elem.get('message', None), elem.get('min', None), elem.get('max', None)) + return cls(elem.get('message', None), elem.get('min', None), elem.get('max', None), elem.get('negate', 'false')) - def __init__(self, message, length_min, length_max): - self.message = message - if length_min is not None: - length_min = int(length_min) - if length_max is not None: - length_max = int(length_max) - self.min = length_min - self.max = length_max + def __init__(self, message, length_min, length_max, negate): + if message is None: + message = f"Must {'not ' if negate == 'true' else ''}have length of at least {length_min} and at most {length_max}" + super().__init__(message, range_min=length_min, range_max=length_max, negate=negate) def validate(self, value, trans=None): - if self.min is not None and len(value) < self.min: - raise ValueError(self.message or ("Must have length of at least %d" % self.min)) - if self.max is not None and len(value) > self.max: - raise ValueError(self.message or ("Must have length no more than %d" % self.max)) + super().validate(len(value), trans) class DatasetOkValidator(Validator): """ Validator that checks if a dataset is in an 'ok' state - """ - def __init__(self, message=None): - self.message = message + >>> from galaxy.datatypes.registry import example_datatype_registry_for_sample + >>> from galaxy.model import History, HistoryDatasetAssociation, set_datatypes_registry + >>> from galaxy.model.mapping import init + >>> from galaxy.util import XML + >>> from galaxy.tools.parameters.basic import ToolParameter + >>> + >>> sa_session = init("/tmp", "sqlite:///:memory:", create_tables=True).session + >>> hist = History() + >>> sa_session.add(hist) + >>> sa_session.flush() + >>> set_datatypes_registry(example_datatype_registry_for_sample()) + >>> ok_hda = hist.add_dataset(HistoryDatasetAssociation(id=1, extension='interval', create_dataset=True, sa_session=sa_session)) + >>> ok_hda.set_dataset_state(model.Dataset.states.OK) + >>> notok_hda = hist.add_dataset(HistoryDatasetAssociation(id=2, extension='interval', create_dataset=True, sa_session=sa_session)) + >>> notok_hda.set_dataset_state(model.Dataset.states.EMPTY) + >>> + >>> p = ToolParameter.build(None, XML(''' + ... + ... + ... + ... ''')) + >>> t = p.validate(ok_hda) + >>> t = p.validate(notok_hda) + Traceback (most recent call last): + ... + ValueError: The selected dataset is still being generated, select another dataset or wait until it is completed + >>> + >>> p = ToolParameter.build(None, XML(''' + ... + ... + ... + ... ''')) + >>> t = p.validate(ok_hda) + Traceback (most recent call last): + ... + ValueError: The selected dataset must not be in state OK + >>> t = p.validate(notok_hda) + """ @classmethod def from_element(cls, param, elem): - return cls(elem.get('message', None)) + negate = elem.get('negate', 'false') + message = elem.get('message', None) + if message is None: + if negate == 'false': + message = "The selected dataset is still being generated, select another dataset or wait until it is completed" + else: + message = "The selected dataset must not be in state OK" + return cls(message, negate) def validate(self, value, trans=None): - if value and value.state != model.Dataset.states.OK: - if self.message is None: - self.message = "The selected dataset is still being generated, select another dataset or wait until it is completed" - raise ValueError(self.message) + if value: + super().validate(value.state == model.Dataset.states.OK) class DatasetEmptyValidator(Validator): - """Validator that checks if a dataset has a positive file size.""" + """ + Validator that checks if a dataset has a positive file size. - def __init__(self, message=None): - self.message = message + >>> from galaxy.datatypes.registry import example_datatype_registry_for_sample + >>> from galaxy.model import Dataset, History, HistoryDatasetAssociation, set_datatypes_registry + >>> from galaxy.model.mapping import init + >>> from galaxy.util import XML + >>> from galaxy.tools.parameters.basic import ToolParameter + >>> + >>> sa_session = init("/tmp", "sqlite:///:memory:", create_tables=True).session + >>> hist = History() + >>> sa_session.add(hist) + >>> sa_session.flush() + >>> set_datatypes_registry(example_datatype_registry_for_sample()) + >>> empty_dataset = Dataset(external_filename=get_test_fname("empty.txt")) + >>> empty_hda = hist.add_dataset(HistoryDatasetAssociation(id=1, extension='interval', dataset=empty_dataset, sa_session=sa_session)) + >>> full_dataset = Dataset(external_filename=get_test_fname("1.tabular")) + >>> full_hda = hist.add_dataset(HistoryDatasetAssociation(id=2, extension='interval', dataset=full_dataset, sa_session=sa_session)) + >>> + >>> p = ToolParameter.build(None, XML(''' + ... + ... + ... + ... ''')) + >>> t = p.validate(full_hda) + >>> t = p.validate(empty_hda) + Traceback (most recent call last): + ... + ValueError: The selected dataset is empty, this tool expects non-empty files. + >>> + >>> p = ToolParameter.build(None, XML(''' + ... + ... + ... + ... ''')) + >>> t = p.validate(full_hda) + Traceback (most recent call last): + ... + ValueError: The selected dataset is non-empty, this tool expects empty files. + >>> t = p.validate(empty_hda) + """ @classmethod def from_element(cls, param, elem): - return cls(elem.get('message', None)) + message = elem.get('message', None) + negate = elem.get('negate', 'false') + if not message: + message = f"The selected dataset is {'non-' if negate == 'true' else ''}empty, this tool expects {'non-' if negate=='false' else ''}empty files." + return cls(message, negate) def validate(self, value, trans=None): if value: - if value.get_size() == 0: - if self.message is None: - self.message = "The selected dataset is empty, this tool expects non-empty files." - raise ValueError(self.message) + super().validate(value.get_size() != 0) class DatasetExtraFilesPathEmptyValidator(Validator): - """Validator that checks if a dataset's extra_files_path exists and is not empty.""" + """ + Validator that checks if a dataset's extra_files_path exists and is not empty. - def __init__(self, message=None): - self.message = message + >>> from galaxy.datatypes.registry import example_datatype_registry_for_sample + >>> from galaxy.model import History, HistoryDatasetAssociation, set_datatypes_registry + >>> from galaxy.model.mapping import init + >>> from galaxy.util import XML + >>> from galaxy.tools.parameters.basic import ToolParameter + >>> + >>> sa_session = init("/tmp", "sqlite:///:memory:", create_tables=True).session + >>> hist = History() + >>> sa_session.add(hist) + >>> sa_session.flush() + >>> set_datatypes_registry(example_datatype_registry_for_sample()) + >>> has_extra_hda = hist.add_dataset(HistoryDatasetAssociation(id=1, extension='interval', create_dataset=True, sa_session=sa_session)) + >>> has_extra_hda.dataset.file_size = 10 + >>> has_extra_hda.dataset.total_size = 15 + >>> has_no_extra_hda = hist.add_dataset(HistoryDatasetAssociation(id=2, extension='interval', create_dataset=True, sa_session=sa_session)) + >>> has_no_extra_hda.dataset.file_size = 10 + >>> has_no_extra_hda.dataset.total_size = 10 + >>> + >>> p = ToolParameter.build(None, XML(''' + ... + ... + ... + ... ''')) + >>> t = p.validate(has_extra_hda) + >>> t = p.validate(has_no_extra_hda) + Traceback (most recent call last): + ... + ValueError: The selected dataset's extra_files_path directory is empty or does not exist, this tool expects non-empty extra_files_path directories associated with the selected input. + >>> + >>> p = ToolParameter.build(None, XML(''' + ... + ... + ... + ... ''')) + >>> t = p.validate(has_extra_hda) + Traceback (most recent call last): + ... + ValueError: The selected dataset's extra_files_path directory is non-empty or does exist, this tool expects empty extra_files_path directories associated with the selected input. + >>> t = p.validate(has_no_extra_hda) + """ @classmethod def from_element(cls, param, elem): - return cls(elem.get('message', None)) + message = elem.get('message', None) + negate = elem.get('negate', 'false') + if not message: + message = f"The selected dataset's extra_files_path directory is {'non-' if negate == 'true' else ''}empty or does {'not ' if negate == 'false' else ''}exist, this tool expects {'non-' if negate == 'false' else ''}empty extra_files_path directories associated with the selected input." + return cls(message, negate) def validate(self, value, trans=None): if value: - if value.get_total_size() == value.get_size(): - if self.message is None: - self.message = "The selected dataset's extra_files_path directory is empty or does not exist, this tool expects non-empty extra_files_path directories associated with the selected input." - raise ValueError(self.message) + super().validate(value.get_total_size() != value.get_size()) class MetadataValidator(Validator): """ Validator that checks for missing metadata + + >>> from galaxy.datatypes.registry import example_datatype_registry_for_sample + >>> from galaxy.model import History, HistoryDatasetAssociation, set_datatypes_registry + >>> from galaxy.model.mapping import init + >>> from galaxy.util import XML + >>> from galaxy.tools.parameters.basic import ToolParameter + >>> + >>> sa_session = init("/tmp", "sqlite:///:memory:", create_tables=True).session + >>> hist = History() + >>> sa_session.add(hist) + >>> sa_session.flush() + >>> set_datatypes_registry(example_datatype_registry_for_sample()) + >>> hda = hist.add_dataset(HistoryDatasetAssociation(id=1, extension='interval', create_dataset=True, sa_session=sa_session)) + >>> hda.set_dataset_state(model.Dataset.states.OK) + >>> # TODO I did not find a way to remove a metadata from the hda, therefore I used two parameters, ideas? + >>> p = ToolParameter.build(None, XML(''' + ... + ... + ... + ... ''')) + >>> p2 = ToolParameter.build(None, XML(''' + ... + ... + ... + ... ''')) + >>> t = p.validate(hda) + >>> t = p2.validate(hda) + Traceback (most recent call last): + ... + ValueError: Metadata missing, click the pencil icon in the history item to edit / save the metadata attributes + >>> p = ToolParameter.build(None, XML(''' + ... + ... + ... + ... ''')) + >>> p2 = ToolParameter.build(None, XML(''' + ... + ... + ... + ... ''')) + >>> t = p.validate(hda) + Traceback (most recent call last): + ... + ValueError: Metadata missing, click the pencil icon in the history item to edit / save the metadata attributes + >>> t = p2.validate(hda) """ requires_dataset_metadata = True - def __init__(self, message=None, check="", skip=""): - self.message = message + @classmethod + def from_element(cls, param, elem): + message = elem.get('message', None) + if not message: + # TODO message not useful for negate="true" .. but maybe OK since the validator itself is not useful then + message = "Metadata missing, click the pencil icon in the history item to edit / save the metadata attributes" + return cls(message=message, + check=elem.get('check', ""), + skip=elem.get('skip', ""), + negate=elem.get('negate', 'false')) + + def __init__(self, message=None, check="", skip="", negate='false'): + super().__init__(message, negate) self.check = check.split(",") self.skip = skip.split(",") - @classmethod - def from_element(cls, param, elem): - return cls(message=elem.get('message', None), check=elem.get('check', ""), skip=elem.get('skip', "")) - def validate(self, value, trans=None): if value: - if not isinstance(value, model.DatasetInstance): - raise ValueError('A non-dataset value was provided.') - if value.missing_meta(check=self.check, skip=self.skip): - if self.message is None: - self.message = "Metadata missing, click the pencil icon in the history item to edit / save the metadata attributes" - raise ValueError(self.message) + # TODO why this validator checks for isinstance(value, model.DatasetInstance) + super().validate(isinstance(value, model.DatasetInstance) and not value.missing_meta(check=self.check, skip=self.skip)) class UnspecifiedBuildValidator(Validator): """ Validator that checks for dbkey not equal to '?' + + >>> from galaxy.datatypes.registry import example_datatype_registry_for_sample + >>> from galaxy.model import History, HistoryDatasetAssociation, set_datatypes_registry + >>> from galaxy.model.mapping import init + >>> from galaxy.util import XML + >>> from galaxy.tools.parameters.basic import ToolParameter + >>> + >>> sa_session = init("/tmp", "sqlite:///:memory:", create_tables=True).session + >>> hist = History() + >>> sa_session.add(hist) + >>> sa_session.flush() + >>> set_datatypes_registry(example_datatype_registry_for_sample()) + >>> has_dbkey_hda = hist.add_dataset(HistoryDatasetAssociation(id=1, extension='interval', create_dataset=True, sa_session=sa_session)) + >>> has_dbkey_hda.set_dataset_state(model.Dataset.states.OK) + >>> has_dbkey_hda.metadata.dbkey = 'hg19' + >>> has_no_dbkey_hda = hist.add_dataset(HistoryDatasetAssociation(id=2, extension='interval', create_dataset=True, sa_session=sa_session)) + >>> has_no_dbkey_hda.set_dataset_state(model.Dataset.states.OK) + >>> + >>> p = ToolParameter.build(None, XML(''' + ... + ... + ... + ... ''')) + >>> t = p.validate(has_dbkey_hda) + >>> t = p.validate(has_no_dbkey_hda) + Traceback (most recent call last): + ... + ValueError: Unspecified genome build, click the pencil icon in the history item to set the genome build + >>> + >>> p = ToolParameter.build(None, XML(''' + ... + ... + ... + ... ''')) + >>> t = p.validate(has_dbkey_hda) + Traceback (most recent call last): + ... + ValueError: Specified genome build, click the pencil icon in the history item to remove the genome build + >>> t = p.validate(has_no_dbkey_hda) """ requires_dataset_metadata = True - def __init__(self, message=None): - if message is None: - self.message = "Unspecified genome build, click the pencil icon in the history item to set the genome build" - else: - self.message = message - @classmethod def from_element(cls, param, elem): - return cls(elem.get('message', None)) + message = elem.get('message', None) + negate = elem.get('negate', 'false') + if not message: + message = f"{'Unspecified' if negate == 'false' else 'Specified'} genome build, click the pencil icon in the history item to {'set' if negate == 'false' else 'remove'} the genome build" + return cls(message, negate) def validate(self, value, trans=None): # if value is None, we cannot validate if value: dbkey = value.metadata.dbkey + # TODO can dbkey really be a list? if isinstance(dbkey, list): dbkey = dbkey[0] - if dbkey == '?': - raise ValueError(self.message) + super().validate(dbkey != '?') class NoOptionsValidator(Validator): - """Validator that checks for empty select list""" + """ + Validator that checks for empty select list - def __init__(self, message=None): - self.message = message + >>> from galaxy.util import XML + >>> from galaxy.tools.parameters.basic import ToolParameter + >>> p = ToolParameter.build(None, XML(''' + ... + ... + ... + ... ''')) + >>> t = p.validate('foo') + >>> t = p.validate(None) + Traceback (most recent call last): + ... + ValueError: No options available for selection + >>> + >>> p = ToolParameter.build(None, XML(''' + ... + ... + ... + ... + ... ''')) + >>> t = p.validate('foo') + Traceback (most recent call last): + ... + ValueError: Options available for selection + >>> t = p.validate(None) + """ @classmethod def from_element(cls, param, elem): - return cls(elem.get('message', None)) + message = elem.get('message', None) + negate = elem.get('negate', 'false') + if not message: + message = f"{'No options' if negate == 'false' else 'Options'} available for selection" + return cls(message, negate) def validate(self, value, trans=None): - if value is None: - if self.message is None: - self.message = "No options available for selection" - raise ValueError(self.message) + super().validate(value is not None) class EmptyTextfieldValidator(Validator): - """Validator that checks for empty text field""" + """ + Validator that checks for empty text field - def __init__(self, message=None): - self.message = message + >>> from galaxy.util import XML + >>> from galaxy.tools.parameters.basic import ToolParameter + >>> p = ToolParameter.build(None, XML(''' + ... + ... + ... + ... ''')) + >>> t = p.validate("") + Traceback (most recent call last): + ... + ValueError: Field requires a value + >>> p = ToolParameter.build(None, XML(''' + ... + ... + ... + ... ''')) + >>> t = p.validate("foo") + Traceback (most recent call last): + ... + ValueError: Field must not set a value + >>> t = p.validate("") + """ @classmethod def from_element(cls, param, elem): - return cls(elem.get('message', None)) + message = elem.get('message', None) + negate = elem.get('negate', 'false') + if not message: + if negate == 'false': + message = elem.get('message', "Field requires a value") + else: + message = elem.get('message', "Field must not set a value") + return cls(message, negate) def validate(self, value, trans=None): - if value == '': - if self.message is None: - self.message = "Field requires a value" - raise ValueError(self.message) + super().validate(value != '') class MetadataInFileColumnValidator(Validator): """ Validator that checks if the value for a dataset's metadata item exists in a file. + + Deprecated: DataTables are now the preferred way. + + note: this is covered in a framework test (validation_dataset_metadata_in_file) """ requires_dataset_metadata = True @@ -403,30 +744,30 @@ class MetadataInFileColumnValidator(Validator): line_startswith = elem.get("line_startswith", None) if line_startswith: line_startswith = line_startswith.strip() - return cls(filename, metadata_name, metadata_column, message, line_startswith, split) + negate = elem.get('negate', 'false') + return cls(filename, metadata_name, metadata_column, message, line_startswith, split, negate) - def __init__(self, filename, metadata_name, metadata_column, message="Value for metadata not found.", line_startswith=None, split="\t"): + def __init__(self, filename, metadata_name, metadata_column, message="Value for metadata not found.", line_startswith=None, split="\t", negate="false"): + super().__init__(message, negate) self.metadata_name = metadata_name - self.message = message - self.valid_values = [] + self.valid_values = set() for line in open(filename): if line_startswith is None or line.startswith(line_startswith): fields = line.split(split) if metadata_column < len(fields): - self.valid_values.append(fields[metadata_column].strip()) + self.valid_values.add(fields[metadata_column].strip()) def validate(self, value, trans=None): if not value: return - if hasattr(value, "metadata"): - if value.metadata.spec[self.metadata_name].param.to_string(value.metadata.get(self.metadata_name)) in self.valid_values: - return - raise ValueError(self.message) + super().validate(value.metadata.spec[self.metadata_name].param.to_string(value.metadata.get(self.metadata_name)) in self.valid_values) class ValueInDataTableColumnValidator(Validator): """ Validator that checks if a value is in a tool data table column. + + note: this is covered in a framework test (validation_value_in_datatable) """ @classmethod @@ -440,13 +781,11 @@ class ValueInDataTableColumnValidator(Validator): except ValueError: pass message = elem.get("message", f"Value was not found in {table_name}.") - line_startswith = elem.get("line_startswith", None) - if line_startswith: - line_startswith = line_startswith.strip() - return cls(tool_data_table, column, message, line_startswith) + negate = elem.get('negate', 'false') + return cls(tool_data_table, column, message, negate) - def __init__(self, tool_data_table, column, message="Value not found.", line_startswith=None): - self.message = message + def __init__(self, tool_data_table, column, message="Value not found.", negate='false'): + super().__init__(message, negate) self.valid_values = [] self._data_table_content_version = None self._tool_data_table = tool_data_table @@ -460,7 +799,7 @@ class ValueInDataTableColumnValidator(Validator): self.valid_values = [] for fields in data_fields: if self._column < len(fields): - self.valid_values.append(fields[self._metadata_column]) + self.valid_values.append(fields[self._column]) def validate(self, value, trans=None): if not value: @@ -468,31 +807,34 @@ class ValueInDataTableColumnValidator(Validator): if not self._tool_data_table.is_current_version(self._data_table_content_version): log.debug('ValueInDataTableColumnValidator: values are out of sync with data table (%s), updating validator.', self._tool_data_table.name) self._load_values() - if value in self.valid_values: - return - raise ValueError(self.message) + super().validate(value in self.valid_values) class ValueNotInDataTableColumnValidator(ValueInDataTableColumnValidator): """ Validator that checks if a value is NOT in a tool data table column. + Equivalent to ValueInDataTableColumnValidator with `negate="true"`. + + note: this is covered in a framework test (validation_value_in_datatable) """ - def __init__(self, tool_data_table, metadata_column, message="Value already present.", line_startswith=None): - super().__init__(tool_data_table, metadata_column, message, line_startswith) + def __init__(self, tool_data_table, metadata_column, message="Value already present.", negate='false'): + super().__init__(tool_data_table, metadata_column, message, negate) def validate(self, value, trans=None): try: - super(ValueInDataTableColumnValidator, self).validate(value, trans) + super().validate(value) except ValueError: return else: raise ValueError(self.message) -class MetadataInDataTableColumnValidator(Validator): +class MetadataInDataTableColumnValidator(ValueInDataTableColumnValidator): """ Validator that checks if the value for a dataset's metadata item exists in a file. + + note: this is covered in a framework test (validation_metadata_in_datatable) """ requires_dataset_metadata = True @@ -504,59 +846,39 @@ class MetadataInDataTableColumnValidator(Validator): metadata_name = elem.get("metadata_name", None) if metadata_name: metadata_name = metadata_name.strip() + # TODO rename to column? metadata_column = elem.get("metadata_column", 0) try: metadata_column = int(metadata_column) except ValueError: pass message = elem.get("message", f"Value for metadata {metadata_name} was not found in {table_name}.") - line_startswith = elem.get("line_startswith", None) - if line_startswith: - line_startswith = line_startswith.strip() - return cls(tool_data_table, metadata_name, metadata_column, message, line_startswith) + negate = elem.get('negate', 'false') + return cls(tool_data_table, metadata_name, metadata_column, message, negate) - def __init__(self, tool_data_table, metadata_name, metadata_column, message="Value for metadata not found.", line_startswith=None): + def __init__(self, tool_data_table, metadata_name, metadata_column, message="Value for metadata not found.", negate="false"): + super().__init__(tool_data_table, metadata_column, message, negate) self.metadata_name = metadata_name - self.message = message - self.valid_values = [] - self._data_table_content_version = None - self._tool_data_table = tool_data_table - if isinstance(metadata_column, str): - metadata_column = tool_data_table.columns[metadata_column] - self._metadata_column = metadata_column - self._load_values() - - def _load_values(self): - self._data_table_content_version, data_fields = self._tool_data_table.get_version_fields() - self.valid_values = [] - for fields in data_fields: - if self._metadata_column < len(fields): - self.valid_values.append(fields[self._metadata_column]) def validate(self, value, trans=None): - if not value: - return - if hasattr(value, "metadata"): - if not self._tool_data_table.is_current_version(self._data_table_content_version): - log.debug('MetadataInDataTableColumnValidator values are out of sync with data table (%s), updating validator.', self._tool_data_table.name) - self._load_values() - if value.metadata.spec[self.metadata_name].param.to_string(value.metadata.get(self.metadata_name)) in self.valid_values: - return - raise ValueError(self.message) + super().validate(value.metadata.spec[self.metadata_name].param.to_string(value.metadata.get(self.metadata_name)), trans) class MetadataNotInDataTableColumnValidator(MetadataInDataTableColumnValidator): """ Validator that checks if the value for a dataset's metadata item doesn't exists in a file. + Equivalent to MetadataInDataTableColumnValidator with `negate="true"`. + + note: this is covered in a framework test (validation_metadata_in_datatable) """ requires_dataset_metadata = True - def __init__(self, tool_data_table, metadata_name, metadata_column, message="Value for metadata not found.", line_startswith=None): - super(MetadataInDataTableColumnValidator, self).__init__(tool_data_table, metadata_name, metadata_column, message, line_startswith) + def __init__(self, tool_data_table, metadata_name, metadata_column, message="Value for metadata not found.", negate="false"): + super().__init__(tool_data_table, metadata_name, metadata_column, message, negate) def validate(self, value, trans=None): try: - super(MetadataInDataTableColumnValidator, self).validate(value, trans) + super().validate(value, trans) except ValueError: return else: @@ -565,7 +887,9 @@ class MetadataNotInDataTableColumnValidator(MetadataInDataTableColumnValidator): class MetadataInRangeValidator(InRangeValidator): """ - Validator that ensures metadata is in a specified range + validator that ensures metadata is in a specified range + + note: this is covered in a framework test (validation_metadata_in_range) """ requires_dataset_metadata = True @@ -574,14 +898,16 @@ class MetadataInRangeValidator(InRangeValidator): metadata_name = elem.get('metadata_name', None) assert metadata_name, "dataset_metadata_in_range validator requires metadata_name attribute." metadata_name = metadata_name.strip() - return cls(metadata_name, - elem.get('message', None), elem.get('min'), - elem.get('max'), elem.get('exclude_min', 'false'), - elem.get('exclude_max', 'false')) + ret = cls(metadata_name, elem.get('message', None), + elem.get('min'), elem.get('max'), + elem.get('exclude_min', 'false'), elem.get('exclude_max', 'false'), + elem.get('negate', 'false')) + ret.message = "Metadata: " + ret.message + return ret - def __init__(self, metadata_name, message, range_min, range_max, exclude_min=False, exclude_max=False): + def __init__(self, metadata_name, message, range_min, range_max, exclude_min, exclude_max, negate): self.metadata_name = metadata_name - super().__init__(message, range_min, range_max, exclude_min, exclude_max) + super().__init__(message, range_min, range_max, exclude_min, exclude_max, negate) def validate(self, value, trans=None): if value: @@ -593,6 +919,7 @@ class MetadataInRangeValidator(InRangeValidator): raise ValueError(f'{self.metadata_name} Metadata missing') except ValueError: raise ValueError(f'{self.metadata_name} must be a float or an integer') + log.error(f"MetadataInRangeValidato.validate value_to_check {value_to_check}") super().validate(value_to_check, trans) @@ -607,7 +934,6 @@ validator_types = dict( empty_field=EmptyTextfieldValidator, empty_dataset=DatasetEmptyValidator, empty_extra_files_path=DatasetExtraFilesPathEmptyValidator, - dataset_metadata_in_file=MetadataInFileColumnValidator, dataset_metadata_in_data_table=MetadataInDataTableColumnValidator, dataset_metadata_not_in_data_table=MetadataNotInDataTableColumnValidator, dataset_metadata_in_range=MetadataInRangeValidator, @@ -616,6 +942,11 @@ validator_types = dict( dataset_ok_validator=DatasetOkValidator, ) +deprecated_validator_types = dict( + dataset_metadata_in_file=MetadataInFileColumnValidator +) +validator_types.update(deprecated_validator_types) + def get_suite(): """Get unittest suite for this module""" diff --git a/packages/test.sh b/packages/test.sh index 0c9ddfe5f2f..30bcb3d0033 100755 --- a/packages/test.sh +++ b/packages/test.sh @@ -1,6 +1,6 @@ #!/bin/bash -set -e +set -ex # Don't display the pip progress bar when running under CI [ "$CI" = 'true' ] && export PIP_PROGRESS_BAR=off diff --git a/test/functional/tools/samples_tool_conf.xml b/test/functional/tools/samples_tool_conf.xml index 1110bfe4af0..69847fa6c1a 100644 --- a/test/functional/tools/samples_tool_conf.xml +++ b/test/functional/tools/samples_tool_conf.xml @@ -136,6 +136,9 @@ + + + diff --git a/test/functional/tools/validation_dataset_metadata_in_file.xml b/test/functional/tools/validation_dataset_metadata_in_file.xml new file mode 100644 index 00000000000..1192046f640 --- /dev/null +++ b/test/functional/tools/validation_dataset_metadata_in_file.xml @@ -0,0 +1,31 @@ + + out1 + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/functional/tools/validation_metadata_in_datatable.xml b/test/functional/tools/validation_metadata_in_datatable.xml new file mode 100644 index 00000000000..7e3e6933c13 --- /dev/null +++ b/test/functional/tools/validation_metadata_in_datatable.xml @@ -0,0 +1,37 @@ + + out1 + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/functional/tools/validation_value_in_datatable.xml b/test/functional/tools/validation_value_in_datatable.xml new file mode 100644 index 00000000000..86ef401743a --- /dev/null +++ b/test/functional/tools/validation_value_in_datatable.xml @@ -0,0 +1,35 @@ + + out1 + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +