diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index a9971d7..faadede 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -8,17 +8,29 @@ All notable changes to this project will be documented in this file. Added ^^^^^ - +- ``IsDateTimeValidator`` +- ``IsDateValidator`` Changed ^^^^^^^ - Updated ``IsTypedDictValidator` and ``IsDataclassValidator`` to require a specific model and checks if the input json is in the defined format. +- Introduced Mixins for parts of InputFilter + + - ``ConditionMixin`` + - ``DataMixin`` + - ``ErrorHandlingMixin`` + - ``ExternalApiMixin`` + - ``FieldMixin`` + - ``FilterMixin`` + - ``ModelMixin`` + - ``ValidationMixin`` Removed ^^^^^^^ - ``RemoveEmojisFilter`` - ``ToPascaleCaseFilter`` +- ``SlugifyFilter`` [0.2.0] - 2025-04-07 diff --git a/docs/source/options/copy.rst b/docs/source/options/copy.rst index 53fad4e..b7b62b9 100644 --- a/docs/source/options/copy.rst +++ b/docs/source/options/copy.rst @@ -20,7 +20,7 @@ Basic Copy Integration .. code-block:: python from flask_inputfilter import InputFilter - from flask_inputfilter.Filter import SlugifyFilter + from flask_inputfilter.Filter import StringSlugifyFilter class MyInputFilter(InputFilter): def __init__(self): @@ -33,7 +33,7 @@ Basic Copy Integration self.add( "escapedUsername", copy="username" - filters=[SlugifyFilter()] + filters=[StringSlugifyFilter()] ) # Example usage @@ -53,7 +53,7 @@ The coping can also be used as a chain. .. code-block:: python from flask_inputfilter import InputFilter - from flask_inputfilter.Filter import SlugifyFilter, ToUpperFilter, ToLowerFilter + from flask_inputfilter.Filter import StringSlugifyFilter, ToUpperFilter, ToLowerFilter class MyInputFilter(InputFilter): def __init__(self): @@ -66,7 +66,7 @@ The coping can also be used as a chain. self.add( "escapedUsername", copy="username" - filters=[SlugifyFilter()] + filters=[StringSlugifyFilter()] ) self.add( diff --git a/docs/source/options/filter.rst b/docs/source/options/filter.rst index c5deb1a..cfd8e57 100644 --- a/docs/source/options/filter.rst +++ b/docs/source/options/filter.rst @@ -224,7 +224,7 @@ StringSlugifyFilter ~~~~~~~~~~~~~~~~~~~ **Description:** -Converts a string into a slug format without deprecation warnings. +Converts a string into a slug format. **Expected Behavior:** diff --git a/flask_inputfilter/Condition/CustomCondition.py b/flask_inputfilter/Condition/CustomCondition.py index 3d77373..6c599fc 100644 --- a/flask_inputfilter/Condition/CustomCondition.py +++ b/flask_inputfilter/Condition/CustomCondition.py @@ -8,7 +8,7 @@ class CustomCondition(BaseCondition): Allows users to define their own condition as a callable. """ - __slots__ = "condition" + __slots__ = ("condition",) def __init__(self, condition: Callable[[Dict[str, Any]], bool]) -> None: self.condition = condition diff --git a/flask_inputfilter/Condition/ExactlyOneOfCondition.py b/flask_inputfilter/Condition/ExactlyOneOfCondition.py index 69765d2..34c2d43 100644 --- a/flask_inputfilter/Condition/ExactlyOneOfCondition.py +++ b/flask_inputfilter/Condition/ExactlyOneOfCondition.py @@ -8,7 +8,7 @@ class ExactlyOneOfCondition(BaseCondition): Condition that ensures exactly one of the specified fields is present. """ - __slots__ = "fields" + __slots__ = ("fields",) def __init__(self, fields: List[str]) -> None: self.fields = fields diff --git a/flask_inputfilter/Condition/OneOfCondition.py b/flask_inputfilter/Condition/OneOfCondition.py index e564d6b..74532a8 100644 --- a/flask_inputfilter/Condition/OneOfCondition.py +++ b/flask_inputfilter/Condition/OneOfCondition.py @@ -8,7 +8,7 @@ class OneOfCondition(BaseCondition): Condition that ensures at least one of the specified fields is present. """ - __slots__ = "fields" + __slots__ = ("fields",) def __init__(self, fields: List[str]) -> None: self.fields = fields diff --git a/flask_inputfilter/Condition/__init__.py b/flask_inputfilter/Condition/__init__.py index 5fa45f0..291e73c 100644 --- a/flask_inputfilter/Condition/__init__.py +++ b/flask_inputfilter/Condition/__init__.py @@ -11,6 +11,7 @@ from .IntegerBiggerThanCondition import IntegerBiggerThanCondition from .NOfCondition import NOfCondition from .NOfMatchesCondition import NOfMatchesCondition +from .NotEqualCondition import NotEqualCondition from .OneOfCondition import OneOfCondition from .OneOfMatchesCondition import OneOfMatchesCondition from .RequiredIfCondition import RequiredIfCondition diff --git a/flask_inputfilter/Filter/ArrayExplodeFilter.py b/flask_inputfilter/Filter/ArrayExplodeFilter.py index ae7b4bd..e1debbb 100644 --- a/flask_inputfilter/Filter/ArrayExplodeFilter.py +++ b/flask_inputfilter/Filter/ArrayExplodeFilter.py @@ -8,7 +8,7 @@ class ArrayExplodeFilter(BaseFilter): Filter that splits a string into an array based on a specified delimiter. """ - __slots__ = "delimiter" + __slots__ = ("delimiter",) def __init__(self, delimiter: str = ",") -> None: self.delimiter = delimiter diff --git a/flask_inputfilter/Filter/BlacklistFilter.py b/flask_inputfilter/Filter/BlacklistFilter.py index 98e691d..3b06938 100644 --- a/flask_inputfilter/Filter/BlacklistFilter.py +++ b/flask_inputfilter/Filter/BlacklistFilter.py @@ -8,7 +8,7 @@ class BlacklistFilter(BaseFilter): Filter that filters out values that are in the blacklist. """ - __slots__ = "blacklist" + __slots__ = ("blacklist",) def __init__(self, blacklist: List[str]) -> None: self.blacklist = blacklist diff --git a/flask_inputfilter/Filter/SlugifyFilter.py b/flask_inputfilter/Filter/SlugifyFilter.py deleted file mode 100644 index 8b8ea65..0000000 --- a/flask_inputfilter/Filter/SlugifyFilter.py +++ /dev/null @@ -1,41 +0,0 @@ -import re -import unicodedata -import warnings -from typing import Any, Optional, Union - -from flask_inputfilter.Enum import UnicodeFormEnum -from flask_inputfilter.Filter import BaseFilter - - -class SlugifyFilter(BaseFilter): - """ - Filter that converts a string to a slug. - """ - - def apply(self, value: Any) -> Union[Optional[str], Any]: - warnings.warn( - "SlugifyFilter is deprecated and will be discontinued. " - "It can safely be replaced with StringSlugifyFilter.", - DeprecationWarning, - ) - - if not isinstance(value, str): - return value - - value_without_accents = "".join( - char - for char in unicodedata.normalize(UnicodeFormEnum.NFD.value, value) - if unicodedata.category(char) != "Mn" - ) - - value = unicodedata.normalize( - UnicodeFormEnum.NFKD.value, value_without_accents - ) - value = value.encode("ascii", "ignore").decode("ascii") - - value = value.lower() - - value = re.sub(r"[^\w\s-]", "", value) - value = re.sub(r"[\s]+", "-", value) - - return value diff --git a/flask_inputfilter/Filter/ToDataclassFilter.py b/flask_inputfilter/Filter/ToDataclassFilter.py index a78ee2b..0ffb71b 100644 --- a/flask_inputfilter/Filter/ToDataclassFilter.py +++ b/flask_inputfilter/Filter/ToDataclassFilter.py @@ -8,7 +8,7 @@ class ToDataclassFilter(BaseFilter): Filter that converts a dictionary to a dataclass. """ - __slots__ = "dataclass_type" + __slots__ = ("dataclass_type",) def __init__(self, dataclass_type: Type[dict]) -> None: self.dataclass_type = dataclass_type diff --git a/flask_inputfilter/Filter/ToEnumFilter.py b/flask_inputfilter/Filter/ToEnumFilter.py index d21b6d7..ae55318 100644 --- a/flask_inputfilter/Filter/ToEnumFilter.py +++ b/flask_inputfilter/Filter/ToEnumFilter.py @@ -9,7 +9,7 @@ class ToEnumFilter(BaseFilter): Filter that converts a value to an Enum instance. """ - __slots__ = "enum_class" + __slots__ = ("enum_class",) def __init__(self, enum_class: Type[Enum]) -> None: self.enum_class = enum_class diff --git a/flask_inputfilter/Filter/ToNormalizedUnicodeFilter.py b/flask_inputfilter/Filter/ToNormalizedUnicodeFilter.py index 8ba1a4c..78e8b4c 100644 --- a/flask_inputfilter/Filter/ToNormalizedUnicodeFilter.py +++ b/flask_inputfilter/Filter/ToNormalizedUnicodeFilter.py @@ -12,7 +12,7 @@ class ToNormalizedUnicodeFilter(BaseFilter): Filter that normalizes a string to a specified Unicode form. """ - __slots__ = "form" + __slots__ = ("form",) def __init__( self, diff --git a/flask_inputfilter/Filter/ToTypedDictFilter.py b/flask_inputfilter/Filter/ToTypedDictFilter.py index 1584e3a..d378a96 100644 --- a/flask_inputfilter/Filter/ToTypedDictFilter.py +++ b/flask_inputfilter/Filter/ToTypedDictFilter.py @@ -10,7 +10,7 @@ class ToTypedDictFilter(BaseFilter): Filter that converts a dictionary to a TypedDict. """ - __slots__ = "typed_dict" + __slots__ = ("typed_dict",) def __init__(self, typed_dict: Type[TypedDict]) -> None: self.typed_dict = typed_dict diff --git a/flask_inputfilter/Filter/TruncateFilter.py b/flask_inputfilter/Filter/TruncateFilter.py index bb810fc..fe045e5 100644 --- a/flask_inputfilter/Filter/TruncateFilter.py +++ b/flask_inputfilter/Filter/TruncateFilter.py @@ -8,7 +8,7 @@ class TruncateFilter(BaseFilter): Filter that truncates a string to a specified maximum length. """ - __slots__ = "max_length" + __slots__ = ("max_length",) def __init__(self, max_length: int) -> None: self.max_length = max_length diff --git a/flask_inputfilter/Filter/WhitelistFilter.py b/flask_inputfilter/Filter/WhitelistFilter.py index 8704d75..fd225d5 100644 --- a/flask_inputfilter/Filter/WhitelistFilter.py +++ b/flask_inputfilter/Filter/WhitelistFilter.py @@ -6,7 +6,7 @@ class WhitelistFilter(BaseFilter): """Filter that filters out values that are not in the whitelist.""" - __slots__ = "whitelist" + __slots__ = ("whitelist",) def __init__(self, whitelist: List[str] = None) -> None: self.whitelist = whitelist diff --git a/flask_inputfilter/Filter/__init__.py b/flask_inputfilter/Filter/__init__.py index 8ece5e9..0e76f1f 100644 --- a/flask_inputfilter/Filter/__init__.py +++ b/flask_inputfilter/Filter/__init__.py @@ -4,7 +4,6 @@ from .Base64ImageDownscaleFilter import Base64ImageDownscaleFilter from .Base64ImageResizeFilter import Base64ImageResizeFilter from .BlacklistFilter import BlacklistFilter -from .SlugifyFilter import SlugifyFilter from .StringRemoveEmojisFilter import StringRemoveEmojisFilter from .StringSlugifyFilter import StringSlugifyFilter from .StringTrimFilter import StringTrimFilter diff --git a/flask_inputfilter/InputFilter.py b/flask_inputfilter/InputFilter.py index 5510f09..18a1337 100644 --- a/flask_inputfilter/InputFilter.py +++ b/flask_inputfilter/InputFilter.py @@ -1,5 +1,4 @@ import json -import re from typing import ( Any, Callable, @@ -18,544 +17,58 @@ from flask_inputfilter.Condition import BaseCondition from flask_inputfilter.Exception import ValidationError from flask_inputfilter.Filter import BaseFilter -from flask_inputfilter.Model import ExternalApiConfig, FieldModel +from flask_inputfilter.Mixin import ( + ConditionMixin, + DataMixin, + ErrorHandlingMixin, + ExternalApiMixin, + FieldMixin, + FilterMixin, + ModelMixin, + ValidationMixin, +) +from flask_inputfilter.Model import FieldModel from flask_inputfilter.Validator import BaseValidator -API_PLACEHOLDER_PATTERN = re.compile(r"{{(.*?)}}") - T = TypeVar("T") -class InputFilter: +class InputFilter( + ConditionMixin, + DataMixin, + ErrorHandlingMixin, + ExternalApiMixin, + FieldMixin, + FilterMixin, + ModelMixin, + ValidationMixin, +): """ Base class for input filters. """ __slots__ = ( "__methods", - "__fields", - "__conditions", - "__global_filters", - "__global_validators", - "__data", - "__validated_data", - "__errors", - "__model_class", + "_fields", + "_conditions", + "_global_filters", + "_global_validators", + "_data", + "_validated_data", + "_errors", + "_model_class", ) def __init__(self, methods: Optional[List[str]] = None) -> None: self.__methods = methods or ["GET", "POST", "PATCH", "PUT", "DELETE"] - self.__fields: Dict[str, FieldModel] = {} - self.__conditions: List[BaseCondition] = [] - self.__global_filters: List[BaseFilter] = [] - self.__global_validators: List[BaseValidator] = [] - self.__data: Dict[str, Any] = {} - self.__validated_data: Dict[str, Any] = {} - self.__errors: Dict[str, str] = {} - self.__model_class: Optional[Type[T]] = None - - @final - def add( - self, - name: str, - required: bool = False, - default: Any = None, - fallback: Any = None, - filters: Optional[List[BaseFilter]] = None, - validators: Optional[List[BaseValidator]] = None, - steps: Optional[List[Union[BaseFilter, BaseValidator]]] = None, - external_api: Optional[ExternalApiConfig] = None, - copy: Optional[str] = None, - ) -> None: - """ - Add the field to the input filter. - - Args: - name: The name of the field. - required: Whether the field is required. - default: The default value of the field. - fallback: The fallback value of the field, if validations fails - or field None, although it is required . - filters: The filters to apply to the field value. - validators: The validators to apply to the field value. - steps: Allows to apply multiple filters and validators - in a specific order. - external_api: Configuration for an external API call. - copy: The name of the field to copy the value from. - """ - self.__fields[name] = FieldModel( - required=required, - default=default, - fallback=fallback, - filters=filters or [], - validators=validators or [], - steps=steps or [], - external_api=external_api, - copy=copy, - ) - - @final - def addCondition(self, condition: BaseCondition) -> None: - """ - Add a condition to the input filter. - - Args: - condition: The condition to add. - """ - self.__conditions.append(condition) - - @final - def addGlobalFilter(self, filter: BaseFilter) -> None: - """ - Add a global filter to be applied to all fields. - - Args: - filter: The filter to add. - """ - self.__global_filters.append(filter) - - @final - def addGlobalValidator(self, validator: BaseValidator) -> None: - """ - Add a global validator to be applied to all fields. - - Args: - validator: The validator to add. - """ - self.__global_validators.append(validator) - - @final - def has(self, field_name: str) -> bool: - """ - This method checks the existence of a specific field within the - input filter values, identified by its field name. It does not return a - value, serving purely as a validation or existence check mechanism. - - Args: - field_name (str): The name of the field to check for existence. - - Returns: - bool: True if the field exists in the input filter, - otherwise False. - """ - return field_name in self.__fields - - @final - def getInput(self, field_name: str) -> Optional[FieldModel]: - """ - Represents a method to retrieve a field by its name. - - This method allows fetching the configuration of a specific field - within the object, using its name as a string. It ensures - compatibility with various field names and provides a generic - return type to accommodate different data types for the fields. - - Args: - field_name: A string representing the name of the field who - needs to be retrieved. - - Returns: - Optional[FieldModel]: The field corresponding to the - specified name. - """ - return self.__fields.get(field_name) - - @final - def getInputs(self) -> Dict[str, FieldModel]: - """ - Retrieve the dictionary of input fields associated with the object. - - Returns: - Dict[str, FieldModel]: Dictionary containing field names as - keys and their corresponding FieldModel instances as values - """ - return self.__fields - - @final - def remove(self, field_name: str) -> Any: - """ - Removes the specified field from the instance or collection. - - This method is used to delete a specific field identified by - its name. It ensures the designated field is removed entirely - from the relevant data structure. No value is returned upon - successful execution. - - Args: - field_name: The name of the field to be removed. - - Returns: - Any: The value of the removed field, if any. - """ - return self.__fields.pop(field_name, None) - - @final - def count(self) -> int: - """ - Counts the total number of elements in the collection. - - This method returns the total count of elements stored within the - underlying data structure, providing a quick way to ascertain the - size or number of entries available. - - Returns: - int: The total number of elements in the collection. - """ - return len(self.__fields) - - @final - def replace( - self, - name: str, - required: bool = False, - default: Any = None, - fallback: Any = None, - filters: Optional[List[BaseFilter]] = None, - validators: Optional[List[BaseValidator]] = None, - steps: Optional[List[Union[BaseFilter, BaseValidator]]] = None, - external_api: Optional[ExternalApiConfig] = None, - copy: Optional[str] = None, - ) -> None: - """ - Replaces a field in the input filter. - - Args: - name: The name of the field. - required: Whether the field is required. - default: The default value of the field. - fallback: The fallback value of the field, if validations fails - or field None, although it is required . - filters: The filters to apply to the field value. - validators: The validators to apply to the field value. - steps: Allows to apply multiple filters and validators - in a specific order. - external_api: Configuration for an external API call. - copy: The name of the field to copy the value from. - """ - self.__fields[name] = FieldModel( - required=required, - default=default, - fallback=fallback, - filters=filters or [], - validators=validators or [], - steps=steps or [], - external_api=external_api, - copy=copy, - ) - - @final - def setData(self, data: Dict[str, Any]) -> None: - """ - Filters and sets the provided data into the object's internal - storage, ensuring that only the specified fields are considered and - their values are processed through defined filters. - - Parameters: - data: - The input dictionary containing key-value pairs where keys - represent field names and values represent the associated - data to be filtered and stored. - """ - filtered_data = {} - for field_name, field_value in data.items(): - if field_name in self.__fields: - filtered_data[field_name] = self.__applyFilters( - filters=self.__fields[field_name].filters, - value=field_value, - ) - else: - filtered_data[field_name] = field_value - - self.__data = filtered_data - - @final - def clear(self) -> None: - """ - Resets all fields of the InputFilter instance to - their initial empty state. - - This method clears the internal storage of fields, - conditions, filters, validators, and data, effectively - resetting the object as if it were newly initialized. - """ - self.__fields.clear() - self.__conditions.clear() - self.__global_filters.clear() - self.__global_validators.clear() - self.__data.clear() - self.__validated_data.clear() - self.__errors.clear() - - @final - def getErrorMessage(self, field_name: str) -> str: - """ - Retrieves and returns a predefined error message. - - This method is intended to provide a consistent error message - to be used across the application when an error occurs. The - message is predefined and does not accept any parameters. - The exact content of the error message may vary based on - specific implementation, but it is designed to convey meaningful - information about the nature of an error. - - Returns: - str: A string representing the predefined error message. - """ - return self.__errors.get(field_name) - - @final - def getErrorMessages(self) -> Dict[str, str]: - """ - Retrieves all error messages associated with the fields in the - input filter. - - This method aggregates and returns a dictionary of error messages - where the keys represent field names, and the values are their - respective error messages. - - Returns: - Dict[str, str]: A dictionary containing field names as keys and - their corresponding error messages as values. - """ - return self.__errors - - @final - def getValue(self, name: str) -> Any: - """ - This method retrieves a value associated with the provided name. It - searches for the value based on the given identifier and returns the - corresponding result. If no value is found, it typically returns a - default or fallback output. The method aims to provide flexibility in - retrieving data without explicitly specifying the details of the - underlying implementation. - - Args: - name: A string that represents the identifier for which the - corresponding value is being retrieved. It is used to perform - the lookup. - - Returns: - Any: The retrieved value associated with the given name. The - specific type of this value is dependent on the - implementation and the data being accessed. - """ - return self.__validated_data.get(name) - - @final - def getValues(self) -> Dict[str, Any]: - """ - Retrieves a dictionary of key-value pairs from the current object. - This method provides access to the internal state or configuration of - the object in a dictionary format, where keys are strings and values - can be of various types depending on the object's design. - - Returns: - Dict[str, Any]: A dictionary containing string keys and their - corresponding values of any data type. - """ - return self.__validated_data - - @final - def getRawValue(self, name: str) -> Any: - """ - Fetches the raw value associated with the provided key. - - This method is used to retrieve the underlying value linked to the - given key without applying any transformations or validations. It - directly fetches the raw stored value and is typically used in - scenarios where the raw data is needed for processing or debugging - purposes. - - Args: - name: The name of the key whose raw value is to be retrieved. - - Returns: - Any: The raw value associated with the provided key. - """ - return self.__data.get(name) if name in self.__data else None - - @final - def getRawValues(self) -> Dict[str, Any]: - """ - Retrieves raw values from a given source and returns them as a - dictionary. - - This method is used to fetch and return unprocessed or raw data in - the form of a dictionary where the keys are strings, representing - the identifiers, and the values are of any data type. - - Returns: - Dict[str, Any]: A dictionary containing the raw values retrieved. - The keys are strings representing the identifiers, and the - values can be of any type, depending on the source - being accessed. - """ - if not self.__fields: - return {} - - return { - field: self.__data[field] - for field in self.__fields - if field in self.__data - } - - @final - def getUnfilteredData(self) -> Dict[str, Any]: - """ - Fetches unfiltered data from the data source. - - This method retrieves data without any filtering, processing, or - manipulations applied. It is intended to provide raw data that has - not been altered since being retrieved from its source. The usage - of this method should be limited to scenarios where unprocessed data - is required, as it does not perform any validations or checks. - - Returns: - Dict[str, Any]: The unfiltered, raw data retrieved from the - data source. The return type may vary based on the - specific implementation of the data source. - """ - return self.__data - - @final - def setUnfilteredData(self, data: Dict[str, Any]) -> None: - """ - Sets unfiltered data for the current instance. This method assigns a - given dictionary of data to the instance for further processing. It - updates the internal state using the provided data. - - Parameters: - data: A dictionary containing the unfiltered - data to be associated with the instance. - """ - self.__data = data - - @final - def getConditions(self) -> List[BaseCondition]: - """ - Retrieve the list of all registered conditions. - - This function provides access to the conditions that have been - registered and stored. Each condition in the returned list - is represented as an instance of the BaseCondition type. - - Returns: - List[BaseCondition]: A list containing all currently registered - instances of BaseCondition. - """ - return self.__conditions - - @final - def getGlobalFilters(self) -> List[BaseFilter]: - """ - Retrieve all global filters associated with this InputFilter instance. - - This method returns a list of BaseFilter instances that have been - added as global filters. These filters are applied universally to - all fields during data processing. - - Returns: - List[BaseFilter]: A list of global filters. - """ - return self.__global_filters - - @final - def getGlobalValidators(self) -> List[BaseValidator]: - """ - Retrieve all global validators associated with this - InputFilter instance. - - This method returns a list of BaseValidator instances that have been - added as global validators. These validators are applied universally - to all fields during validation. - - Returns: - List[BaseValidator]: A list of global validators. - """ - return self.__global_validators - - @final - def hasUnknown(self) -> bool: - """ - Checks whether any values in the current data do not have - corresponding configurations in the defined fields. - - Returns: - bool: True if there are any unknown fields; False otherwise. - """ - if not self.__data and self.__fields: - return True - return any( - field_name not in self.__fields.keys() - for field_name in self.__data.keys() - ) - - @final - def merge(self, other: "InputFilter") -> None: - """ - Merges another InputFilter instance intelligently into the current - instance. - - - Fields with the same name are merged recursively if possible, - otherwise overwritten. - - Conditions, are combined and deduplicated. - - Global filters and validators are merged without duplicates. - - Args: - other (InputFilter): The InputFilter instance to merge. - """ - if not isinstance(other, InputFilter): - raise TypeError( - "Can only merge with another InputFilter instance." - ) - - for key, new_field in other.getInputs().items(): - self.__fields[key] = new_field - - self.__conditions = self.__conditions + other.__conditions - - for filter in other.__global_filters: - existing_types = [type(v) for v in self.__global_filters] - if type(filter) in existing_types: - index = existing_types.index(type(filter)) - self.__global_filters[index] = filter - - else: - self.__global_filters.append(filter) - - for validator in other.__global_validators: - existing_types = [type(v) for v in self.__global_validators] - if type(validator) in existing_types: - index = existing_types.index(type(validator)) - self.__global_validators[index] = validator - - else: - self.__global_validators.append(validator) - - @final - def setModel(self, model_class: Type[T]) -> None: - """ - Set the model class for serialization. - - Args: - model_class: The class to use for serialization. - """ - self.__model_class = model_class - - @final - def serialize(self) -> Union[Dict[str, Any], T]: - """ - Serialize the validated data. If a model class is set, - returns an instance of that class, otherwise returns the - raw validated data. - - Returns: - Union[Dict[str, Any], T]: The serialized data. - """ - if self.__model_class is None: - return self.__validated_data - - return self.__model_class(**self.__validated_data) + self._fields: Dict[str, FieldModel] = {} + self._conditions: List[BaseCondition] = [] + self._global_filters: List[BaseFilter] = [] + self._global_validators: List[BaseValidator] = [] + self._data: Dict[str, Any] = {} + self._validated_data: Dict[str, Any] = {} + self._errors: Dict[str, str] = {} + self._model_class: Optional[Type[T]] = None @final def isValid(self) -> bool: @@ -570,89 +83,14 @@ def isValid(self) -> bool: all required conditions; otherwise, returns False. """ try: - self.validateData(self.__data) + self.validateData(self._data) except ValidationError as e: - self.__errors = e.args[0] + self._errors = e.args[0] return False return True - @final - def validateData( - self, data: Optional[Dict[str, Any]] = None - ) -> Dict[str, Any]: - """ - Validates input data against defined field rules, including applying - filters, validators, custom logic steps, and fallback mechanisms. The - validation process also ensures the required fields are handled - appropriately and conditions are checked after processing. - - Args: - data (Dict[str, Any]): A dictionary containing the input data to - be validated where keys represent field names and values - represent the corresponding data. - - Returns: - Dict[str, Any]: A dictionary containing the validated data with - any modifications, default values, or processed values as - per the defined validation rules. - - Raises: - Any errors raised during external API calls, validation, or - logical steps execution of the respective fields or conditions - will propagate without explicit handling here. - """ - validated_data = self.__validated_data - data = data or self.__data - errors = {} - - for field_name, field_info in self.__fields.items(): - value = data.get(field_name) - - required = field_info.required - default = field_info.default - fallback = field_info.fallback - filters = field_info.filters - validators = field_info.validators - steps = field_info.steps - external_api = field_info.external_api - copy = field_info.copy - - try: - if copy: - value = validated_data.get(copy) - - if external_api: - value = self.__callExternalApi( - external_api, fallback, validated_data - ) - - value = self.__applyFilters(filters, value) - value = ( - self.__validateField(validators, fallback, value) or value - ) - value = self.__applySteps(steps, fallback, value) or value - value = self.__checkForRequired( - field_name, required, default, fallback, value - ) - - validated_data[field_name] = value - - except ValidationError as e: - errors[field_name] = str(e) - - try: - self.__checkConditions(validated_data) - except ValidationError as e: - errors["_condition"] = str(e) - - if errors: - raise ValidationError(errors) - - self.__validated_data = validated_data - return validated_data - @classmethod @final def validate( @@ -686,11 +124,11 @@ def wrapper( try: kwargs = kwargs or {} - input_filter.__data = {**data, **kwargs} + input_filter._data = {**data, **kwargs} validated_data = input_filter.validateData() - if input_filter.__model_class is not None: + if input_filter._model_class is not None: validated_data = input_filter.serialize() g.validated_data = validated_data @@ -708,207 +146,83 @@ def wrapper( return decorator - def __applyFilters(self, filters: List[BaseFilter], value: Any) -> Any: - """ - Apply filters to the field value. - """ - if value is None: - return value - - for filter_ in self.__global_filters + filters: - value = filter_.apply(value) - - return value - - def __validateField( - self, validators: List[BaseValidator], fallback: Any, value: Any - ) -> None: - """ - Validate the field value. - """ - if value is None: - return - - try: - for validator in self.__global_validators + validators: - validator.validate(value) - except ValidationError: - if fallback is None: - raise - - return fallback - - @staticmethod - def __applySteps( - steps: List[Union[BaseFilter, BaseValidator]], - fallback: Any, - value: Any, - ) -> Any: - """ - Apply multiple filters and validators in a specific order. + @final + def validateData( + self, data: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: """ - if value is None: - return + Validates input data against defined field rules, including applying + filters, validators, custom logic steps, and fallback mechanisms. The + validation process also ensures the required fields are handled + appropriately and conditions are checked after processing. - try: - for step in steps: - if isinstance(step, BaseFilter): - value = step.apply(value) - elif isinstance(step, BaseValidator): - step.validate(value) - except ValidationError: - if fallback is None: - raise - return fallback - return value - - def __callExternalApi( - self, config: ExternalApiConfig, fallback: Any, validated_data: dict - ) -> Optional[Any]: - """ - Makes a call to an external API using provided configuration and - returns the response. - - Summary: - The function constructs a request based on the given API - configuration and validated data, including headers, parameters, - and other request settings. It utilizes the `requests` library - to send the API call and processes the response. If a fallback - value is supplied, it is returned in case of any failure during - the API call. If no fallback is provided, a validation error is - raised. - - Parameters: - config: - An object containing the configuration details for the - external API call, such as URL, headers, method, and API key. - fallback: - The value to be returned in case the external API call fails. - validated_data: - The dictionary containing data used to replace placeholders - in the URL and parameters of the API request. + Args: + data (Dict[str, Any]): A dictionary containing the input data to + be validated where keys represent field names and values + represent the corresponding data. Returns: - Optional[Any]: - The JSON-decoded response from the API, or the fallback - value if the call fails and a fallback is provided. + Dict[str, Any]: A dictionary containing the validated data with + any modifications, default values, or processed values as + per the defined validation rules. Raises: - ValidationError - Raised if the external API call does not succeed and no - fallback value is provided. + Any errors raised during external API calls, validation, or + logical steps execution of the respective fields or conditions + will propagate without explicit handling here. """ - import logging - - import requests - - logger = logging.getLogger(__name__) - - data_key = config.data_key - - requestData = { - "headers": {}, - "params": {}, - } - - if config.api_key: - requestData["headers"]["Authorization"] = ( - f"Bearer " f"{config.api_key}" - ) + validated_data = self._validated_data + data = data or self._data + errors = {} - if config.headers: - requestData["headers"].update(config.headers) + for field_name, field_info in self._fields.items(): + value = data.get(field_name) - if config.params: - requestData["params"] = self.__replacePlaceholdersInParams( - config.params, validated_data - ) + required = field_info.required + default = field_info.default + fallback = field_info.fallback + filters = field_info.filters + validators = field_info.validators + steps = field_info.steps + external_api = field_info.external_api + copy = field_info.copy - requestData["url"] = self.__replacePlaceholders( - config.url, validated_data - ) - requestData["method"] = config.method + try: + if copy: + value = validated_data.get(copy) - try: - response = requests.request(**requestData) + if external_api: + value = self._ExternalApiMixin__callExternalApi( + external_api, fallback, validated_data + ) - if response.status_code != 200: - logger.error( - f"External_api request inside of InputFilter " - f"failed: {response.text}" + value = self._FilterMixin__applyFilters(filters, value) + value = ( + self._ValidationMixin__validateField( + validators, fallback, value + ) + or value ) - raise - - result = response.json() - - if data_key: - return result.get(data_key) - - return result - except Exception: - if fallback is None: - raise ValidationError( - f"External API call failed for field " f"'{data_key}'." + value = ( + self._FieldMixin__applySteps(steps, fallback, value) + or value + ) + value = self._FieldMixin__checkForRequired( + field_name, required, default, fallback, value ) - return fallback - - @staticmethod - def __replacePlaceholders(value: str, validated_data: dict) -> str: - """ - Replace all placeholders, marked with '{{ }}' in value - with the corresponding values from validated_data. - """ - return API_PLACEHOLDER_PATTERN.sub( - lambda match: str(validated_data.get(match.group(1))), - value, - ) - - def __replacePlaceholdersInParams( - self, params: dict, validated_data: dict - ) -> dict: - """ - Replace all placeholders in params with the corresponding - values from validated_data. - """ - return { - key: self.__replacePlaceholders(value, validated_data) - if isinstance(value, str) - else value - for key, value in params.items() - } - - @staticmethod - def __checkForRequired( - field_name: str, - required: bool, - default: Any, - fallback: Any, - value: Any, - ) -> Any: - """ - Determine the value of the field, considering the required and - fallback attributes. - - If the field is not required and no value is provided, the default - value is returned. If the field is required and no value is provided, - the fallback value is returned. If no of the above conditions are met, - a ValidationError is raised. - """ - if value is not None: - return value + validated_data[field_name] = value - if not required: - return default + except ValidationError as e: + errors[field_name] = str(e) - if fallback is not None: - return fallback + try: + self._ConditionMixin__checkConditions(validated_data) + except ValidationError as e: + errors["_condition"] = str(e) - raise ValidationError(f"Field '{field_name}' is required.") + if errors: + raise ValidationError(errors) - def __checkConditions(self, validated_data: Dict[str, Any]) -> None: - for condition in self.__conditions: - if not condition.check(validated_data): - raise ValidationError( - f"Condition '{condition.__class__.__name__}' not met." - ) + self._validated_data = validated_data + return validated_data diff --git a/flask_inputfilter/Mixin/BaseMixin.py b/flask_inputfilter/Mixin/BaseMixin.py new file mode 100644 index 0000000..f62df1f --- /dev/null +++ b/flask_inputfilter/Mixin/BaseMixin.py @@ -0,0 +1,2 @@ +class BaseMixin: + __slots__ = () diff --git a/flask_inputfilter/Mixin/ConditionMixin.py b/flask_inputfilter/Mixin/ConditionMixin.py new file mode 100644 index 0000000..aa05bfa --- /dev/null +++ b/flask_inputfilter/Mixin/ConditionMixin.py @@ -0,0 +1,41 @@ +from typing import Any, Dict, List + +from typing_extensions import final + +from flask_inputfilter.Condition import BaseCondition +from flask_inputfilter.Exception import ValidationError +from flask_inputfilter.Mixin import BaseMixin + + +class ConditionMixin(BaseMixin): + @final + def addCondition(self, condition: BaseCondition) -> None: + """ + Add a condition to the input filter. + + Args: + condition: The condition to add. + """ + self._conditions.append(condition) + + @final + def getConditions(self) -> List[BaseCondition]: + """ + Retrieve the list of all registered conditions. + + This function provides access to the conditions that have been + registered and stored. Each condition in the returned list + is represented as an instance of the BaseCondition type. + + Returns: + List[BaseCondition]: A list containing all currently registered + instances of BaseCondition. + """ + return self._conditions + + def __checkConditions(self, validated_data: Dict[str, Any]) -> None: + for condition in self._conditions: + if not condition.check(validated_data): + raise ValidationError( + f"Condition '{condition.__class__.__name__}' not met." + ) diff --git a/flask_inputfilter/Mixin/DataMixin.py b/flask_inputfilter/Mixin/DataMixin.py new file mode 100644 index 0000000..e85d8f1 --- /dev/null +++ b/flask_inputfilter/Mixin/DataMixin.py @@ -0,0 +1,159 @@ +from typing import Any, Dict + +from typing_extensions import final + +from flask_inputfilter.Mixin import BaseMixin + + +class DataMixin(BaseMixin): + @final + def setData(self, data: Dict[str, Any]) -> None: + """ + Filters and sets the provided data into the object's internal + storage, ensuring that only the specified fields are considered and + their values are processed through defined filters. + + Parameters: + data: + The input dictionary containing key-value pairs where keys + represent field names and values represent the associated + data to be filtered and stored. + """ + filtered_data = {} + for field_name, field_value in data.items(): + if field_name in self._fields: + filtered_data[field_name] = self._FilterMixin__applyFilters( + filters=self._fields[field_name].filters, + value=field_value, + ) + else: + filtered_data[field_name] = field_value + + self._data = filtered_data + + @final + def getValue(self, name: str) -> Any: + """ + This method retrieves a value associated with the provided name. It + searches for the value based on the given identifier and returns the + corresponding result. If no value is found, it typically returns a + default or fallback output. The method aims to provide flexibility in + retrieving data without explicitly specifying the details of the + underlying implementation. + + Args: + name: A string that represents the identifier for which the + corresponding value is being retrieved. It is used to perform + the lookup. + + Returns: + Any: The retrieved value associated with the given name. The + specific type of this value is dependent on the + implementation and the data being accessed. + """ + return self._validated_data.get(name) + + @final + def getValues(self) -> Dict[str, Any]: + """ + Retrieves a dictionary of key-value pairs from the current object. + This method provides access to the internal state or configuration of + the object in a dictionary format, where keys are strings and values + can be of various types depending on the object's design. + + Returns: + Dict[str, Any]: A dictionary containing string keys and their + corresponding values of any data type. + """ + return self._validated_data + + @final + def getRawValue(self, name: str) -> Any: + """ + Fetches the raw value associated with the provided key. + + This method is used to retrieve the underlying value linked to the + given key without applying any transformations or validations. It + directly fetches the raw stored value and is typically used in + scenarios where the raw data is needed for processing or debugging + purposes. + + Args: + name: The name of the key whose raw value is to be retrieved. + + Returns: + Any: The raw value associated with the provided key. + """ + return self._data.get(name) if name in self._data else None + + @final + def getRawValues(self) -> Dict[str, Any]: + """ + Retrieves raw values from a given source and returns them as a + dictionary. + + This method is used to fetch and return unprocessed or raw data in + the form of a dictionary where the keys are strings, representing + the identifiers, and the values are of any data type. + + Returns: + Dict[str, Any]: A dictionary containing the raw values retrieved. + The keys are strings representing the identifiers, and the + values can be of any type, depending on the source + being accessed. + """ + if not self._fields: + return {} + + return { + field: self._data[field] + for field in self._fields + if field in self._data + } + + @final + def getUnfilteredData(self) -> Dict[str, Any]: + """ + Fetches unfiltered data from the data source. + + This method retrieves data without any filtering, processing, or + manipulations applied. It is intended to provide raw data that has + not been altered since being retrieved from its source. The usage + of this method should be limited to scenarios where unprocessed data + is required, as it does not perform any validations or checks. + + Returns: + Dict[str, Any]: The unfiltered, raw data retrieved from the + data source. The return type may vary based on the + specific implementation of the data source. + """ + return self._data + + @final + def setUnfilteredData(self, data: Dict[str, Any]) -> None: + """ + Sets unfiltered data for the current instance. This method assigns a + given dictionary of data to the instance for further processing. It + updates the internal state using the provided data. + + Parameters: + data: A dictionary containing the unfiltered + data to be associated with the instance. + """ + self._data = data + + @final + def hasUnknown(self) -> bool: + """ + Checks whether any values in the current data do not have + corresponding configurations in the defined fields. + + Returns: + bool: True if there are any unknown fields; False otherwise. + """ + if not self._data and self._fields: + return True + return any( + field_name not in self._fields.keys() + for field_name in self._data.keys() + ) diff --git a/flask_inputfilter/Mixin/ErrorHandlingMixin.py b/flask_inputfilter/Mixin/ErrorHandlingMixin.py new file mode 100644 index 0000000..2d5eeec --- /dev/null +++ b/flask_inputfilter/Mixin/ErrorHandlingMixin.py @@ -0,0 +1,40 @@ +from typing import Dict + +from typing_extensions import final + +from flask_inputfilter.Mixin import BaseMixin + + +class ErrorHandlingMixin(BaseMixin): + @final + def getErrorMessage(self, field_name: str) -> str: + """ + Retrieves and returns a predefined error message. + + This method is intended to provide a consistent error message + to be used across the application when an error occurs. The + message is predefined and does not accept any parameters. + The exact content of the error message may vary based on + specific implementation, but it is designed to convey meaningful + information about the nature of an error. + + Returns: + str: A string representing the predefined error message. + """ + return self._errors.get(field_name) + + @final + def getErrorMessages(self) -> Dict[str, str]: + """ + Retrieves all error messages associated with the fields in the + input filter. + + This method aggregates and returns a dictionary of error messages + where the keys represent field names, and the values are their + respective error messages. + + Returns: + Dict[str, str]: A dictionary containing field names as keys and + their corresponding error messages as values. + """ + return self._errors diff --git a/flask_inputfilter/Mixin/ExternalApiMixin.py b/flask_inputfilter/Mixin/ExternalApiMixin.py new file mode 100644 index 0000000..875d686 --- /dev/null +++ b/flask_inputfilter/Mixin/ExternalApiMixin.py @@ -0,0 +1,126 @@ +import re +from typing import Any, Optional + +from flask_inputfilter.Exception import ValidationError +from flask_inputfilter.Mixin import BaseMixin +from flask_inputfilter.Model import ExternalApiConfig + +API_PLACEHOLDER_PATTERN = re.compile(r"{{(.*?)}}") + + +class ExternalApiMixin(BaseMixin): + def __callExternalApi( + self, config: ExternalApiConfig, fallback: Any, validated_data: dict + ) -> Optional[Any]: + """ + Makes a call to an external API using provided configuration and + returns the response. + + Summary: + The function constructs a request based on the given API + configuration and validated data, including headers, parameters, + and other request settings. It utilizes the `requests` library + to send the API call and processes the response. If a fallback + value is supplied, it is returned in case of any failure during + the API call. If no fallback is provided, a validation error is + raised. + + Parameters: + config: + An object containing the configuration details for the + external API call, such as URL, headers, method, and API key. + fallback: + The value to be returned in case the external API call fails. + validated_data: + The dictionary containing data used to replace placeholders + in the URL and parameters of the API request. + + Returns: + Optional[Any]: + The JSON-decoded response from the API, or the fallback + value if the call fails and a fallback is provided. + + Raises: + ValidationError + Raised if the external API call does not succeed and no + fallback value is provided. + """ + import logging + + import requests + + logger = logging.getLogger(__name__) + + data_key = config.data_key + + requestData = { + "headers": {}, + "params": {}, + } + + if config.api_key: + requestData["headers"]["Authorization"] = ( + f"Bearer " f"{config.api_key}" + ) + + if config.headers: + requestData["headers"].update(config.headers) + + if config.params: + requestData["params"] = self.__replacePlaceholdersInParams( + config.params, validated_data + ) + + requestData["url"] = self.__replacePlaceholders( + config.url, validated_data + ) + requestData["method"] = config.method + + try: + response = requests.request(**requestData) + + if response.status_code != 200: + logger.error( + f"External_api request inside of InputFilter " + f"failed: {response.text}" + ) + raise + + result = response.json() + + if data_key: + return result.get(data_key) + + return result + except Exception: + if fallback is None: + raise ValidationError( + f"External API call failed for field " f"'{data_key}'." + ) + + return fallback + + @staticmethod + def __replacePlaceholders(value: str, validated_data: dict) -> str: + """ + Replace all placeholders, marked with '{{ }}' in value + with the corresponding values from validated_data. + """ + return API_PLACEHOLDER_PATTERN.sub( + lambda match: str(validated_data.get(match.group(1))), + value, + ) + + def __replacePlaceholdersInParams( + self, params: dict, validated_data: dict + ) -> dict: + """ + Replace all placeholders in params with the corresponding + values from validated_data. + """ + return { + key: self.__replacePlaceholders(value, validated_data) + if isinstance(value, str) + else value + for key, value in params.items() + } diff --git a/flask_inputfilter/Mixin/FieldMixin.py b/flask_inputfilter/Mixin/FieldMixin.py new file mode 100644 index 0000000..8c340ca --- /dev/null +++ b/flask_inputfilter/Mixin/FieldMixin.py @@ -0,0 +1,222 @@ +from typing import Any, Dict, List, Optional, Union + +from typing_extensions import final + +from flask_inputfilter.Exception import ValidationError +from flask_inputfilter.Filter import BaseFilter +from flask_inputfilter.Mixin import BaseMixin +from flask_inputfilter.Model import ExternalApiConfig, FieldModel +from flask_inputfilter.Validator import BaseValidator + + +class FieldMixin(BaseMixin): + @final + def add( + self, + name: str, + required: bool = False, + default: Any = None, + fallback: Any = None, + filters: Optional[List[BaseFilter]] = None, + validators: Optional[List[BaseValidator]] = None, + steps: Optional[List[Union[BaseFilter, BaseValidator]]] = None, + external_api: Optional[ExternalApiConfig] = None, + copy: Optional[str] = None, + ) -> None: + """ + Add the field to the input filter. + + Args: + name: The name of the field. + required: Whether the field is required. + default: The default value of the field. + fallback: The fallback value of the field, if validations fails + or field None, although it is required . + filters: The filters to apply to the field value. + validators: The validators to apply to the field value. + steps: Allows to apply multiple filters and validators + in a specific order. + external_api: Configuration for an external API call. + copy: The name of the field to copy the value from. + """ + self._fields[name] = FieldModel( + required=required, + default=default, + fallback=fallback, + filters=filters or [], + validators=validators or [], + steps=steps or [], + external_api=external_api, + copy=copy, + ) + + @final + def has(self, field_name: str) -> bool: + """ + This method checks the existence of a specific field within the + input filter values, identified by its field name. It does not return a + value, serving purely as a validation or existence check mechanism. + + Args: + field_name (str): The name of the field to check for existence. + + Returns: + bool: True if the field exists in the input filter, + otherwise False. + """ + return field_name in self._fields + + @final + def getInput(self, field_name: str) -> Optional[FieldModel]: + """ + Represents a method to retrieve a field by its name. + + This method allows fetching the configuration of a specific field + within the object, using its name as a string. It ensures + compatibility with various field names and provides a generic + return type to accommodate different data types for the fields. + + Args: + field_name: A string representing the name of the field who + needs to be retrieved. + + Returns: + Optional[FieldModel]: The field corresponding to the + specified name. + """ + return self._fields.get(field_name) + + @final + def getInputs(self) -> Dict[str, FieldModel]: + """ + Retrieve the dictionary of input fields associated with the object. + + Returns: + Dict[str, FieldModel]: Dictionary containing field names as + keys and their corresponding FieldModel instances as values + """ + return self._fields + + @final + def remove(self, field_name: str) -> Any: + """ + Removes the specified field from the instance or collection. + + This method is used to delete a specific field identified by + its name. It ensures the designated field is removed entirely + from the relevant data structure. No value is returned upon + successful execution. + + Args: + field_name: The name of the field to be removed. + + Returns: + Any: The value of the removed field, if any. + """ + return self._fields.pop(field_name, None) + + @final + def count(self) -> int: + """ + Counts the total number of elements in the collection. + + This method returns the total count of elements stored within the + underlying data structure, providing a quick way to ascertain the + size or number of entries available. + + Returns: + int: The total number of elements in the collection. + """ + return len(self._fields) + + @final + def replace( + self, + name: str, + required: bool = False, + default: Any = None, + fallback: Any = None, + filters: Optional[List[BaseFilter]] = None, + validators: Optional[List[BaseValidator]] = None, + steps: Optional[List[Union[BaseFilter, BaseValidator]]] = None, + external_api: Optional[ExternalApiConfig] = None, + copy: Optional[str] = None, + ) -> None: + """ + Replaces a field in the input filter. + + Args: + name: The name of the field. + required: Whether the field is required. + default: The default value of the field. + fallback: The fallback value of the field, if validations fails + or field None, although it is required . + filters: The filters to apply to the field value. + validators: The validators to apply to the field value. + steps: Allows to apply multiple filters and validators + in a specific order. + external_api: Configuration for an external API call. + copy: The name of the field to copy the value from. + """ + self._fields[name] = FieldModel( + required=required, + default=default, + fallback=fallback, + filters=filters or [], + validators=validators or [], + steps=steps or [], + external_api=external_api, + copy=copy, + ) + + @staticmethod + def __applySteps( + steps: List[Union[BaseFilter, BaseValidator]], + fallback: Any, + value: Any, + ) -> Any: + """ + Apply multiple filters and validators in a specific order. + """ + if value is None: + return + + try: + for step in steps: + if isinstance(step, BaseFilter): + value = step.apply(value) + elif isinstance(step, BaseValidator): + step.validate(value) + except ValidationError: + if fallback is None: + raise + return fallback + return value + + @staticmethod + def __checkForRequired( + field_name: str, + required: bool, + default: Any, + fallback: Any, + value: Any, + ) -> Any: + """ + Determine the value of the field, considering the required and + fallback attributes. + + If the field is not required and no value is provided, the default + value is returned. If the field is required and no value is provided, + the fallback value is returned. If no of the above conditions are met, + a ValidationError is raised. + """ + if value is not None: + return value + + if not required: + return default + + if fallback is not None: + return fallback + + raise ValidationError(f"Field '{field_name}' is required.") diff --git a/flask_inputfilter/Mixin/FilterMixin.py b/flask_inputfilter/Mixin/FilterMixin.py new file mode 100644 index 0000000..f276049 --- /dev/null +++ b/flask_inputfilter/Mixin/FilterMixin.py @@ -0,0 +1,44 @@ +from typing import Any, List + +from typing_extensions import final + +from flask_inputfilter.Filter import BaseFilter +from flask_inputfilter.Mixin import BaseMixin + + +class FilterMixin(BaseMixin): + @final + def addGlobalFilter(self, filter: BaseFilter) -> None: + """ + Add a global filter to be applied to all fields. + + Args: + filter: The filter to add. + """ + self._global_filters.append(filter) + + @final + def getGlobalFilters(self) -> List[BaseFilter]: + """ + Retrieve all global filters associated with this InputFilter instance. + + This method returns a list of BaseFilter instances that have been + added as global filters. These filters are applied universally to + all fields during data processing. + + Returns: + List[BaseFilter]: A list of global filters. + """ + return self._global_filters + + def __applyFilters(self, filters: List[BaseFilter], value: Any) -> Any: + """ + Apply filters to the field value. + """ + if value is None: + return value + + for filter_ in self._global_filters + filters: + value = filter_.apply(value) + + return value diff --git a/flask_inputfilter/Mixin/ModelMixin.py b/flask_inputfilter/Mixin/ModelMixin.py new file mode 100644 index 0000000..1dc8e43 --- /dev/null +++ b/flask_inputfilter/Mixin/ModelMixin.py @@ -0,0 +1,99 @@ +from typing import TYPE_CHECKING, Any, Dict, Type, TypeVar, Union + +from typing_extensions import final + +from flask_inputfilter.Mixin import BaseMixin + +if TYPE_CHECKING: + from flask_inputfilter import InputFilter + +T = TypeVar("T") + + +class ModelMixin(BaseMixin): + @final + def clear(self) -> None: + """ + Resets all fields of the InputFilter instance to + their initial empty state. + + This method clears the internal storage of fields, + conditions, filters, validators, and data, effectively + resetting the object as if it were newly initialized. + """ + self._fields.clear() + self._conditions.clear() + self._global_filters.clear() + self._global_validators.clear() + self._data.clear() + self._validated_data.clear() + self._errors.clear() + + @final + def merge(self, other: "InputFilter") -> None: + """ + Merges another InputFilter instance intelligently into the current + instance. + + - Fields with the same name are merged recursively if possible, + otherwise overwritten. + - Conditions, are combined and deduplicated. + - Global filters and validators are merged without duplicates. + + Args: + other (InputFilter): The InputFilter instance to merge. + """ + from flask_inputfilter import InputFilter + + if not isinstance(other, InputFilter): + raise TypeError( + "Can only merge with another InputFilter instance." + ) + + for key, new_field in other.getInputs().items(): + self._fields[key] = new_field + + self._conditions = self._conditions + other._conditions + + for filter in other._global_filters: + existing_types = [type(v) for v in self._global_filters] + if type(filter) in existing_types: + index = existing_types.index(type(filter)) + self._global_filters[index] = filter + + else: + self._global_filters.append(filter) + + for validator in other._global_validators: + existing_types = [type(v) for v in self._global_validators] + if type(validator) in existing_types: + index = existing_types.index(type(validator)) + self._global_validators[index] = validator + + else: + self._global_validators.append(validator) + + @final + def setModel(self, model_class: Type[T]) -> None: + """ + Set the model class for serialization. + + Args: + model_class: The class to use for serialization. + """ + self._model_class = model_class + + @final + def serialize(self) -> Union[Dict[str, Any], T]: + """ + Serialize the validated data. If a model class is set, + returns an instance of that class, otherwise returns the + raw validated data. + + Returns: + Union[Dict[str, Any], T]: The serialized data. + """ + if self._model_class is None: + return self._validated_data + + return self._model_class(**self._validated_data) diff --git a/flask_inputfilter/Mixin/ValidationMixin.py b/flask_inputfilter/Mixin/ValidationMixin.py new file mode 100644 index 0000000..6c06904 --- /dev/null +++ b/flask_inputfilter/Mixin/ValidationMixin.py @@ -0,0 +1,52 @@ +from typing import Any, List + +from typing_extensions import final + +from flask_inputfilter.Exception import ValidationError +from flask_inputfilter.Mixin import BaseMixin +from flask_inputfilter.Validator import BaseValidator + + +class ValidationMixin(BaseMixin): + @final + def addGlobalValidator(self, validator: BaseValidator) -> None: + """ + Add a global validator to be applied to all fields. + + Args: + validator: The validator to add. + """ + self._global_validators.append(validator) + + @final + def getGlobalValidators(self) -> List[BaseValidator]: + """ + Retrieve all global validators associated with this + InputFilter instance. + + This method returns a list of BaseValidator instances that have been + added as global validators. These validators are applied universally + to all fields during validation. + + Returns: + List[BaseValidator]: A list of global validators. + """ + return self._global_validators + + def __validateField( + self, validators: List[BaseValidator], fallback: Any, value: Any + ) -> None: + """ + Validate the field value. + """ + if value is None: + return + + try: + for validator in self._global_validators + validators: + validator.validate(value) + except ValidationError: + if fallback is None: + raise + + return fallback diff --git a/flask_inputfilter/Mixin/__init__.py b/flask_inputfilter/Mixin/__init__.py new file mode 100644 index 0000000..1a87170 --- /dev/null +++ b/flask_inputfilter/Mixin/__init__.py @@ -0,0 +1,10 @@ +from flask_inputfilter.Mixin.BaseMixin import BaseMixin + +from .ConditionMixin import ConditionMixin +from .DataMixin import DataMixin +from .ErrorHandlingMixin import ErrorHandlingMixin +from .ExternalApiMixin import ExternalApiMixin +from .FieldMixin import FieldMixin +from .FilterMixin import FilterMixin +from .ModelMixin import ModelMixin +from .ValidationMixin import ValidationMixin diff --git a/flask_inputfilter/Validator/IsArrayValidator.py b/flask_inputfilter/Validator/IsArrayValidator.py index c0deb65..acf9eff 100644 --- a/flask_inputfilter/Validator/IsArrayValidator.py +++ b/flask_inputfilter/Validator/IsArrayValidator.py @@ -9,7 +9,7 @@ class IsArrayValidator(BaseValidator): Validator that checks if a value is an array. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = error_message diff --git a/flask_inputfilter/Validator/IsBase64ImageValidator.py b/flask_inputfilter/Validator/IsBase64ImageValidator.py index 59da0bb..c492c96 100644 --- a/flask_inputfilter/Validator/IsBase64ImageValidator.py +++ b/flask_inputfilter/Validator/IsBase64ImageValidator.py @@ -13,7 +13,7 @@ class IsBase64ImageValidator(BaseValidator): Validator that checks if a Base64 string is a valid image. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__( self, diff --git a/flask_inputfilter/Validator/IsBooleanValidator.py b/flask_inputfilter/Validator/IsBooleanValidator.py index d9f3a0f..939b4b3 100644 --- a/flask_inputfilter/Validator/IsBooleanValidator.py +++ b/flask_inputfilter/Validator/IsBooleanValidator.py @@ -9,7 +9,7 @@ class IsBooleanValidator(BaseValidator): Validator that checks if a value is a bool. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = error_message diff --git a/flask_inputfilter/Validator/IsDateTimeValidator.py b/flask_inputfilter/Validator/IsDateTimeValidator.py new file mode 100644 index 0000000..3d33bcc --- /dev/null +++ b/flask_inputfilter/Validator/IsDateTimeValidator.py @@ -0,0 +1,22 @@ +from datetime import datetime +from typing import Any, Optional + +from flask_inputfilter.Exception import ValidationError +from flask_inputfilter.Validator import BaseValidator + + +class IsDateTimeValidator(BaseValidator): + """ + Validator that checks if a value is a date. + """ + + __slots__ = ("error_message",) + + def __init__(self, error_message: Optional[str] = None) -> None: + self.error_message = error_message + + def validate(self, value: Any) -> None: + if not isinstance(value, datetime): + raise ValidationError( + self.error_message or f"Value '{value}' is not an datetime." + ) diff --git a/flask_inputfilter/Validator/IsDateValidator.py b/flask_inputfilter/Validator/IsDateValidator.py new file mode 100644 index 0000000..4d407c1 --- /dev/null +++ b/flask_inputfilter/Validator/IsDateValidator.py @@ -0,0 +1,22 @@ +from datetime import date +from typing import Any, Optional + +from flask_inputfilter.Exception import ValidationError +from flask_inputfilter.Validator import BaseValidator + + +class IsDateValidator(BaseValidator): + """ + Validator that checks if a value is a date. + """ + + __slots__ = ("error_message",) + + def __init__(self, error_message: Optional[str] = None) -> None: + self.error_message = error_message + + def validate(self, value: Any) -> None: + if not isinstance(value, date): + raise ValidationError( + self.error_message or f"Value '{value}' is not an date." + ) diff --git a/flask_inputfilter/Validator/IsFloatValidator.py b/flask_inputfilter/Validator/IsFloatValidator.py index 87e71fe..421e7b8 100644 --- a/flask_inputfilter/Validator/IsFloatValidator.py +++ b/flask_inputfilter/Validator/IsFloatValidator.py @@ -9,7 +9,7 @@ class IsFloatValidator(BaseValidator): Validator that checks if a value is a float. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = error_message diff --git a/flask_inputfilter/Validator/IsFutureDateValidator.py b/flask_inputfilter/Validator/IsFutureDateValidator.py index c149428..720eeea 100644 --- a/flask_inputfilter/Validator/IsFutureDateValidator.py +++ b/flask_inputfilter/Validator/IsFutureDateValidator.py @@ -10,7 +10,7 @@ class IsFutureDateValidator(BaseValidator): Validator that checks if a date is in the future. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = error_message diff --git a/flask_inputfilter/Validator/IsHexadecimalValidator.py b/flask_inputfilter/Validator/IsHexadecimalValidator.py index 4f3f513..ed09070 100644 --- a/flask_inputfilter/Validator/IsHexadecimalValidator.py +++ b/flask_inputfilter/Validator/IsHexadecimalValidator.py @@ -9,7 +9,7 @@ class IsHexadecimalValidator(BaseValidator): Validator that checks if a value is a valid hexadecimal string. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__( self, diff --git a/flask_inputfilter/Validator/IsHorizontalImageValidator.py b/flask_inputfilter/Validator/IsHorizontalImageValidator.py index e252dc4..0764ad9 100644 --- a/flask_inputfilter/Validator/IsHorizontalImageValidator.py +++ b/flask_inputfilter/Validator/IsHorizontalImageValidator.py @@ -18,7 +18,7 @@ class IsHorizontalImageValidator(BaseValidator): initialization of the validator. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message=None): self.error_message = ( diff --git a/flask_inputfilter/Validator/IsHtmlValidator.py b/flask_inputfilter/Validator/IsHtmlValidator.py index 4144088..180cb63 100644 --- a/flask_inputfilter/Validator/IsHtmlValidator.py +++ b/flask_inputfilter/Validator/IsHtmlValidator.py @@ -10,7 +10,7 @@ class IsHtmlValidator(BaseValidator): Validator that checks if a value contains valid HTML. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = ( diff --git a/flask_inputfilter/Validator/IsIntegerValidator.py b/flask_inputfilter/Validator/IsIntegerValidator.py index c92b02f..e074abe 100644 --- a/flask_inputfilter/Validator/IsIntegerValidator.py +++ b/flask_inputfilter/Validator/IsIntegerValidator.py @@ -9,7 +9,7 @@ class IsIntegerValidator(BaseValidator): Validator that checks if a value is an integer. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = error_message diff --git a/flask_inputfilter/Validator/IsJsonValidator.py b/flask_inputfilter/Validator/IsJsonValidator.py index 75d6cc6..fcbc237 100644 --- a/flask_inputfilter/Validator/IsJsonValidator.py +++ b/flask_inputfilter/Validator/IsJsonValidator.py @@ -10,7 +10,7 @@ class IsJsonValidator(BaseValidator): Validator that checks if a value is a valid JSON string. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = error_message diff --git a/flask_inputfilter/Validator/IsLowercaseValidator.py b/flask_inputfilter/Validator/IsLowercaseValidator.py index 0cdb00e..bb8daa1 100644 --- a/flask_inputfilter/Validator/IsLowercaseValidator.py +++ b/flask_inputfilter/Validator/IsLowercaseValidator.py @@ -9,7 +9,7 @@ class IsLowercaseValidator(BaseValidator): Validator that checks if a value is entirely lowercase. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = ( diff --git a/flask_inputfilter/Validator/IsMacAddressValidator.py b/flask_inputfilter/Validator/IsMacAddressValidator.py index 78e3ff5..435a61f 100644 --- a/flask_inputfilter/Validator/IsMacAddressValidator.py +++ b/flask_inputfilter/Validator/IsMacAddressValidator.py @@ -13,7 +13,7 @@ class IsMacAddressValidator(BaseValidator): Validator that checks if a value is a valid MAC address. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = ( diff --git a/flask_inputfilter/Validator/IsPastDateValidator.py b/flask_inputfilter/Validator/IsPastDateValidator.py index 61f7892..2cf0a78 100644 --- a/flask_inputfilter/Validator/IsPastDateValidator.py +++ b/flask_inputfilter/Validator/IsPastDateValidator.py @@ -10,7 +10,7 @@ class IsPastDateValidator(BaseValidator): Validator that checks if a date is in the past. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = error_message diff --git a/flask_inputfilter/Validator/IsPortValidator.py b/flask_inputfilter/Validator/IsPortValidator.py index 4e4801e..e69e613 100644 --- a/flask_inputfilter/Validator/IsPortValidator.py +++ b/flask_inputfilter/Validator/IsPortValidator.py @@ -9,7 +9,7 @@ class IsPortValidator(BaseValidator): Validator that checks if a value is a valid network port (1-65535). """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = ( diff --git a/flask_inputfilter/Validator/IsRgbColorValidator.py b/flask_inputfilter/Validator/IsRgbColorValidator.py index e3173e5..36ed3e3 100644 --- a/flask_inputfilter/Validator/IsRgbColorValidator.py +++ b/flask_inputfilter/Validator/IsRgbColorValidator.py @@ -14,7 +14,7 @@ class IsRgbColorValidator(BaseValidator): color string (e.g., 'rgb(255, 0, 0)'). """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = error_message or "Value is not a valid RGB color." diff --git a/flask_inputfilter/Validator/IsStringValidator.py b/flask_inputfilter/Validator/IsStringValidator.py index 4a38fa6..db76c15 100644 --- a/flask_inputfilter/Validator/IsStringValidator.py +++ b/flask_inputfilter/Validator/IsStringValidator.py @@ -9,7 +9,7 @@ class IsStringValidator(BaseValidator): Validator that checks if a value is a string. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = error_message diff --git a/flask_inputfilter/Validator/IsTypedDictValidator.py b/flask_inputfilter/Validator/IsTypedDictValidator.py index 82a2af6..774a57b 100644 --- a/flask_inputfilter/Validator/IsTypedDictValidator.py +++ b/flask_inputfilter/Validator/IsTypedDictValidator.py @@ -11,7 +11,7 @@ class IsTypedDictValidator(BaseValidator): Validator that checks if a value is a TypedDict. """ - __slots__ = "typed_dict_type" "error_message" + __slots__ = ("typed_dict_type", "error_message") def __init__( self, diff --git a/flask_inputfilter/Validator/IsUUIDValidator.py b/flask_inputfilter/Validator/IsUUIDValidator.py index a2d6cc4..94eea60 100644 --- a/flask_inputfilter/Validator/IsUUIDValidator.py +++ b/flask_inputfilter/Validator/IsUUIDValidator.py @@ -10,7 +10,7 @@ class IsUUIDValidator(BaseValidator): Validator that checks if a value is a valid UUID string. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = error_message diff --git a/flask_inputfilter/Validator/IsUppercaseValidator.py b/flask_inputfilter/Validator/IsUppercaseValidator.py index 34003b4..9bbe7ca 100644 --- a/flask_inputfilter/Validator/IsUppercaseValidator.py +++ b/flask_inputfilter/Validator/IsUppercaseValidator.py @@ -9,7 +9,7 @@ class IsUppercaseValidator(BaseValidator): Validator that checks if a value is entirely uppercase. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = ( diff --git a/flask_inputfilter/Validator/IsUrlValidator.py b/flask_inputfilter/Validator/IsUrlValidator.py index 939b4c6..c91319b 100644 --- a/flask_inputfilter/Validator/IsUrlValidator.py +++ b/flask_inputfilter/Validator/IsUrlValidator.py @@ -10,7 +10,7 @@ class IsUrlValidator(BaseValidator): Validator that checks if a value is a valid URL. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = error_message or "Value is not a valid URL." diff --git a/flask_inputfilter/Validator/IsVerticalImageValidator.py b/flask_inputfilter/Validator/IsVerticalImageValidator.py index 353ac34..bd7b0bf 100644 --- a/flask_inputfilter/Validator/IsVerticalImageValidator.py +++ b/flask_inputfilter/Validator/IsVerticalImageValidator.py @@ -19,7 +19,7 @@ class IsVerticalImageValidator(BaseValidator): error if the validation fails. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message=None): self.error_message = ( diff --git a/flask_inputfilter/Validator/IsWeekdayValidator.py b/flask_inputfilter/Validator/IsWeekdayValidator.py index 08eda55..d10af8e 100644 --- a/flask_inputfilter/Validator/IsWeekdayValidator.py +++ b/flask_inputfilter/Validator/IsWeekdayValidator.py @@ -11,7 +11,7 @@ class IsWeekdayValidator(BaseValidator): Supports datetime and ISO 8601 formatted strings. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = error_message diff --git a/flask_inputfilter/Validator/IsWeekendValidator.py b/flask_inputfilter/Validator/IsWeekendValidator.py index 891bdc6..d676152 100644 --- a/flask_inputfilter/Validator/IsWeekendValidator.py +++ b/flask_inputfilter/Validator/IsWeekendValidator.py @@ -11,7 +11,7 @@ class IsWeekendValidator(BaseValidator): Supports datetime and ISO 8601 formatted strings. """ - __slots__ = "error_message" + __slots__ = ("error_message",) def __init__(self, error_message: Optional[str] = None) -> None: self.error_message = error_message diff --git a/flask_inputfilter/Validator/__init__.py b/flask_inputfilter/Validator/__init__.py index ba3b9d9..06700bc 100644 --- a/flask_inputfilter/Validator/__init__.py +++ b/flask_inputfilter/Validator/__init__.py @@ -17,6 +17,8 @@ from .IsBase64ImageValidator import IsBase64ImageValidator from .IsBooleanValidator import IsBooleanValidator from .IsDataclassValidator import IsDataclassValidator +from .IsDateTimeValidator import IsDateTimeValidator +from .IsDateValidator import IsDateValidator from .IsFloatValidator import IsFloatValidator from .IsFutureDateValidator import IsFutureDateValidator from .IsHexadecimalValidator import IsHexadecimalValidator diff --git a/test/test_condition.py b/test/test_condition.py index 61597bd..0f685fc 100644 --- a/test/test_condition.py +++ b/test/test_condition.py @@ -15,13 +15,13 @@ IntegerBiggerThanCondition, NOfCondition, NOfMatchesCondition, + NotEqualCondition, OneOfCondition, OneOfMatchesCondition, RequiredIfCondition, StringLongerThanCondition, TemporalOrderCondition, ) -from flask_inputfilter.Condition.NotEqualCondition import NotEqualCondition from flask_inputfilter.Exception import ValidationError diff --git a/test/test_input_filter.py b/test/test_input_filter.py index 25492e1..f776241 100644 --- a/test/test_input_filter.py +++ b/test/test_input_filter.py @@ -8,7 +8,7 @@ from flask_inputfilter.Condition import BaseCondition, ExactlyOneOfCondition from flask_inputfilter.Exception import ValidationError from flask_inputfilter.Filter import ( - SlugifyFilter, + StringSlugifyFilter, ToFloatFilter, ToIntegerFilter, ToLowerFilter, @@ -951,7 +951,7 @@ def test_copy(self) -> None: self.inputFilter.add("username") self.inputFilter.add( - "escapedUsername", copy="username", filters=[SlugifyFilter()] + "escapedUsername", copy="username", filters=[StringSlugifyFilter()] ) validated_data = self.inputFilter.validateData( diff --git a/test/test_validator.py b/test/test_validator.py index a7dce47..95a36c3 100644 --- a/test/test_validator.py +++ b/test/test_validator.py @@ -10,6 +10,8 @@ from flask_inputfilter.Exception import ValidationError from flask_inputfilter.Filter import ( Base64ImageDownscaleFilter, + ToDateFilter, + ToDateTimeFilter, ToIntegerFilter, ) from flask_inputfilter.Validator import ( @@ -29,6 +31,8 @@ IsBase64ImageValidator, IsBooleanValidator, IsDataclassValidator, + IsDateTimeValidator, + IsDateValidator, IsFloatValidator, IsFutureDateValidator, IsHexadecimalValidator, @@ -740,6 +744,56 @@ class User: with self.assertRaises(ValidationError): self.inputFilter.validateData({"data": "not a dict"}) + def test_is_datetime_validator(self) -> None: + """ + Test that IsDateTimeValidator validates datetime type. + """ + + self.inputFilter.add( + "datetime", + filters=[ToDateTimeFilter()], + validators=[IsDateTimeValidator()], + ) + + self.inputFilter.validateData({"datetime": "2025-01-01 00:00:00"}) + + with self.assertRaises(ValidationError): + self.inputFilter.validateData({"datetime": "not a datetime"}) + + self.inputFilter.add( + "datetime2", + filters=[ToDateTimeFilter()], + validators=[ + IsDateTimeValidator(error_message="Custom error message") + ], + ) + + with self.assertRaises(ValidationError): + self.inputFilter.validateData({"datetime": 123}) + + def test_is_date_validator(self) -> None: + """ + Test that IsDateValidator validates datetime type. + """ + + self.inputFilter.add( + "date", filters=[ToDateFilter()], validators=[IsDateValidator()] + ) + + self.inputFilter.validateData({"date": "2025-01-01"}) + + with self.assertRaises(ValidationError): + self.inputFilter.validateData({"date": "not a date"}) + + self.inputFilter.add( + "date2", + filters=[ToDateFilter()], + validators=[IsDateValidator(error_message="Custom error message")], + ) + + with self.assertRaises(ValidationError): + self.inputFilter.validateData({"date": 123}) + def test_is_float_validator(self) -> None: """ Test that IsFloatValidator validates float type.