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
2 changes: 1 addition & 1 deletion backend/grants_shared/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import copy
import re
import typing

from apiflask import validators # noqa: TID251
Expand Down Expand Up @@ -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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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}."
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion backend/grants_shared/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.