Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions hed/validator/data/class_regex.json
Original file line number Diff line number Diff line change
Expand Up @@ -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])?$",
Comment thread
VisLab marked this conversation as resolved.
"numericClass": "^[+-]?([0-9]+(\\.[0-9]*)?|\\.[0-9]+)([eE][+-]?[0-9]+)?$"
}
}
186 changes: 17 additions & 169 deletions hed/validator/util/class_util.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
23 changes: 23 additions & 0 deletions tests/test_deprecations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand Down Expand Up @@ -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,
),
]


Expand Down Expand Up @@ -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()
109 changes: 54 additions & 55 deletions tests/validator/test_tag_validator_util.py
Original file line number Diff line number Diff line change
@@ -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__":
Expand Down
Loading