diff --git a/backend/grants_shared/pyproject.toml b/backend/grants_shared/pyproject.toml index 0b2ba80..6cda64b 100644 --- a/backend/grants_shared/pyproject.toml +++ b/backend/grants_shared/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "grants-shared" -version = "0.3.0" +version = "0.3.1" description = "Shared code used by the Simpler Grants.gov & Grants Management repos" readme = "README.md" license = "CC0-1.0" diff --git a/backend/grants_shared/src/grants_shared/api/schemas/extension/field_validators.py b/backend/grants_shared/src/grants_shared/api/schemas/extension/field_validators.py index e1701d7..43ae234 100644 --- a/backend/grants_shared/src/grants_shared/api/schemas/extension/field_validators.py +++ b/backend/grants_shared/src/grants_shared/api/schemas/extension/field_validators.py @@ -1,4 +1,5 @@ import copy +import re import typing from apiflask import validators # noqa: TID251 @@ -112,6 +113,89 @@ def __call__(self, value: _SizedT) -> _SizedT: return value +class WordLimit(validators.Validator): + """Validator which succeeds if the value passed to it has word count between + a minimum and maximum. + + :param min: The minimum word count. If not provided, minimum word count + will not be checked. + :param max: The maximum word count. If not provided, maximum word count + will not be checked. + :param equal: The exact word count. If provided, maximum and minimum + word count will not be checked. + """ + + error_mapping: dict[str, MarshmallowErrorContainer] = { + "message_min": MarshmallowErrorContainer( + SchemaValidationError.MIN_WORDS, "Shorter than minimum word count {min}." + ), + "message_max": MarshmallowErrorContainer( + SchemaValidationError.MAX_WORDS, "Longer than maximum word count {max}." + ), + "message_all": MarshmallowErrorContainer( + SchemaValidationError.MIN_OR_MAX_WORDS, "Word count must be between {min} and {max}." + ), + "message_equal": MarshmallowErrorContainer( + SchemaValidationError.EQUALS_WORDS, "Word count must be {equal}." + ), + } + + def __init__( + self, + min: int | None = None, + max: int | None = None, + equal: int | None = None, + ): + """ + :param min: The minimum word count. If not provided, minimum word count + will not be checked. + :param max: The maximum word count. If not provided, maximum word count + will not be checked. + :param equal: The exact word count. If provided, maximum and minimum + word count will not be checked. + """ + self.min = min + self.max = max + self.equal = equal + + def _make_error(self, key: str) -> ValidationError: + try: + # Make a copy of the error mapping so we aren't modifying + # the class-level configurations above when we do formatting + error_container = copy.copy(self.error_mapping[key]) + except KeyError as error: + class_name = self.__class__.__name__ + message = ( + f"ValidationError raised by `{class_name}`, but error key `{key}` does " + "not exist in the `error_messages` dictionary." + ) + raise AssertionError(message) from error + + error_container.message = error_container.message.format( + min=self.min, max=self.max, equal=self.equal + ) + + return ValidationError([error_container]) + + def __call__(self, value: str) -> str: + length = len(re.findall(r"\s+", value.strip())) + 1 + + if self.equal is not None: + if length != self.equal: + raise self._make_error("message_equal") + return value + + if self.min is not None and length < self.min: + key = "message_min" if self.max is None else "message_all" + raise self._make_error(key) + + if self.max is not None and length > self.max: + key = "message_max" if self.min is None else "message_all" + raise self._make_error(key) + + return value + + class Email(validators.Email): EMAIL_ERROR = MarshmallowErrorContainer( SchemaValidationError.FORMAT, "Not a valid email address." diff --git a/backend/grants_shared/src/grants_shared/api/schemas/extension/schema_validation_error.py b/backend/grants_shared/src/grants_shared/api/schemas/extension/schema_validation_error.py index 9cb776b..fae0993 100644 --- a/backend/grants_shared/src/grants_shared/api/schemas/extension/schema_validation_error.py +++ b/backend/grants_shared/src/grants_shared/api/schemas/extension/schema_validation_error.py @@ -18,6 +18,11 @@ class SchemaValidationError(StrEnum): MIN_OR_MAX_LENGTH = "min_or_max_length" EQUALS = "equals" + MIN_WORDS = "min_words" + MAX_WORDS = "max_words" + MIN_OR_MAX_WORDS = "min_or_max_words" + EQUALS_WORDS = "equals_words" + MIN_VALUE = "min_value" MAX_VALUE = "max_value" MIN_OR_MAX_VALUE = "min_or_max_value" diff --git a/backend/grants_shared/tests/grants_shared/api/schemas/schema_validation_utils.py b/backend/grants_shared/tests/grants_shared/api/schemas/schema_validation_utils.py index 4476563..857510a 100644 --- a/backend/grants_shared/tests/grants_shared/api/schemas/schema_validation_utils.py +++ b/backend/grants_shared/tests/grants_shared/api/schemas/schema_validation_utils.py @@ -77,6 +77,30 @@ def get_one_of_error_msg(choices: list[str]): ) +def get_min_word_error_msg(length: int): + return MarshmallowErrorContainer( + SchemaValidationError.MIN_WORDS, f"Shorter than minimum word count {length}." + ) + + +def get_max_word_error_msg(length: int): + return MarshmallowErrorContainer( + SchemaValidationError.MAX_WORDS, f"Longer than maximum word count {length}." + ) + + +def get_word_range_error_msg(min: int, max: int): + return MarshmallowErrorContainer( + SchemaValidationError.MIN_OR_MAX_WORDS, f"Word count must be between {min} and {max}." + ) + + +def get_word_equal_error_msg(equal: int): + return MarshmallowErrorContainer( + SchemaValidationError.EQUALS_WORDS, f"Word count must be {equal}." + ) + + def get_min_length_error_msg(length: int): return MarshmallowErrorContainer( SchemaValidationError.MIN_LENGTH, f"Shorter than minimum length {length}." @@ -199,6 +223,10 @@ class FieldTestSchema(Schema): field_str_max = fields.String(validate=[validators.Length(max=3)]) field_str_min_and_max = fields.String(validate=[validators.Length(min=2, max=3)]) field_str_equal = fields.String(validate=[validators.Length(equal=3)]) + field_word_min = fields.String(validate=[validators.WordLimit(min=2)]) + field_word_max = fields.String(validate=[validators.WordLimit(max=3)]) + field_word_min_and_max = fields.String(validate=[validators.WordLimit(min=2, max=3)]) + field_word_equal = fields.String(validate=[validators.WordLimit(equal=2)]) field_str_regex = fields.String(validate=[validators.Regexp("^\\d{3}$")]) field_str_regex_msg = fields.String( validate=[validators.Regexp("^\\d{3}$", error_message="This is the override error")] @@ -285,6 +313,10 @@ def get_valid_field_test_schema_req(): "field_str_max": "a", "field_str_min_and_max": "ab", "field_str_equal": "abc", + "field_word_min": "abc abc", + "field_word_max": "abc abc abc", + "field_word_min_and_max": "abc abc", + "field_word_equal": "abc abc", "field_str_regex": "123", "field_str_regex_msg": "123", "field_str_email": "person@example.com", @@ -354,6 +386,10 @@ def get_invalid_field_test_schema_req(): "field_str_max": "abcdef", "field_str_min_and_max": "a", "field_str_equal": "a", + "field_word_min": "abc", + "field_word_max": "abc abc abc abc", + "field_word_min_and_max": "abc abc abc abc", + "field_word_equal": "abc abc abc", "field_str_regex": "abc", "field_str_regex_msg": "abc", "field_str_email": "not an email", @@ -421,6 +457,10 @@ def get_expected_validation_errors(): "field_str_max": [get_max_length_error_msg(3)], "field_str_min_and_max": [get_length_range_error_msg(2, 3)], "field_str_equal": [get_length_equal_error_msg(3)], + "field_word_min": [get_min_word_error_msg(2)], + "field_word_max": [get_max_word_error_msg(3)], + "field_word_min_and_max": [get_word_range_error_msg(2, 3)], + "field_word_equal": [get_word_equal_error_msg(2)], "field_str_regex": [INVALID_STRING_PATTERN], "field_str_regex_msg": [ MarshmallowErrorContainer(SchemaValidationError.FORMAT, "This is the override error") diff --git a/backend/grants_shared/uv.lock b/backend/grants_shared/uv.lock index e2d1b86..866ea89 100644 --- a/backend/grants_shared/uv.lock +++ b/backend/grants_shared/uv.lock @@ -467,7 +467,7 @@ wheels = [ [[package]] name = "grants-shared" -version = "0.3.0" +version = "0.3.1" source = { virtual = "." } dependencies = [ { name = "apiflask" },