Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------

Expand Down
6 changes: 5 additions & 1 deletion flask_inputfilter/_input_filter.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions flask_inputfilter/conditions/base_condition.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import warnings

warnings.warn(
Expand All @@ -7,5 +9,3 @@
DeprecationWarning,
stacklevel=2,
)

from flask_inputfilter.models import BaseCondition
4 changes: 4 additions & 0 deletions flask_inputfilter/filters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -42,6 +44,7 @@
"StringSlugifyFilter",
"StringTrimFilter",
"ToAlphaNumericFilter",
"ToBase64ImageFilter",
"ToBooleanFilter",
"ToCamelCaseFilter",
"ToDataclassFilter",
Expand All @@ -50,6 +53,7 @@
"ToDigitsFilter",
"ToEnumFilter",
"ToFloatFilter",
"ToImageFilter",
"ToIntegerFilter",
"ToIsoFilter",
"ToLowerFilter",
Expand Down
4 changes: 2 additions & 2 deletions flask_inputfilter/filters/base_filter.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import warnings

warnings.warn(
Expand All @@ -7,5 +9,3 @@
DeprecationWarning,
stacklevel=2,
)

from flask_inputfilter.models import BaseFilter
8 changes: 6 additions & 2 deletions flask_inputfilter/filters/string_slugify_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
101 changes: 101 additions & 0 deletions flask_inputfilter/filters/to_base64_image_filter.py
Original file line number Diff line number Diff line change
@@ -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")
64 changes: 64 additions & 0 deletions flask_inputfilter/filters/to_image_filter.py
Original file line number Diff line number Diff line change
@@ -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
10 changes: 8 additions & 2 deletions flask_inputfilter/filters/to_normalized_unicode_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
4 changes: 3 additions & 1 deletion flask_inputfilter/input_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 12 additions & 10 deletions flask_inputfilter/mixins/data_mixin/data_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading