Skip to content

Commit

Permalink
Add multiple file validation support
Browse files Browse the repository at this point in the history
- Add `MultipleFileField` field.
- Update `FileRequired`, `FileAllowed`, and `FileSize` to make them work with multiple files.
- Add tests for using Length with files field.
  • Loading branch information
greyli committed Oct 1, 2023
1 parent 35b531f commit 62f4773
Show file tree
Hide file tree
Showing 4 changed files with 247 additions and 34 deletions.
8 changes: 8 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Changes
=======

Version 1.1.3
-------------

Unreleased

- Add field ``MultipleFileField``. ``FileRequired``, ``FileAllowed``, ``FileSize``
now can be used to validate multiple files :pr:`556` :issue:`338`

Version 1.1.2
-------------

Expand Down
33 changes: 31 additions & 2 deletions docs/form.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,34 @@ field. It will check that the file is a non-empty instance of

return render_template('upload.html', form=form)


Similarly, you can use the :class:`MultipleFileField` provided by Flask-WTF
to handle multiple files. It will check that the files is a list of non-empty instance of
:class:`~werkzeug.datastructures.FileStorage`, otherwise ``data`` will be
``None``. ::

from flask_wtf import FlaskForm
from flask_wtf.file import MultipleFileField, FileRequired
from werkzeug.utils import secure_filename

class PhotoForm(FlaskForm):
photos = MultipleFileField(validators=[FileRequired()])

@app.route('/upload', methods=['GET', 'POST'])
def upload():
form = PhotoForm()

if form.validate_on_submit():
for f in form.photo.data: # form.photo.data return a list of FileStorage object
filename = secure_filename(f.filename)
f.save(os.path.join(
app.instance_path, 'photos', filename
))
return redirect(url_for('index'))

return render_template('upload.html', form=form)


Remember to set the ``enctype`` of the HTML form to
``multipart/form-data``, otherwise ``request.files`` will be empty.

Expand All @@ -81,8 +109,9 @@ Validation
~~~~~~~~~~

Flask-WTF supports validating file uploads with
:class:`FileRequired` and :class:`FileAllowed`. They can be used with both
Flask-WTF's and WTForms's ``FileField`` classes.
:class:`FileRequired`, :class:`FileAllowed`, and :class:`FileSize`. They
can be used with both Flask-WTF's and WTForms's ``FileField`` and
``MultipleFileField`` classes.

:class:`FileAllowed` works well with Flask-Uploads. ::

Expand Down
96 changes: 64 additions & 32 deletions src/flask_wtf/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from werkzeug.datastructures import FileStorage
from wtforms import FileField as _FileField
from wtforms import MultipleFileField as _MultipleFileField
from wtforms.validators import DataRequired
from wtforms.validators import StopValidation
from wtforms.validators import ValidationError
Expand All @@ -20,8 +21,24 @@ def process_formdata(self, valuelist):
self.raw_data = ()


class MultipleFileField(_MultipleFileField):
"""Werkzeug-aware subclass of :class:`wtforms.fields.MultipleFileField`.
.. versionadded:: 1.2.0
"""

def process_formdata(self, valuelist):
valuelist = (x for x in valuelist if isinstance(x, FileStorage) and x)
data = list(valuelist) or None

if data is not None:
self.data = data
else:
self.raw_data = ()


class FileRequired(DataRequired):
"""Validates that the data is a Werkzeug
"""Validates that the uploaded files(s) is a Werkzeug
:class:`~werkzeug.datastructures.FileStorage` object.
:param message: error message
Expand All @@ -30,7 +47,11 @@ class FileRequired(DataRequired):
"""

def __call__(self, form, field):
if not (isinstance(field.data, FileStorage) and field.data):
if not isinstance(field.data, list):
field.data = [field.data]
if not (
all(isinstance(x, FileStorage) and x for x in field.data) and field.data
):
raise StopValidation(
self.message or field.gettext("This field is required.")
)
Expand All @@ -40,7 +61,7 @@ def __call__(self, form, field):


class FileAllowed:
"""Validates that the uploaded file is allowed by a given list of
"""Validates that the uploaded file(s) is allowed by a given list of
extensions or a Flask-Uploads :class:`~flaskext.uploads.UploadSet`.
:param upload_set: A list of extensions or an
Expand All @@ -55,34 +76,39 @@ def __init__(self, upload_set, message=None):
self.message = message

def __call__(self, form, field):
if not (isinstance(field.data, FileStorage) and field.data):
if not isinstance(field.data, list):
field.data = [field.data]
if not (
all(isinstance(x, FileStorage) and x for x in field.data) and field.data
):
return

filename = field.data.filename.lower()
filenames = [f.filename.lower() for f in field.data]

if isinstance(self.upload_set, abc.Iterable):
if any(filename.endswith("." + x) for x in self.upload_set):
return
for filename in filenames:
if isinstance(self.upload_set, abc.Iterable):
if any(filename.endswith("." + x) for x in self.upload_set):
continue

raise StopValidation(
self.message
or field.gettext(
"File does not have an approved extension: {extensions}"
).format(extensions=", ".join(self.upload_set))
)
raise StopValidation(
self.message
or field.gettext(
"File does not have an approved extension: {extensions}"
).format(extensions=", ".join(self.upload_set))
)

if not self.upload_set.file_allowed(field.data, filename):
raise StopValidation(
self.message
or field.gettext("File does not have an approved extension.")
)
if not self.upload_set.file_allowed(field.data, filename):
raise StopValidation(
self.message
or field.gettext("File does not have an approved extension.")
)


file_allowed = FileAllowed


class FileSize:
"""Validates that the uploaded file is within a minimum and maximum
"""Validates that the uploaded file(s) is within a minimum and maximum
file size (set in bytes).
:param min_size: minimum allowed file size (in bytes). Defaults to 0 bytes.
Expand All @@ -98,22 +124,28 @@ def __init__(self, max_size, min_size=0, message=None):
self.message = message

def __call__(self, form, field):
if not (isinstance(field.data, FileStorage) and field.data):
if not isinstance(field.data, list):
field.data = [field.data]
if not (
all(isinstance(x, FileStorage) and x for x in field.data) and field.data
):
return

file_size = len(field.data.read())
field.data.seek(0) # reset cursor position to beginning of file

if (file_size < self.min_size) or (file_size > self.max_size):
# the file is too small or too big => validation failure
raise ValidationError(
self.message
or field.gettext(
"File must be between {min_size} and {max_size} bytes.".format(
min_size=self.min_size, max_size=self.max_size
for f in field.data:
file_size = len(f.read())
print(f, file_size, self.max_size, self.min_size)
f.seek(0) # reset cursor position to beginning of file

if (file_size < self.min_size) or (file_size > self.max_size):
# the file is too small or too big => validation failure
raise ValidationError(
self.message
or field.gettext(
"File must be between {min_size} and {max_size} bytes.".format(
min_size=self.min_size, max_size=self.max_size
)
)
)
)


file_size = FileSize
144 changes: 144 additions & 0 deletions tests/test_file.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import pytest
from werkzeug.datastructures import FileStorage
from werkzeug.datastructures import ImmutableMultiDict
from werkzeug.datastructures import MultiDict
from wtforms import FileField as BaseFileField
from wtforms import MultipleFileField as BaseMultipleFileField
from wtforms.validators import Length

from flask_wtf import FlaskForm
from flask_wtf.file import FileAllowed
from flask_wtf.file import FileField
from flask_wtf.file import FileRequired
from flask_wtf.file import FileSize
from flask_wtf.file import MultipleFileField


@pytest.fixture
Expand All @@ -17,6 +21,7 @@ class Meta:
csrf = False

file = FileField()
files = MultipleFileField()

return UploadForm

Expand Down Expand Up @@ -126,3 +131,142 @@ class Meta:
assert not F().validate()
assert not F(f=FileStorage()).validate()
assert F(f=FileStorage(filename="real")).validate()
assert F(f=FileStorage(filename="real")).validate()


def test_process_formdata_for_files(form):
assert (
form(
ImmutableMultiDict([("files", FileStorage()), ("files", FileStorage())])
).files.data
is None
)
assert (
form(
ImmutableMultiDict(
[
("files", FileStorage(filename="a.jpg")),
("files", FileStorage(filename="b.jpg")),
]
)
).files.data
is not None
)


def test_files_required(form):
form.files.kwargs["validators"] = [FileRequired()]

f = form()
assert not f.validate()
assert f.files.errors[0] == "This field is required."

f = form(files="not a file")
assert not f.validate()
assert f.files.errors[0] == "This field is required."

f = form(files=[FileStorage()])
assert not f.validate()

f = form(files=[FileStorage(filename="real")])
assert f.validate()


def test_files_allowed(form):
form.files.kwargs["validators"] = [FileAllowed(("txt",))]

f = form()
assert f.validate()

f = form(
files=[FileStorage(filename="test.txt"), FileStorage(filename="test2.txt")]
)
assert f.validate()

f = form(files=[FileStorage(filename="test.txt"), FileStorage(filename="test.png")])
assert not f.validate()
assert f.files.errors[0] == "File does not have an approved extension: txt"


def test_files_allowed_uploadset(app, form):
pytest.importorskip("flask_uploads")
from flask_uploads import UploadSet, configure_uploads

app.config["UPLOADS_DEFAULT_DEST"] = "uploads"
txt = UploadSet("txt", extensions=("txt",))
configure_uploads(app, (txt,))
form.files.kwargs["validators"] = [FileAllowed(txt)]

f = form()
assert f.validate()

f = form(
files=[FileStorage(filename="test.txt"), FileStorage(filename="test2.txt")]
)
assert f.validate()

f = form(files=[FileStorage(filename="test.txt"), FileStorage(filename="test.png")])
assert not f.validate()
assert f.files.errors[0] == "File does not have an approved extension."


def test_validate_base_multiple_field(req_ctx):
class F(FlaskForm):
class Meta:
csrf = False

f = BaseMultipleFileField(validators=[FileRequired()])

assert not F().validate()
assert not F(f=[FileStorage()]).validate()
assert F(f=[FileStorage(filename="real")]).validate()


def test_file_size_small_files_pass_validation(form, tmp_path):
form.files.kwargs["validators"] = [FileSize(max_size=100)]
path = tmp_path / "test_file_smaller_than_max.txt"
path.write_bytes(b"\0")

with path.open("rb") as file:
f = form(files=[FileStorage(file)])
assert f.validate()


@pytest.mark.parametrize(
"min_size, max_size, invalid_file_size", [(1, 100, 0), (0, 100, 101)]
)
def test_file_size_invalid_file_sizes_fails_validation(
form, min_size, max_size, invalid_file_size, tmp_path
):
form.files.kwargs["validators"] = [FileSize(min_size=min_size, max_size=max_size)]
path = tmp_path / "test_file_invalid_size.txt"
path.write_bytes(b"\0" * invalid_file_size)

with path.open("rb") as file:
f = form(files=[FileStorage(file)])
assert not f.validate()
assert f.files.errors[
0
] == "File must be between {min_size} and {max_size} bytes.".format(
min_size=min_size, max_size=max_size
)


def test_files_length(form, min_num=2, max_num=3):
form.files.kwargs["validators"] = [Length(min_num, max_num)]

f = form(files=[FileStorage("1")])
assert not f.validate()
assert f.files.errors[
0
] == "Field must be between {min_num} and {max_num} characters long.".format(
min_num=min_num, max_num=max_num
)

f = form(
files=[
FileStorage(filename="1"),
FileStorage(filename="2"),
]
)
assert f.validate()

0 comments on commit 62f4773

Please sign in to comment.