From 6c9e2266cdc4ee3230d334500da9330a2d042035 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 12 Jul 2025 03:38:37 +0330 Subject: [PATCH 1/2] feat: Add MaxFileSizeValidator and MinFileSizeValidator Add two new validators for enforcing file size constraints on uploaded files: - MaxFileSizeValidator: Ensures files do not exceed a maximum size (in bytes) - MinFileSizeValidator: Ensures files meet a minimum size (in bytes) Both validators: - Support custom error messages and codes - Are deconstructible for migrations - Include comprehensive unit and integration tests - Work with FileField and ImageField - Follow DRF conventions and patterns --- docs/api-guide/validators.md | 68 +++++++++ rest_framework/validators.py | 71 +++++++++ tests/test_validators.py | 284 ++++++++++++++++++++++++++++++++++- 3 files changed, 422 insertions(+), 1 deletion(-) diff --git a/docs/api-guide/validators.md b/docs/api-guide/validators.md index 57bcb8628c..29d904b77a 100644 --- a/docs/api-guide/validators.md +++ b/docs/api-guide/validators.md @@ -170,6 +170,74 @@ If you want the date field to be entirely hidden from the user, then use `Hidden --- +## MaxFileSizeValidator and MinFileSizeValidator + +These validators can be used to enforce file size constraints on uploaded files. They are especially useful for `FileField` and `ImageField` in serializers. + +### MaxFileSizeValidator + +Ensures that the uploaded file does not exceed a maximum size (in bytes). + +**Parameters:** + +* `max_size` (*required*) — The maximum file size in bytes. +* `message` — Custom error message. May use `{max_size}` in the string. +* `code` — Custom error code. Default is `'max_file_size'`. + +### MinFileSizeValidator + +Ensures that the uploaded file meets a minimum size (in bytes). + +**Parameters:** + +* `min_size` (*required*) — The minimum file size in bytes. +* `message` — Custom error message. May use `{min_size}` in the string. +* `code` — Custom error code. Default is `'min_file_size'`. + +### Usage Examples + +#### Basic usage with FileField + + from rest_framework import serializers + from rest_framework.validators import MaxFileSizeValidator, MinFileSizeValidator + + class FileUploadSerializer(serializers.Serializer): + file = serializers.FileField(validators=[ + MaxFileSizeValidator(1024 * 1024), # 1MB max + MinFileSizeValidator(1024), # 1KB min + ]) + +#### Usage with ImageField + + class ImageUploadSerializer(serializers.Serializer): + image = serializers.ImageField(validators=[ + MaxFileSizeValidator(5 * 1024 * 1024), # 5MB max + MinFileSizeValidator(1024), # 1KB min + ]) + +#### Custom error messages and codes + + class CustomFileSerializer(serializers.Serializer): + file = serializers.FileField(validators=[ + MaxFileSizeValidator( + max_size=1024 * 1024, + message="File size cannot exceed {max_size} bytes", + code='file_too_large' + ), + MinFileSizeValidator( + min_size=1024, + message="File must be at least {min_size} bytes", + code='file_too_small' + ), + ]) + +### Error Codes + +* `max_file_size` — Default error code for MaxFileSizeValidator +* `min_file_size` — Default error code for MinFileSizeValidator + +--- + # Advanced field defaults Validators that are applied across multiple fields in the serializer can sometimes require a field input that should not be provided by the API client, but that *is* available as input to the validator. diff --git a/rest_framework/validators.py b/rest_framework/validators.py index 4c444cf01e..c82e5f90e2 100644 --- a/rest_framework/validators.py +++ b/rest_framework/validators.py @@ -9,6 +9,7 @@ from django.core.exceptions import FieldError from django.db import DataError from django.db.models import Exists +from django.utils.deconstruct import deconstructible from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import ValidationError @@ -344,3 +345,73 @@ def filter_queryset(self, attrs, queryset, field_name, date_field_name): filter_kwargs[field_name] = value filter_kwargs['%s__year' % date_field_name] = date.year return qs_filter(queryset, **filter_kwargs) + + +@deconstructible +class MaxFileSizeValidator: + """ + Validator that ensures uploaded files do not exceed a maximum size. + + Should be applied to individual file fields on the serializer. + """ + message = _('File size must not exceed {max_size} bytes.') + code = 'max_file_size' + + def __init__(self, max_size, message=None, code=None): + self.max_size = max_size + self.message = message or self.message + self.code = code or self.code + + def __call__(self, value): + if hasattr(value, 'size'): + if value.size > self.max_size: + message = self.message.format(max_size=self.max_size) + raise ValidationError(message, code=self.code) + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return (self.message == other.message + and self.code == other.code + and self.max_size == other.max_size) + + def __repr__(self): + return '<%s(max_size=%s)>' % ( + self.__class__.__name__, + smart_repr(self.max_size) + ) + + +@deconstructible +class MinFileSizeValidator: + """ + Validator that ensures uploaded files meet a minimum size. + + Should be applied to individual file fields on the serializer. + """ + message = _('File size must be at least {min_size} bytes.') + code = 'min_file_size' + + def __init__(self, min_size, message=None, code=None): + self.min_size = min_size + self.message = message or self.message + self.code = code or self.code + + def __call__(self, value): + if hasattr(value, 'size'): + if value.size < self.min_size: + message = self.message.format(min_size=self.min_size) + raise ValidationError(message, code=self.code) + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + return (self.message == other.message + and self.code == other.code + and self.min_size == other.min_size) + + def __repr__(self): + return '<%s(min_size=%s)>' % ( + self.__class__.__name__, + smart_repr(self.min_size) + ) diff --git a/tests/test_validators.py b/tests/test_validators.py index c594eecbe5..f583b1ede8 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,16 +1,20 @@ import datetime +import io import re from unittest.mock import MagicMock, patch import pytest from django import VERSION as django_version +from django.core.files.uploadedfile import SimpleUploadedFile from django.db import DataError, models from django.test import TestCase +from PIL import Image from rest_framework import serializers from rest_framework.exceptions import ValidationError from rest_framework.validators import ( - BaseUniqueForValidator, UniqueTogetherValidator, UniqueValidator, qs_exists + BaseUniqueForValidator, MaxFileSizeValidator, MinFileSizeValidator, + UniqueTogetherValidator, UniqueValidator, qs_exists ) @@ -972,3 +976,281 @@ def test_equality_operator(self): assert validator == validator2 validator2.date_field = "bar2" assert validator != validator2 + + +# Tests for `MaxFileSizeValidator` and `MinFileSizeValidator` +# ----------------------------------------------------------- + +class TestFileSizeValidators(TestCase): + class DummyFile: + def __init__(self, size): + self.size = size + + def test_max_file_size_validator_pass(self): + validator = MaxFileSizeValidator(1024) + file = self.DummyFile(1024) + # Should not raise + validator(file) + + def test_max_file_size_validator_fail(self): + validator = MaxFileSizeValidator(1024) + file = self.DummyFile(1025) + with pytest.raises(ValidationError) as exc: + validator(file) + assert 'File size must not exceed 1024 bytes.' in str(exc.value) + assert exc.value.get_codes() == ['max_file_size'] + + def test_min_file_size_validator_pass(self): + validator = MinFileSizeValidator(100) + file = self.DummyFile(200) + # Should not raise + validator(file) + + def test_min_file_size_validator_fail(self): + validator = MinFileSizeValidator(100) + file = self.DummyFile(50) + with pytest.raises(ValidationError) as exc: + validator(file) + assert 'File size must be at least 100 bytes.' in str(exc.value) + assert exc.value.get_codes() == ['min_file_size'] + + def test_max_file_size_validator_exact_size(self): + validator = MaxFileSizeValidator(1024) + file = self.DummyFile(1024) + # Should not raise for exact size + validator(file) + + def test_min_file_size_validator_exact_size(self): + validator = MinFileSizeValidator(100) + file = self.DummyFile(100) + # Should not raise for exact size + validator(file) + + def test_max_file_size_validator_zero_size(self): + validator = MaxFileSizeValidator(1024) + file = self.DummyFile(0) + # Should not raise for zero size + validator(file) + + def test_min_file_size_validator_zero_size(self): + validator = MinFileSizeValidator(100) + file = self.DummyFile(0) + with pytest.raises(ValidationError) as exc: + validator(file) + assert 'File size must be at least 100 bytes.' in str(exc.value) + + def test_max_file_size_validator_no_size_attribute(self): + validator = MaxFileSizeValidator(1024) + # Object without size attribute should not raise + validator("not a file") + + def test_min_file_size_validator_no_size_attribute(self): + validator = MinFileSizeValidator(100) + # Object without size attribute should not raise + validator("not a file") + + def test_max_file_size_validator_custom_message(self): + validator = MaxFileSizeValidator(1024, message="File too big: {max_size}") + file = self.DummyFile(1025) + with pytest.raises(ValidationError) as exc: + validator(file) + assert 'File too big: 1024' in str(exc.value) + + def test_min_file_size_validator_custom_message(self): + validator = MinFileSizeValidator(100, message="File too small: {min_size}") + file = self.DummyFile(50) + with pytest.raises(ValidationError) as exc: + validator(file) + assert 'File too small: 100' in str(exc.value) + + def test_max_file_size_validator_custom_code(self): + validator = MaxFileSizeValidator(1024, code='custom_max_size') + file = self.DummyFile(1025) + with pytest.raises(ValidationError) as exc: + validator(file) + assert exc.value.get_codes() == ['custom_max_size'] + + def test_min_file_size_validator_custom_code(self): + validator = MinFileSizeValidator(100, code='custom_min_size') + file = self.DummyFile(50) + with pytest.raises(ValidationError) as exc: + validator(file) + assert exc.value.get_codes() == ['custom_min_size'] + + def test_max_file_size_validator_equality(self): + validator1 = MaxFileSizeValidator(1024) + validator2 = MaxFileSizeValidator(1024) + validator3 = MaxFileSizeValidator(2048) + validator4 = MaxFileSizeValidator(1024, message="Custom") + + assert validator1 == validator2 + assert validator1 != validator3 + assert validator1 != validator4 + + def test_min_file_size_validator_equality(self): + validator1 = MinFileSizeValidator(100) + validator2 = MinFileSizeValidator(100) + validator3 = MinFileSizeValidator(200) + validator4 = MinFileSizeValidator(100, message="Custom") + + assert validator1 == validator2 + assert validator1 != validator3 + assert validator1 != validator4 + + def test_max_file_size_validator_repr(self): + validator = MaxFileSizeValidator(1024) + assert repr(validator) == '' + + def test_min_file_size_validator_repr(self): + validator = MinFileSizeValidator(100) + assert repr(validator) == '' + + def test_max_file_size_validator_boundary_conditions(self): + validator = MaxFileSizeValidator(1024) + # One byte over + file = self.DummyFile(1025) + with pytest.raises(ValidationError): + validator(file) + # One byte under + file = self.DummyFile(1023) + validator(file) # Should not raise + + def test_min_file_size_validator_boundary_conditions(self): + validator = MinFileSizeValidator(100) + # One byte under + file = self.DummyFile(99) + with pytest.raises(ValidationError): + validator(file) + # One byte over + file = self.DummyFile(101) + validator(file) # Should not raise + + +class TestFileSizeValidatorIntegration(TestCase): + def test_filefield_max_and_min_size(self): + class FileSerializer(serializers.Serializer): + file = serializers.FileField(validators=[ + MaxFileSizeValidator(10), + MinFileSizeValidator(5), + ]) + + # File of size 7 (should pass) + file = SimpleUploadedFile('test.txt', b'1234567') + serializer = FileSerializer(data={'file': file}) + assert serializer.is_valid(), serializer.errors + + # File of size 4 (too small) + file = SimpleUploadedFile('test.txt', b'1234') + serializer = FileSerializer(data={'file': file}) + assert not serializer.is_valid() + assert 'min_file_size' in serializer.errors['file'][0].code + + # File of size 12 (too large) + file = SimpleUploadedFile('test.txt', b'123456789012') + serializer = FileSerializer(data={'file': file}) + assert not serializer.is_valid() + assert 'max_file_size' in serializer.errors['file'][0].code + + def test_filefield_max_size_only(self): + class FileSerializer(serializers.Serializer): + file = serializers.FileField(validators=[ + MaxFileSizeValidator(1024), + ]) + + # File under limit (should pass) + file = SimpleUploadedFile('test.txt', b'x' * 512) + serializer = FileSerializer(data={'file': file}) + assert serializer.is_valid(), serializer.errors + + # File over limit + file = SimpleUploadedFile('test.txt', b'x' * 2048) + serializer = FileSerializer(data={'file': file}) + assert not serializer.is_valid() + assert 'max_file_size' in serializer.errors['file'][0].code + + def test_filefield_min_size_only(self): + class FileSerializer(serializers.Serializer): + file = serializers.FileField(validators=[ + MinFileSizeValidator(100), + ]) + + # File over minimum (should pass) + file = SimpleUploadedFile('test.txt', b'x' * 200) + serializer = FileSerializer(data={'file': file}) + assert serializer.is_valid(), serializer.errors + + # File under minimum + file = SimpleUploadedFile('test.txt', b'x' * 50) + serializer = FileSerializer(data={'file': file}) + assert not serializer.is_valid() + assert 'min_file_size' in serializer.errors['file'][0].code + + def test_imagefield_max_size(self): + class ImageSerializer(serializers.Serializer): + image = serializers.ImageField(validators=[MaxFileSizeValidator(1024)]) + + # Create a small image in memory + img = Image.new('RGB', (10, 10), color='red') + buf = io.BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + image_bytes = buf.read() + image_file = SimpleUploadedFile('test.png', image_bytes, content_type='image/png') + serializer = ImageSerializer(data={'image': image_file}) + assert serializer.is_valid(), serializer.errors + + # Create a valid image, then pad it to exceed the size limit + big_bytes = image_bytes + b'0' * (1025 - len(image_bytes)) + big_image_file = SimpleUploadedFile('big.png', big_bytes, content_type='image/png') + serializer = ImageSerializer(data={'image': big_image_file}) + assert not serializer.is_valid() + assert 'max_file_size' in serializer.errors['image'][0].code + + def test_imagefield_min_size(self): + class ImageSerializer(serializers.Serializer): + image = serializers.ImageField(validators=[MinFileSizeValidator(100)]) + + # Create a large image in memory + img = Image.new('RGB', (50, 50), color='blue') + buf = io.BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + image_bytes = buf.read() + image_file = SimpleUploadedFile('test.png', image_bytes, content_type='image/png') + serializer = ImageSerializer(data={'image': image_file}) + assert serializer.is_valid(), serializer.errors + + # Create a small image + small_img = Image.new('RGB', (5, 5), color='green') + small_buf = io.BytesIO() + small_img.save(small_buf, format='PNG') + small_buf.seek(0) + small_image_bytes = small_buf.read() + small_image_file = SimpleUploadedFile('small.png', small_image_bytes, content_type='image/png') + serializer = ImageSerializer(data={'image': small_image_file}) + assert not serializer.is_valid() + assert 'min_file_size' in serializer.errors['image'][0].code + + def test_multiple_validators_on_same_field(self): + class FileSerializer(serializers.Serializer): + file = serializers.FileField(validators=[ + MaxFileSizeValidator(1000), + MinFileSizeValidator(100), + ]) + + # Valid file + file = SimpleUploadedFile('test.txt', b'x' * 500) + serializer = FileSerializer(data={'file': file}) + assert serializer.is_valid(), serializer.errors + + # Too small + file = SimpleUploadedFile('test.txt', b'x' * 50) + serializer = FileSerializer(data={'file': file}) + assert not serializer.is_valid() + assert 'min_file_size' in serializer.errors['file'][0].code + + # Too large + file = SimpleUploadedFile('test.txt', b'x' * 1500) + serializer = FileSerializer(data={'file': file}) + assert not serializer.is_valid() + assert 'max_file_size' in serializer.errors['file'][0].code From 888df27dc3ae5096128cdd716b783f9696953f46 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 12 Jul 2025 03:46:58 +0330 Subject: [PATCH 2/2] test: skip image file size validator tests if Pillow is not installed - Remove top-level PIL import from tests/test_validators.py - Conditionally import PIL inside image-related tests and skip them if Pillow is unavailable --- tests/test_validators.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_validators.py b/tests/test_validators.py index f583b1ede8..029ab68a84 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -8,7 +8,6 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.db import DataError, models from django.test import TestCase -from PIL import Image from rest_framework import serializers from rest_framework.exceptions import ValidationError @@ -1186,6 +1185,11 @@ class FileSerializer(serializers.Serializer): assert 'min_file_size' in serializer.errors['file'][0].code def test_imagefield_max_size(self): + try: + from PIL import Image + except ImportError: + pytest.skip("PIL not available") + class ImageSerializer(serializers.Serializer): image = serializers.ImageField(validators=[MaxFileSizeValidator(1024)]) @@ -1207,6 +1211,11 @@ class ImageSerializer(serializers.Serializer): assert 'max_file_size' in serializer.errors['image'][0].code def test_imagefield_min_size(self): + try: + from PIL import Image + except ImportError: + pytest.skip("PIL not available") + class ImageSerializer(serializers.Serializer): image = serializers.ImageField(validators=[MinFileSizeValidator(100)]) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy