diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 5f8b4af..cba5eb5 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -3,6 +3,22 @@ Changelog All notable changes to this project will be documented in this file. +[0.6.2] - 2025-07-03 +-------------------- + +Added +^^^^^ +- Added IsImageValidator, ToBase64ImageFilter and ToImageFilter. + + +[0.6.1] - 2025-07-02 +-------------------- + +Changed +^^^^^^^ +- Fixed issue with ``__init__.py`` for compiled versions. + + [0.6.0] - 2025-06-30 -------------------- diff --git a/flask_inputfilter/_input_filter.pyx b/flask_inputfilter/_input_filter.pyx index 91e963f..127e96b 100644 --- a/flask_inputfilter/_input_filter.pyx +++ b/flask_inputfilter/_input_filter.pyx @@ -184,7 +184,11 @@ cdef class InputFilter: dict[str, Any] validated_data validated_data, errors = DataMixin.validate_with_conditions( - self.fields, data, self.global_filters, self.global_validators, self.conditions + self.fields, + data, + self.global_filters, + self.global_validators, + self.conditions, ) if errors: diff --git a/flask_inputfilter/conditions/base_condition.py b/flask_inputfilter/conditions/base_condition.py index c6ad10e..4a4ba7f 100644 --- a/flask_inputfilter/conditions/base_condition.py +++ b/flask_inputfilter/conditions/base_condition.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import warnings warnings.warn( @@ -7,5 +9,3 @@ DeprecationWarning, stacklevel=2, ) - -from flask_inputfilter.models import BaseCondition diff --git a/flask_inputfilter/filters/__init__.py b/flask_inputfilter/filters/__init__.py index 43dbbe8..ae55b3b 100644 --- a/flask_inputfilter/filters/__init__.py +++ b/flask_inputfilter/filters/__init__.py @@ -9,6 +9,7 @@ from .string_slugify_filter import StringSlugifyFilter from .string_trim_filter import StringTrimFilter from .to_alpha_numeric_filter import ToAlphaNumericFilter +from .to_base64_image_filter import ToBase64ImageFilter from .to_boolean_filter import ToBooleanFilter from .to_camel_case_filter import ToCamelCaseFilter from .to_dataclass_filter import ToDataclassFilter @@ -17,6 +18,7 @@ from .to_digits_filter import ToDigitsFilter from .to_enum_filter import ToEnumFilter from .to_float_filter import ToFloatFilter +from .to_image_filter import ToImageFilter from .to_integer_filter import ToIntegerFilter from .to_iso_filter import ToIsoFilter from .to_lower_filter import ToLowerFilter @@ -42,6 +44,7 @@ "StringSlugifyFilter", "StringTrimFilter", "ToAlphaNumericFilter", + "ToBase64ImageFilter", "ToBooleanFilter", "ToCamelCaseFilter", "ToDataclassFilter", @@ -50,6 +53,7 @@ "ToDigitsFilter", "ToEnumFilter", "ToFloatFilter", + "ToImageFilter", "ToIntegerFilter", "ToIsoFilter", "ToLowerFilter", diff --git a/flask_inputfilter/filters/base_filter.py b/flask_inputfilter/filters/base_filter.py index 45393e7..e4800d9 100644 --- a/flask_inputfilter/filters/base_filter.py +++ b/flask_inputfilter/filters/base_filter.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import warnings warnings.warn( @@ -7,5 +9,3 @@ DeprecationWarning, stacklevel=2, ) - -from flask_inputfilter.models import BaseFilter diff --git a/flask_inputfilter/filters/string_slugify_filter.py b/flask_inputfilter/filters/string_slugify_filter.py index d857aea..f16d33c 100644 --- a/flask_inputfilter/filters/string_slugify_filter.py +++ b/flask_inputfilter/filters/string_slugify_filter.py @@ -38,12 +38,16 @@ def apply(self, value: Any) -> Union[Optional[str], Any]: value_without_accents = "".join( char - for char in unicodedata.normalize(UnicodeFormEnum.NFD.value, value) + for char in unicodedata.normalize( + UnicodeFormEnum.NFD.value, + value, + ) if unicodedata.category(char) != "Mn" ) value = unicodedata.normalize( - UnicodeFormEnum.NFKD.value, value_without_accents + UnicodeFormEnum.NFKD.value, + value_without_accents, ) value = value.encode("ascii", "ignore").decode("ascii") diff --git a/flask_inputfilter/filters/to_base64_image_filter.py b/flask_inputfilter/filters/to_base64_image_filter.py new file mode 100644 index 0000000..33c89b0 --- /dev/null +++ b/flask_inputfilter/filters/to_base64_image_filter.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import base64 +import io +from typing import Any, Optional + +from PIL import Image + +from flask_inputfilter.enums import ImageFormatEnum +from flask_inputfilter.models import BaseFilter + + +class ToBase64ImageFilter(BaseFilter): + """ + Converts an image to a base64 encoded string. Supports various input + formats including file paths, bytes, or PIL Image objects. + + **Parameters:** + + - **format** (*ImageFormatEnum*, default: ``ImageFormatEnum.PNG``): + The output image format for the base64 encoding. + - **quality** (*int*, default: ``85``): The image quality (1-100) for + lossy formats like JPEG. Higher values mean better quality. + + **Expected Behavior:** + + Converts the input image to a base64 encoded string: + - If input is a PIL Image object, converts it directly + - If input is a string, tries to open it as a file path + - If input is bytes, tries to open as image data + - If input is already a base64 string, validates and returns it + - Returns the original value if conversion fails + + **Example Usage:** + + .. code-block:: python + + class ImageFilter(InputFilter): + def __init__(self): + super().__init__() + + self.add('image', filters=[ + ToBase64ImageFilter(format=ImageFormatEnum.JPEG) + ]) + """ + + __slots__ = ("format", "quality") + + def __init__( + self, + format: Optional[ImageFormatEnum] = None, + quality: int = 85, + ) -> None: + self.format = format if format else ImageFormatEnum.PNG + self.quality = quality + + def apply(self, value: Any) -> Any: + if isinstance(value, Image.Image): + return self._image_to_base64(value) + + # Try to open as file path + if isinstance(value, str): + try: + with Image.open(value) as img: + return self._image_to_base64(img) + except OSError: + pass + + # Try to decode as base64 + try: + Image.open(io.BytesIO(base64.b64decode(value))).verify() + return value + except Exception: + pass + + # Try to open as raw bytes + if isinstance(value, bytes): + try: + img = Image.open(io.BytesIO(value)) + return self._image_to_base64(img) + except OSError: + pass + + return value + + def _image_to_base64(self, image: Image.Image) -> str: + """Convert a PIL Image to base64 encoded string.""" + if image.mode in ("RGBA", "P"): + image = image.convert("RGB") + + buffered = io.BytesIO() + + save_options = {"format": self.format.value} + + if self.format in (ImageFormatEnum.JPEG, ImageFormatEnum.WEBP): + save_options["quality"] = self.quality + save_options["optimize"] = True + + image.save(buffered, **save_options) + + return base64.b64encode(buffered.getvalue()).decode("ascii") diff --git a/flask_inputfilter/filters/to_image_filter.py b/flask_inputfilter/filters/to_image_filter.py new file mode 100644 index 0000000..c5d4231 --- /dev/null +++ b/flask_inputfilter/filters/to_image_filter.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import base64 +import io +from typing import Any + +from PIL import Image + +from flask_inputfilter.models import BaseFilter + + +class ToImageFilter(BaseFilter): + """ + Converts various input formats to a PIL Image object. Supports file paths, + base64 encoded strings, and bytes. + + **Expected Behavior:** + + Converts the input to a PIL Image object: + - If input is already a PIL Image object, returns it as-is + - If input is a string, tries to open it as a file path or decode as base64 + - If input is bytes, tries to open as image data + - Returns the original value if conversion fails + + **Example Usage:** + + .. code-block:: python + + class ImageFilter(InputFilter): + def __init__(self): + super().__init__() + + self.add('image', filters=[ + ToImageFilter() + ]) + """ + + __slots__ = () + + def apply(self, value: Any) -> Any: + if isinstance(value, Image.Image): + return value + + if isinstance(value, str): + # Try to open as file path + try: + return Image.open(value) + except OSError: + pass + + # Try to decode as base64 + try: + return Image.open(io.BytesIO(base64.b64decode(value))) + except Exception: + pass + + # Try to open as raw bytes + if isinstance(value, bytes): + try: + return Image.open(io.BytesIO(value)) + except OSError: + pass + + return value diff --git a/flask_inputfilter/filters/to_normalized_unicode_filter.py b/flask_inputfilter/filters/to_normalized_unicode_filter.py index 4e1012e..c133696 100644 --- a/flask_inputfilter/filters/to_normalized_unicode_filter.py +++ b/flask_inputfilter/filters/to_normalized_unicode_filter.py @@ -59,8 +59,14 @@ def apply(self, value: Any) -> Union[str, Any]: value_without_accents = "".join( char - for char in unicodedata.normalize(UnicodeFormEnum.NFD.value, value) + for char in unicodedata.normalize( + UnicodeFormEnum.NFD.value, + value, + ) if unicodedata.category(char) != "Mn" ) - return unicodedata.normalize(self.form.value, value_without_accents) + return unicodedata.normalize( + self.form.value, + value_without_accents, + ) diff --git a/flask_inputfilter/input_filter.py b/flask_inputfilter/input_filter.py index cb045ea..8e61183 100644 --- a/flask_inputfilter/input_filter.py +++ b/flask_inputfilter/input_filter.py @@ -235,7 +235,9 @@ def set_data(self, data: dict[str, Any]) -> None: data to be filtered and stored. """ self.data = DataMixin.filter_data( - data, self.fields, self.global_filters + data, + self.fields, + self.global_filters, ) def get_value(self, name: str) -> Any: diff --git a/flask_inputfilter/mixins/data_mixin/data_mixin.py b/flask_inputfilter/mixins/data_mixin/data_mixin.py index 14f50a5..2d4d680 100644 --- a/flask_inputfilter/mixins/data_mixin/data_mixin.py +++ b/flask_inputfilter/mixins/data_mixin/data_mixin.py @@ -21,7 +21,8 @@ class DataMixin: @staticmethod def has_unknown_fields( - data: dict[str, Any], fields: dict[str, FieldModel] + data: dict[str, Any], + fields: dict[str, FieldModel], ) -> bool: """ Check if data contains fields not defined in fields configuration. Uses @@ -39,11 +40,6 @@ def has_unknown_fields( if not data and fields: return True - # Use set operations for faster lookup when there are many fields - if len(fields) > LARGE_DATASET_THRESHOLD: - field_set = set(fields.keys()) - return any(field_name not in field_set for field_name in data) - # Use direct dict lookup for smaller field counts return any(field_name not in fields for field_name in data) @staticmethod @@ -120,7 +116,8 @@ def validate_with_conditions( @staticmethod def merge_input_filters( - target_filter: InputFilter, source_filter: InputFilter + target_filter: InputFilter, + source_filter: InputFilter, ) -> None: """ Efficiently merge one InputFilter into another. @@ -138,16 +135,21 @@ def merge_input_filters( # Merge global filters (avoid duplicates by type) DataMixin._merge_component_list( - target_filter.global_filters, source_filter.global_filters + target_filter.global_filters, + source_filter.global_filters, ) # Merge global validators (avoid duplicates by type) DataMixin._merge_component_list( - target_filter.global_validators, source_filter.global_validators + target_filter.global_validators, + source_filter.global_validators, ) @staticmethod - def _merge_component_list(target_list: list, source_list: list) -> None: + def _merge_component_list( + target_list: list, + source_list: list, + ) -> None: """ Helper method to merge component lists avoiding duplicates by type. diff --git a/flask_inputfilter/mixins/external_api_mixin/external_api_mixin.py b/flask_inputfilter/mixins/external_api_mixin/external_api_mixin.py index 74190cd..a093054 100644 --- a/flask_inputfilter/mixins/external_api_mixin/external_api_mixin.py +++ b/flask_inputfilter/mixins/external_api_mixin/external_api_mixin.py @@ -117,7 +117,8 @@ def call_external_api( @staticmethod def replace_placeholders( - value: str, validated_data: dict[str, Any] + value: str, + validated_data: dict[str, Any], ) -> str: """ Replace all placeholders, marked with '{{ }}' in value with the @@ -142,7 +143,8 @@ def replace_placeholders( @staticmethod def replace_placeholders_in_params( - params: dict, validated_data: dict[str, Any] + params: dict, + validated_data: dict[str, Any], ) -> dict: """ Replace all placeholders in params with the corresponding values from diff --git a/flask_inputfilter/mixins/validation_mixin/_validation_mixin.pyx b/flask_inputfilter/mixins/validation_mixin/_validation_mixin.pyx index 7884b32..eb1a7c4 100644 --- a/flask_inputfilter/mixins/validation_mixin/_validation_mixin.pyx +++ b/flask_inputfilter/mixins/validation_mixin/_validation_mixin.pyx @@ -13,7 +13,11 @@ cdef class ValidationMixin: @staticmethod @cython.exceptval(check=False) - cdef object apply_filters(list[BaseFilter] filters1, list[BaseFilter] filters2, object value): + cdef object apply_filters( + list[BaseFilter] filters1, + list[BaseFilter] filters2, + object value, + ): """ Apply filters to the field value. @@ -51,9 +55,9 @@ cdef class ValidationMixin: @staticmethod cdef object apply_steps( - list[BaseFilter | BaseValidator] steps, - object fallback, - object value + list[BaseFilter | BaseValidator] steps, + object fallback, + object value, ): """ Apply multiple filters and validators in a specific order. @@ -105,7 +109,10 @@ cdef class ValidationMixin: return value @staticmethod - cdef void check_conditions(list[BaseCondition] conditions, dict[str, Any] validated_data) except *: + cdef void check_conditions( + list[BaseCondition] conditions, + dict[str, Any] validated_data, + ) except *: """ Checks if all conditions are met. @@ -135,9 +142,9 @@ cdef class ValidationMixin: @staticmethod cdef inline object check_for_required( - str field_name, - FieldModel field_info, - object value, + str field_name, + FieldModel field_info, + object value, ): """ Determine the value of the field, considering the required and @@ -177,10 +184,10 @@ cdef class ValidationMixin: @staticmethod cdef object validate_field( - list[BaseValidator] validators1, - list[BaseValidator] validators2, - object fallback, - object value + list[BaseValidator] validators1, + list[BaseValidator] validators2, + object fallback, + object value ): """ Validate the field value. @@ -227,10 +234,10 @@ cdef class ValidationMixin: @staticmethod cdef tuple validate_fields( - dict[str, FieldModel] fields, - dict[str, Any] data, - list[BaseFilter] global_filters, - list[BaseValidator] global_validators + dict[str, FieldModel] fields, + dict[str, Any] data, + list[BaseFilter] global_filters, + list[BaseValidator] global_validators ): """ Validate multiple fields based on their configurations. @@ -317,10 +324,10 @@ cdef class ValidationMixin: @staticmethod cdef inline object get_field_value( - str field_name, - FieldModel field_info, - dict[str, Any] data, - dict[str, Any] validated_data + str field_name, + FieldModel field_info, + dict[str, Any] data, + dict[str, Any] validated_data ): """ Retrieve the value of a field based on its configuration. diff --git a/flask_inputfilter/mixins/validation_mixin/validation_mixin.py b/flask_inputfilter/mixins/validation_mixin/validation_mixin.py index 46dcf17..a454ae6 100644 --- a/flask_inputfilter/mixins/validation_mixin/validation_mixin.py +++ b/flask_inputfilter/mixins/validation_mixin/validation_mixin.py @@ -15,7 +15,9 @@ class ValidationMixin: @staticmethod def apply_filters( - filters1: list[BaseFilter], filters2: list[BaseFilter], value: Any + filters1: list[BaseFilter], + filters2: list[BaseFilter], + value: Any, ) -> Any: """ Apply filters to the field value. @@ -132,7 +134,8 @@ def apply_steps( @staticmethod def check_conditions( - conditions: list[BaseCondition], validated_data: dict[str, Any] + conditions: list[BaseCondition], + validated_data: dict[str, Any], ) -> None: """ Checks if all conditions are met. diff --git a/flask_inputfilter/validators/__init__.py b/flask_inputfilter/validators/__init__.py index 931f60a..3afcfaa 100644 --- a/flask_inputfilter/validators/__init__.py +++ b/flask_inputfilter/validators/__init__.py @@ -24,6 +24,7 @@ from .is_hexadecimal_validator import IsHexadecimalValidator from .is_horizontal_image_validator import IsHorizontalImageValidator from .is_html_validator import IsHtmlValidator +from .is_image_validator import IsImageValidator from .is_instance_validator import IsInstanceValidator from .is_integer_validator import IsIntegerValidator from .is_json_validator import IsJsonValidator @@ -72,6 +73,7 @@ "IsHexadecimalValidator", "IsHorizontalImageValidator", "IsHtmlValidator", + "IsImageValidator", "IsInstanceValidator", "IsIntegerValidator", "IsJsonValidator", diff --git a/flask_inputfilter/validators/base_validator.py b/flask_inputfilter/validators/base_validator.py index 398315b..b6590e1 100644 --- a/flask_inputfilter/validators/base_validator.py +++ b/flask_inputfilter/validators/base_validator.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import warnings warnings.warn( @@ -7,5 +9,3 @@ DeprecationWarning, stacklevel=2, ) - -from flask_inputfilter.models import BaseValidator diff --git a/flask_inputfilter/validators/is_image_validator.py b/flask_inputfilter/validators/is_image_validator.py new file mode 100644 index 0000000..79224c2 --- /dev/null +++ b/flask_inputfilter/validators/is_image_validator.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import base64 +import binascii +import io +from typing import Any, Optional + +from PIL import Image + +from flask_inputfilter.exceptions import ValidationError +from flask_inputfilter.models import BaseValidator + + +class IsImageValidator(BaseValidator): + """ + Validates that the provided value is a valid image. Supports various input + formats including file paths, base64 encoded strings, bytes, or PIL Image + objects. + + **Parameters:** + + - **error_message** (*Optional[str]*): Custom error message if + validation fails. + + **Expected Behavior:** + + Attempts to validate the input as an image by: + - If input is a PIL Image object, it's considered valid + - If input is a string, tries to open it as a file path or decode as base64 + - If input is bytes, tries to open as image data + - Raises a ``ValidationError`` if the input cannot be processed as an image + + **Example Usage:** + + .. code-block:: python + + class ImageInputFilter(InputFilter): + def __init__(self): + super().__init__() + + self.add('image', validators=[ + IsImageValidator() + ]) + """ + + __slots__ = ("error_message",) + + def __init__( + self, + error_message: Optional[str] = None, + ) -> None: + self.error_message = error_message + + def validate(self, value: Any) -> None: + if isinstance(value, Image.Image): + return + + if isinstance(value, str): + try: + with Image.open(value) as img: + img.verify() + return + except OSError: + pass + + try: + Image.open(io.BytesIO(base64.b64decode(value))).verify() + return + except (binascii.Error, OSError): + pass + + if isinstance(value, bytes): + try: + Image.open(io.BytesIO(value)).verify() + return + except OSError: + pass + + raise ValidationError( + self.error_message or "Value is not a valid image." + ) diff --git a/pyproject.toml b/pyproject.toml index f0c1208..690f8c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "flask_inputfilter" -version = "0.6.1" +version = "0.6.2" description = "A library to easily filter and validate input data in Flask applications" readme = "README.md" keywords = [ @@ -141,9 +141,15 @@ pyupgrade = [ true ] +[tool.ruff.lint.isort] +force-single-line = false +split-on-trailing-comma = true + [tool.ruff.format] quote-style = "double" indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "lf" [tool.docformatter] wrap-summaries = 79 diff --git a/setup.py b/setup.py index d554f2a..a8485fd 100644 --- a/setup.py +++ b/setup.py @@ -10,9 +10,7 @@ pyx_files = Path("flask_inputfilter").rglob("*.pyx") - pyx_modules = [ - ".".join(path.with_suffix("").parts) for path in pyx_files - ] + pyx_modules = [".".join(path.with_suffix("").parts) for path in pyx_files] ext_modules = cythonize( module_list=[ diff --git a/tests/filters/test_to_base64_image_filter.py b/tests/filters/test_to_base64_image_filter.py new file mode 100644 index 0000000..56910c9 --- /dev/null +++ b/tests/filters/test_to_base64_image_filter.py @@ -0,0 +1,101 @@ +import base64 +import io +import unittest + +from flask_inputfilter import InputFilter +from flask_inputfilter.enums import ImageFormatEnum +from flask_inputfilter.filters import ToBase64ImageFilter +from PIL import Image + + +class TestToBase64ImageFilter(unittest.TestCase): + def setUp(self) -> None: + """Set up a new InputFilter instance before each test.""" + self.input_filter = InputFilter() + + def test_pil_image_to_base64(self) -> None: + """Should convert PIL Image to base64 string.""" + self.input_filter.add("image", filters=[ToBase64ImageFilter()]) + img = Image.new("RGB", (100, 100), color="red") + validated_data = self.input_filter.validate_data({"image": img}) + + # Verify it's a base64 string + result = validated_data["image"] + self.assertIsInstance(result, str) + + # Verify it can be decoded back to an image + decoded_img = Image.open(io.BytesIO(base64.b64decode(result))) + self.assertEqual(decoded_img.size, (100, 100)) + + def test_bytes_to_base64(self) -> None: + """Should convert image bytes to base64 string.""" + self.input_filter.add("image", filters=[ToBase64ImageFilter()]) + img = Image.new("RGB", (50, 50), color="blue") + buffer = io.BytesIO() + img.save(buffer, format="PNG") + image_bytes = buffer.getvalue() + + validated_data = self.input_filter.validate_data({"image": image_bytes}) + + # Verify it's a base64 string + result = validated_data["image"] + self.assertIsInstance(result, str) + + # Verify it can be decoded back to an image + decoded_img = Image.open(io.BytesIO(base64.b64decode(result))) + self.assertEqual(decoded_img.size, (50, 50)) + + def test_existing_base64_unchanged(self) -> None: + """Should return existing base64 strings unchanged.""" + self.input_filter.add("image", filters=[ToBase64ImageFilter()]) + with open("tests/data/base64_image.txt") as file: + original_base64 = file.read() + + validated_data = self.input_filter.validate_data({"image": original_base64}) + self.assertEqual(validated_data["image"], original_base64) + + def test_jpeg_format(self) -> None: + """Should convert to JPEG format when specified.""" + self.input_filter.add( + "image", + filters=[ToBase64ImageFilter(format=ImageFormatEnum.JPEG)] + ) + img = Image.new("RGB", (100, 100), color="green") + validated_data = self.input_filter.validate_data({"image": img}) + + # Verify it's a base64 string and can be decoded as JPEG + result = validated_data["image"] + decoded_img = Image.open(io.BytesIO(base64.b64decode(result))) + self.assertEqual(decoded_img.format, "JPEG") + + def test_non_image_input_unchanged(self) -> None: + """Should return non-image inputs unchanged.""" + self.input_filter.add("image", filters=[ToBase64ImageFilter()]) + + # Test with string + validated_data = self.input_filter.validate_data({"image": "not_an_image"}) + self.assertEqual(validated_data["image"], "not_an_image") + + # Test with number + validated_data = self.input_filter.validate_data({"image": 123}) + self.assertEqual(validated_data["image"], 123) + + def test_quality_parameter(self) -> None: + """Should use quality parameter for JPEG images.""" + self.input_filter.add( + "image", + filters=[ToBase64ImageFilter( + format=ImageFormatEnum.JPEG, + quality=50 + )] + ) + img = Image.new("RGB", (100, 100), color="red") + validated_data = self.input_filter.validate_data({"image": img}) + + # Verify it's a base64 string + result = validated_data["image"] + self.assertIsInstance(result, str) + + # Verify it can be decoded back to an image + decoded_img = Image.open(io.BytesIO(base64.b64decode(result))) + self.assertEqual(decoded_img.format, "JPEG") \ No newline at end of file diff --git a/tests/filters/test_to_image_filter.py b/tests/filters/test_to_image_filter.py new file mode 100644 index 0000000..b48e2c9 --- /dev/null +++ b/tests/filters/test_to_image_filter.py @@ -0,0 +1,94 @@ +import base64 +import io +import unittest + +from flask_inputfilter import InputFilter +from flask_inputfilter.filters import ToImageFilter +from PIL import Image + + +class TestToImageFilter(unittest.TestCase): + def setUp(self) -> None: + """Set up a new InputFilter instance before each test.""" + self.input_filter = InputFilter() + + def test_pil_image_unchanged(self) -> None: + """Should return PIL Image objects unchanged.""" + self.input_filter.add("image", filters=[ToImageFilter()]) + img = Image.new("RGB", (100, 100), color="red") + validated_data = self.input_filter.validate_data({"image": img}) + + result = validated_data["image"] + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, (100, 100)) + + def test_base64_to_image(self) -> None: + """Should convert base64 strings to PIL Image objects.""" + self.input_filter.add("image", filters=[ToImageFilter()]) + with open("tests/data/base64_image.txt") as file: + base64_string = file.read() + + validated_data = self.input_filter.validate_data({"image": base64_string}) + + result = validated_data["image"] + self.assertIsInstance(result, Image.Image) + self.assertTrue(hasattr(result, 'size')) + + def test_bytes_to_image(self) -> None: + """Should convert image bytes to PIL Image objects.""" + self.input_filter.add("image", filters=[ToImageFilter()]) + + # Create image bytes + img = Image.new("RGB", (50, 50), color="blue") + buffer = io.BytesIO() + img.save(buffer, format="PNG") + image_bytes = buffer.getvalue() + + validated_data = self.input_filter.validate_data({"image": image_bytes}) + + result = validated_data["image"] + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, (50, 50)) + + def test_invalid_string_unchanged(self) -> None: + """Should return invalid strings unchanged.""" + self.input_filter.add("image", filters=[ToImageFilter()]) + + validated_data = self.input_filter.validate_data({"image": "not_an_image"}) + self.assertEqual(validated_data["image"], "not_an_image") + + def test_invalid_bytes_unchanged(self) -> None: + """Should return invalid bytes unchanged.""" + self.input_filter.add("image", filters=[ToImageFilter()]) + + validated_data = self.input_filter.validate_data({"image": b"not_an_image"}) + self.assertEqual(validated_data["image"], b"not_an_image") + + def test_non_image_input_unchanged(self) -> None: + """Should return non-image inputs unchanged.""" + self.input_filter.add("image", filters=[ToImageFilter()]) + + # Test with number + validated_data = self.input_filter.validate_data({"image": 123}) + self.assertEqual(validated_data["image"], 123) + + # Test with list + validated_data = self.input_filter.validate_data({"image": [1, 2, 3]}) + self.assertEqual(validated_data["image"], [1, 2, 3]) + + def test_preserve_image_properties(self) -> None: + """Should preserve image properties when converting.""" + self.input_filter.add("image", filters=[ToImageFilter()]) + + # Create a colored image + original_img = Image.new("RGB", (200, 150), color="green") + buffer = io.BytesIO() + original_img.save(buffer, format="PNG") + image_bytes = buffer.getvalue() + + validated_data = self.input_filter.validate_data({"image": image_bytes}) + + result = validated_data["image"] + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, (200, 150)) + self.assertEqual(result.mode, "RGB") \ No newline at end of file diff --git a/tests/validators/test_is_image_validator.py b/tests/validators/test_is_image_validator.py new file mode 100644 index 0000000..c03a414 --- /dev/null +++ b/tests/validators/test_is_image_validator.py @@ -0,0 +1,58 @@ +import base64 +import io +import unittest + +from flask_inputfilter.exceptions import ValidationError +from flask_inputfilter.validators import IsImageValidator +from PIL import Image + +from tests.validators import BaseValidatorTest + + +class TestIsImageValidator(BaseValidatorTest): + def test_valid_pil_image(self) -> None: + """Should accept PIL Image objects.""" + self.input_filter.add("image", validators=[IsImageValidator()]) + img = Image.new("RGB", (100, 100), color="red") + self.input_filter.validate_data({"image": img}) + + def test_valid_base64_image(self) -> None: + """Should accept valid base64 encoded images.""" + self.input_filter.add("image", validators=[IsImageValidator()]) + with open("tests/data/base64_image.txt") as file: + self.input_filter.validate_data({"image": file.read()}) + + def test_valid_bytes_image(self) -> None: + """Should accept valid image bytes.""" + self.input_filter.add("image", validators=[IsImageValidator()]) + img = Image.new("RGB", (100, 100), color="blue") + buffer = io.BytesIO() + img.save(buffer, format="PNG") + image_bytes = buffer.getvalue() + self.input_filter.validate_data({"image": image_bytes}) + + def test_invalid_string(self) -> None: + """Should reject invalid strings.""" + self.input_filter.add("image", validators=[IsImageValidator()]) + with self.assertRaises(ValidationError): + self.input_filter.validate_data({"image": "not_an_image"}) + + def test_invalid_bytes(self) -> None: + """Should reject invalid bytes.""" + self.input_filter.add("image", validators=[IsImageValidator()]) + with self.assertRaises(ValidationError): + self.input_filter.validate_data({"image": b"not_an_image"}) + + def test_invalid_type(self) -> None: + """Should reject invalid types.""" + self.input_filter.add("image", validators=[IsImageValidator()]) + with self.assertRaises(ValidationError): + self.input_filter.validate_data({"image": 123}) + + def test_custom_error_message(self) -> None: + """Should use custom error message.""" + self.input_filter.add( + "image", + validators=[IsImageValidator(error_message="Custom error")], + ) + self.assertValidationError("image", "not_an_image", "Custom error") \ No newline at end of file