From a7a14fa365081c7235547c3d27a9130412bc1dc4 Mon Sep 17 00:00:00 2001 From: Glen Blosser Date: Mon, 3 Aug 2026 12:53:21 -0500 Subject: [PATCH 1/6] Adding Word Limit validation --- .../api/schemas/extension/field_validators.py | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) 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..1080e70 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,73 @@ def __call__(self, value: _SizedT) -> _SizedT: return value +class WordLimit(validators.WordLimit): + """Validator which succeeds if the value passed to it has equal + or fewer words than the provided limit. + + :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. + :param error: Error message to raise in case of a validation error. + Can be interpolated with `{input}`, `{min}` and `{max}`. + """ + + error_mapping: dict[str, MarshmallowErrorContainer] = { + "message_min": MarshmallowErrorContainer( + SchemaValidationError.MIN_LENGTH, "Shorter than minimum word count {min}." + ), + "message_max": MarshmallowErrorContainer( + SchemaValidationError.MAX_LENGTH, "Longer than maximum word count {max}." + ), + "message_all": MarshmallowErrorContainer( + SchemaValidationError.MIN_OR_MAX_LENGTH, "Word count must be between {min} and {max}." + ), + "message_equal": MarshmallowErrorContainer( + SchemaValidationError.EQUALS, "Word count must be {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: _SizedT) -> _SizedT: + length = len(re.findall(r"\s+"), value.trim()) + 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." From 4db005be08d7ccf12c6825cfbf8b36093da0064c Mon Sep 17 00:00:00 2001 From: Glen Blosser Date: Mon, 3 Aug 2026 15:21:23 -0500 Subject: [PATCH 2/6] Fixing wordLimit description --- .../grants_shared/api/schemas/extension/field_validators.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 1080e70..23f9ccb 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 @@ -114,8 +114,8 @@ def __call__(self, value: _SizedT) -> _SizedT: class WordLimit(validators.WordLimit): - """Validator which succeeds if the value passed to it has equal - or fewer words than the provided limit. + """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. From 5f4ab6a0b198adcf30264d941ee5182fd7a9ed94 Mon Sep 17 00:00:00 2001 From: Glen Blosser Date: Wed, 5 Aug 2026 10:41:59 -0500 Subject: [PATCH 3/6] WIP fixing class def --- .../api/schemas/extension/field_validators.py | 26 ++++++++++--- .../api/schemas/schema_validation_utils.py | 38 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) 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 23f9ccb..f7fc961 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 @@ -113,7 +113,7 @@ def __call__(self, value: _SizedT) -> _SizedT: return value -class WordLimit(validators.WordLimit): +class WordLimit(validators.Validator): """Validator which succeeds if the value passed to it has word count between a minimum and maximum. @@ -123,8 +123,6 @@ class WordLimit(validators.WordLimit): will not be checked. :param equal: The exact word count. If provided, maximum and minimum word count will not be checked. - :param error: Error message to raise in case of a validation error. - Can be interpolated with `{input}`, `{min}` and `{max}`. """ error_mapping: dict[str, MarshmallowErrorContainer] = { @@ -142,6 +140,24 @@ class WordLimit(validators.WordLimit): ), } + def __init__( + self, + min: int = None, + max: int = None, + equal: int = 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 @@ -161,8 +177,8 @@ def _make_error(self, key: str) -> ValidationError: return ValidationError([error_container]) - def __call__(self, value: _SizedT) -> _SizedT: - length = len(re.findall(r"\s+"), value.trim()) + 1 + 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: 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..29ef266 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,28 @@ def get_one_of_error_msg(choices: list[str]): ) +def get_min_word_error_msg(length: int): + return MarshmallowErrorContainer( + SchemaValidationError.MIN_LENGTH, f"Shorter than minimum word count {length}." + ) + + +def get_max_word_error_msg(length: int): + return MarshmallowErrorContainer( + SchemaValidationError.MAX_LENGTH, f"Longer than maximum word count {length}." + ) + + +def get_word_range_error_msg(min: int, max: int): + return MarshmallowErrorContainer( + SchemaValidationError.MIN_OR_MAX_LENGTH, f"Word count must be between {min} and {max}." + ) + + +def get_word_equal_error_msg(equal: int): + return MarshmallowErrorContainer(SchemaValidationError.EQUALS, 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 +221,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 +311,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 +384,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 +455,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") From 7140b487b2cb2711647473db7ce8f34e2ab1f573 Mon Sep 17 00:00:00 2001 From: Glen Blosser Date: Wed, 5 Aug 2026 12:55:04 -0500 Subject: [PATCH 4/6] Fixing validator.WordLimit --- .../api/schemas/extension/field_validators.py | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) 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 f7fc961..c56ee7e 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 @@ -141,22 +141,22 @@ class WordLimit(validators.Validator): } def __init__( - self, - min: int = None, - max: int = None, - equal: int = 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 + self, + min: int = None, + max: int = None, + equal: int = 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: From 1790d170f09f9ca3bfca5fd61856ebab11c9dcdc Mon Sep 17 00:00:00 2001 From: Glen Blosser Date: Wed, 5 Aug 2026 12:58:17 -0500 Subject: [PATCH 5/6] Fixing lint --- .../grants_shared/api/schemas/extension/field_validators.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 c56ee7e..709653b 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 @@ -142,9 +142,9 @@ class WordLimit(validators.Validator): def __init__( self, - min: int = None, - max: int = None, - equal: int = None, + min: int | None = None, + max: int | None = None, + equal: int | None = None, ): """ :param min: The minimum word count. If not provided, minimum word count From 54be9b7b4d8fc358316590f6bec3ec8b25bbc35c Mon Sep 17 00:00:00 2001 From: Glen Blosser Date: Thu, 6 Aug 2026 14:33:02 -0500 Subject: [PATCH 6/6] Bumping version --- backend/grants_shared/pyproject.toml | 2 +- .../api/schemas/extension/field_validators.py | 8 ++++---- .../api/schemas/extension/schema_validation_error.py | 5 +++++ .../api/schemas/schema_validation_utils.py | 10 ++++++---- backend/grants_shared/uv.lock | 2 +- 5 files changed, 17 insertions(+), 10 deletions(-) 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 709653b..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 @@ -127,16 +127,16 @@ class WordLimit(validators.Validator): error_mapping: dict[str, MarshmallowErrorContainer] = { "message_min": MarshmallowErrorContainer( - SchemaValidationError.MIN_LENGTH, "Shorter than minimum word count {min}." + SchemaValidationError.MIN_WORDS, "Shorter than minimum word count {min}." ), "message_max": MarshmallowErrorContainer( - SchemaValidationError.MAX_LENGTH, "Longer than maximum word count {max}." + SchemaValidationError.MAX_WORDS, "Longer than maximum word count {max}." ), "message_all": MarshmallowErrorContainer( - SchemaValidationError.MIN_OR_MAX_LENGTH, "Word count must be between {min} and {max}." + SchemaValidationError.MIN_OR_MAX_WORDS, "Word count must be between {min} and {max}." ), "message_equal": MarshmallowErrorContainer( - SchemaValidationError.EQUALS, "Word count must be {equal}." + SchemaValidationError.EQUALS_WORDS, "Word count must be {equal}." ), } 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 29ef266..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 @@ -79,24 +79,26 @@ def get_one_of_error_msg(choices: list[str]): def get_min_word_error_msg(length: int): return MarshmallowErrorContainer( - SchemaValidationError.MIN_LENGTH, f"Shorter than minimum word count {length}." + SchemaValidationError.MIN_WORDS, f"Shorter than minimum word count {length}." ) def get_max_word_error_msg(length: int): return MarshmallowErrorContainer( - SchemaValidationError.MAX_LENGTH, f"Longer than maximum word count {length}." + 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_LENGTH, f"Word count must be between {min} and {max}." + 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, f"Word count must be {equal}.") + return MarshmallowErrorContainer( + SchemaValidationError.EQUALS_WORDS, f"Word count must be {equal}." + ) def get_min_length_error_msg(length: int): 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" },