diff --git a/hed/validator/data/class_regex.json b/hed/validator/data/class_regex.json index 7791a0fb..0d9fdec8 100644 --- a/hed/validator/data/class_regex.json +++ b/hed/validator/data/class_regex.json @@ -57,7 +57,7 @@ "testClass": ["newline", "tab", "nonascii"] }, "class_words": { - "dateTimeClass": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})?$", - "numericClass": "^[+-]?(\\d+(\\.\\d*)?|\\.\\d+)([eE][+-]?\\d+)?$" + "dateTimeClass": "^[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])T(?:2[0-3]|[01][0-9]):[0-5][0-9]:(?:[0-5][0-9]|60)(?:\\.[0-9]{1,6})?(?:Z|[+-](?:2[0-3]|[01][0-9]):[0-5][0-9])?$", + "numericClass": "^[+-]?([0-9]+(\\.[0-9]*)?|\\.[0-9]+)([eE][+-]?[0-9]+)?$" } } \ No newline at end of file diff --git a/hed/validator/util/class_util.py b/hed/validator/util/class_util.py index e22e39e4..a49d4d09 100644 --- a/hed/validator/util/class_util.py +++ b/hed/validator/util/class_util.py @@ -1,7 +1,6 @@ """Utilities to support HED validation.""" -import datetime -import re +import warnings from hed.errors.error_reporter import ErrorHandler from hed.errors.error_types import ValidationErrors @@ -11,42 +10,30 @@ class UnitValueValidator: """Validates units.""" - DATE_TIME_VALUE_CLASS = "dateTimeClass" - NUMERIC_VALUE_CLASS = "numericClass" - TEXT_VALUE_CLASS = "textClass" - NAME_VALUE_CLASS = "nameClass" - - DIGIT_OR_POUND_EXPRESSION = r"^(-?[\d.]+(?:e-?\d+)?|#)$" - def __init__(self, modern_allowed_char_rules=False, value_validators=None): """Validates the unit and value classes on a given tag. Parameters: - value_validators(dict or None): Override or add value class validators - + modern_allowed_char_rules (bool): If True, use the 8.3.0 and later character rules. + value_validators (dict or None): Deprecated, removed in hedtools 2.0.0. Accepted so + that existing callers keep working; it has no effect. The per-class validator + functions it used to override were never reached by validation. + + Notes: + The per-character sets and the whole-value rules of each value class come from + ``hed/validator/data/class_regex.json`` through ``CharRexValidator``; there is no + per-class validator function. """ + if value_validators is not None: + warnings.warn( + "The value_validators= parameter of UnitValueValidator is deprecated and will be removed in " + "hedtools 2.0.0; it has no effect. Value class rules come from hed/validator/data/class_regex.json.", + DeprecationWarning, + stacklevel=2, + ) self._validate_characters = modern_allowed_char_rules - self._value_validators = self._get_default_value_class_validators() self._char_validator = CharRexValidator() - if value_validators and isinstance(value_validators, dict): - self._value_validators.update(value_validators) - - def _get_default_value_class_validators(self): - """Return a dictionary of value class validator functions. - - Returns: - dict: Dictionary of value class validator functions. - - """ - validator_dict = { - self.DATE_TIME_VALUE_CLASS: is_date_time_value_class, - self.NUMERIC_VALUE_CLASS: is_numeric_value_class, - self.TEXT_VALUE_CLASS: is_text_value_class, - self.NAME_VALUE_CLASS: is_name_value_class, - } - - return validator_dict def check_tag_unit_class_units_are_valid( self, original_tag, validate_text, report_as=None, error_code=None, allow_placeholders=True @@ -224,142 +211,3 @@ def _report_bad_units(original_tag, report_as): report_as = report_as if report_as else original_tag tag_unit_class_units = original_tag.get_tag_unit_class_units() return ErrorHandler.format_error(ValidationErrors.UNITS_INVALID, tag=report_as, units=tag_unit_class_units) - - def _validate_value_class_portion(self, original_tag, portion_to_validate): - if portion_to_validate is None: - return False - - value_class_types = original_tag.value_classes - return self.validate_value_class_type(portion_to_validate, value_class_types) - - def validate_value_class_type(self, unit_or_value_portion, valid_types) -> bool: - """Report invalid unit or valid class values. - - Parameters: - unit_or_value_portion (str): The value portion to validate. - valid_types (list): The names of value class or unit class types (e.g. dateTime or dateTimeClass). - - Returns: - bool: True if this is one of the valid_types validators. - - """ - has_valid_func = False - for unit_class_type in valid_types: - valid_func = self._value_validators.get(unit_class_type) - if valid_func: - has_valid_func = True - if valid_func(unit_or_value_portion): - return True - return not has_valid_func - - -def find_invalid_positions(s, pattern): - """Return a list of (index, char) pairs for characters in s that do not match pattern. - - Parameters: - s (str): The string to scan. - pattern (str): A single-character regex pattern specifying valid characters. - - Returns: - list[tuple[int, str]]: Each tuple contains the character index and the invalid character. - - """ - # List to store positions of invalid characters - invalid_positions = [] - - # Iterate over the string, check each character - for i, char in enumerate(s): - if not re.match(pattern, char): - # If the character does not match, record its position and value - invalid_positions.append((i, char)) - - return invalid_positions - - -def is_date_time_value_class(date_time_string) -> bool: - """Check if the specified string is a valid datetime. - - Parameters: - date_time_string (str): A datetime string. - - Returns: - bool: True if the datetime string is valid. False, if otherwise. - - Notes: - - ISO 8601 datetime string. - - """ - try: - date_time_obj = datetime.datetime.fromisoformat(date_time_string) - return not date_time_obj.tzinfo - except ValueError: - return False - - -def is_name_value_class(name_str) -> bool: - """Return True if name_str is a valid HED name-value. - - Allowed characters are ASCII word characters (letters, digits, underscore), - hyphens, and Unicode code points U+0080 through U+FFFF. - - Parameters: - name_str (str): The string to validate. - - Returns: - bool: True if the string matches the allowed pattern. - - """ - pattern = r"^[\w\-\u0080-\uFFFF]+$" - if re.fullmatch(pattern, name_str): - return True - else: - return False - - -def is_numeric_value_class(numeric_string) -> bool: - """Check to see if valid numeric value. - - Parameters: - numeric_string (str): A string that should be only a number with no units. - - Returns: - bool: True if the numeric string is valid. False, if otherwise. - - """ - if re.search(UnitValueValidator.DIGIT_OR_POUND_EXPRESSION, numeric_string): - return True - - return False - - -def is_text_value_class(text_string) -> bool: - """Placeholder for eventual text value class validation. - - Parameters: - text_string (str): Text class. - - Returns: - bool: True - - """ - return True - - -def is_clock_face_time(time_string) -> bool: - """Check if a valid HH:MM time string. - - Parameters: - time_string (str): A time string. - - Returns: - bool: True if the time string is valid. False, if otherwise. - - Notes: - - This is deprecated and has no expected use going forward. - - """ - try: - time_obj = datetime.time.fromisoformat(time_string) - return not time_obj.tzinfo and not time_obj.microsecond - except ValueError: - return False diff --git a/spec_tests/hed-tests b/spec_tests/hed-tests index 27f01706..5ed6094d 160000 --- a/spec_tests/hed-tests +++ b/spec_tests/hed-tests @@ -1 +1 @@ -Subproject commit 27f01706ae90085b7e42a9a5fe0b04034f1a6616 +Subproject commit 5ed6094dbe72836e26661f9524f8e5eabb176b1f diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py index 31f255be..59e6195e 100644 --- a/tests/test_deprecations.py +++ b/tests/test_deprecations.py @@ -21,6 +21,7 @@ from hed.schema.hed_schema_io import from_string from hed.schema.schema_io.base2schema import SchemaLoader from hed.validator import SpreadsheetValidator +from hed.validator.util import UnitValueValidator FIXTURE_DIR = os.path.realpath(os.path.join(os.path.dirname(__file__), "data/schema_tests/merge_group_tests")) @@ -48,6 +49,11 @@ def _has_parameter(func, name): lambda: _has_parameter(SpreadsheetValidator.validate, "def_dicts"), 2, ), + ( + "value_validators= parameter of UnitValueValidator.__init__ (no-op since the date-time cleanup)", + lambda: _has_parameter(UnitValueValidator.__init__, "value_validators"), + 2, + ), ] @@ -127,5 +133,22 @@ def test_no_warning_with_extra_def_dicts(self): self.assertEqual(issues, []) +class TestValueValidatorsParameterDeprecation(unittest.TestCase): + """UnitValueValidator(value_validators=...) injected per-class functions that validation never called.""" + + def test_value_validators_keyword_warns_and_is_ignored(self): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + validator = UnitValueValidator(value_validators={"dateTimeClass": lambda value: False}) + self.assertTrue(any(issubclass(w.category, DeprecationWarning) for w in caught)) + self.assertTrue(validator._char_validator.is_valid_value("2026-09-30T09:55:00", "dateTimeClass")) + + def test_no_warning_without_value_validators(self): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + UnitValueValidator(modern_allowed_char_rules=True) + self.assertFalse(any(issubclass(w.category, DeprecationWarning) for w in caught)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/validator/test_tag_validator_util.py b/tests/validator/test_tag_validator_util.py index a493469f..428f771c 100644 --- a/tests/validator/test_tag_validator_util.py +++ b/tests/validator/test_tag_validator_util.py @@ -1,63 +1,62 @@ +"""Whole-value rules of the value classes, as the character table defines them. + +The dateTimeClass rule is the BIDS Datetime format (RFC 3339 with an optional offset), shared with +hed-specification's character_sets.json; the numericClass rule uses ASCII digits only. +""" + import unittest -from hed.validator.util import class_util -from tests.validator.test_tag_validator import TestHed +from hed.validator.util.char_util import CharRexValidator + + +class TestValueClassWholeValueRules(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.validator = CharRexValidator(modern_allowed_char_rules=True) + def test_date_times_in_the_bids_format_are_valid(self): + for value in [ + "2026-09-30T09:55:00", + "2026-09-30T09:55:00.5", + "2026-09-30T09:55:00.123456", + "2026-09-30T09:55:00Z", + "2026-09-30T09:55:00+02:00", + "2026-09-30T09:55:00-05:30", + "2026-09-30T23:59:60", + "2000-01-01T00:00:00", + ]: + with self.subTest(value=value): + self.assertTrue(self.validator.is_valid_value(value, "dateTimeClass"), value) -class StringUtilityFunctions(TestHed): - def test_clock_face_times(self): - valid_test_strings = { - "validPM": "23:52", - "validMidnight": "00:55", - "validHour": "11:00", - "validSingleDigitHour": "08:30", - "validSeconds": "19:33:47", - } - invalid_test_strings = { - "invalidDate": "8/8/2019", - "invalidHour": "25:11", - "invalidSingleDigitHour": "8:30", - "invalidMinute": "12:65", - "invalidSecond": "15:45:82", - "invalidTimeZone": "16:25:51+00:00", - "invalidMilliseconds": "17:31:05.123", - "invalidMicroseconds": "09:21:16.123456", - "invalidDateTime": "2000-01-01T00:55:00", - "invalidString": "not a time", - } - for string in valid_test_strings.values(): - result = class_util.is_clock_face_time(string) - self.assertEqual(result, True, string) - for string in invalid_test_strings.values(): - result = class_util.is_clock_face_time(string) - self.assertEqual(result, False, string) + def test_date_times_outside_the_bids_format_are_invalid(self): + for value in [ + "2026-09-30", + "2026-09-30 09:55:00", + "2026-09-30t09:55:00z", + "2026-09-30T09:55", + "2026-09-30T09:55:00+0200", + "2026-09-30T09:55:00+02", + "2026-09-30T24:00:00", + "2026-09-30T09:55:00.1234567", + "2026-13-01T09:55:00", + "2026-09-32T09:55:00", + "2026-09-30T09:60:00", + "2026-09-30T09:55:00+99:99", + "20260930T095500", + "2026-W40-3T09:55:00", + "8/8/2019", + "not a time", + ]: + with self.subTest(value=value): + self.assertFalse(self.validator.is_valid_value(value, "dateTimeClass"), value) - def test_date_times(self): - valid_test_strings = { - "validPM": "2000-01-01T23:52:00", - "validMidnight": "2000-01-01T00:55:00", - "validHour": "2000-01-01T11:00:00", - "validSingleDigitHour": "2000-01-01T08:30:00", - "validSeconds": "2000-01-01T19:33:47", - "validMilliseconds": "2000-01-01T17:31:05.123", - "validMicroseconds": "2000-01-01T09:21:16.123456", - } - invalid_test_strings = { - "invalidDate": "8/8/2019", - "invalidTime": "00:55:00", - "invalidHour": "2000-01-01T25:11", - "invalidSingleDigitHour": "2000-01-01T8:30", - "invalidMinute": "2000-01-01T12:65", - "invalidSecond": "2000-01-01T15:45:82", - "invalidTimeZone": "2000-01-01T16:25:51+00:00", - "invalidString": "not a time", - } - for string in valid_test_strings.values(): - result = class_util.is_date_time_value_class(string) - self.assertEqual(result, True, string) - for string in invalid_test_strings.values(): - result = class_util.is_date_time_value_class(string) - self.assertEqual(result, False, string) + def test_numeric_values_use_ascii_digits(self): + for value in ["3", "-3.5", ".5", "3.", "1e10", "-2.5E-3", "+7"]: + with self.subTest(value=value): + self.assertTrue(self.validator.is_valid_value(value, "numericClass"), value) + for value in ["", "3 mA", "1e", "abc", "3^2", "+", "\u0663", "1\u0663"]: + with self.subTest(value=value): + self.assertFalse(self.validator.is_valid_value(value, "numericClass"), value) if __name__ == "__main__":