Repository: wtforms/wtforms
Pull request #281 (2016-06-22 18:56:43)

Add support for multiple file input
Took a similar approach to `SelectField` and `SelectMultipleField`.  The `FileInput` widget now takes a `multiple` argument, and there are two fields `FileField` and `MultipleFileField`.

`FileField` is no longer based on `StringField`, since it's only a coincidence that the data is a string. I updated the docs to clarify that extensions might change what data is passed to this field (see Flask-WTF for example).

If no data is passed to the field, its `data` is `None` rather than the empty string.  There was no test for this before, so I figured it was ok to break this.  `None` makes more sense for "no file was uploaded".  The multiple field will have an empty list for no data.

`FileInput` always sets `value` to `False` so that it is not rendered.  Browsers won't honor the value of a file input for security reasons anyway.  `FileField._value` returns `False` as well, to be consistent if the widget it changed.

An alternate implementation could use one `FileField` with a `multiple` argument as well.  I'm willing to implement that instead if it sounds better.

This patch contains #280 (call process_formdata if formdata is empty) since testing this ran into that issue.  Merging this after that, or just closing that, should both work in git.


Patch:
diff --git a/docs/fields.rst b/docs/fields.rst
index cf80f10..9f79680 100644
--- a/docs/fields.rst
+++ b/docs/fields.rst
@@ -16,7 +16,7 @@ Fields are defined as members on a form in a declarative fashion::
         address = TextAreaField(u'Mailing Address', [validators.optional(), validators.length(max=200)])
 
 When a field is defined on a form, the construction parameters are saved until
-the form is instantiated. At form instantiation time, a copy of the field is 
+the form is instantiated. At form instantiation time, a copy of the field is
 made with all the parameters specified in the definition. Each instance of the
 field keeps its own field data and errors list.
 
@@ -63,7 +63,7 @@ The Field base class
 
     .. automethod:: process(formdata [, data])
     .. automethod:: process_data
-    .. automethod:: process_formdata 
+    .. automethod:: process_formdata
     .. attribute:: data
 
         Contains the resulting (sanitized) value of calling either of the
@@ -120,7 +120,7 @@ The Field base class
 
     .. attribute:: name
 
-        The HTML form name of this field. This is the name as defined in your 
+        The HTML form name of this field. This is the name as defined in your
         Form prefixed with the `prefix` passed to the Form constructor.
 
     .. attribute:: short_name
@@ -241,6 +241,8 @@ refer to a single input from the form.
                 image_data = request.FILES[form.image.name].read()
                 open(os.path.join(UPLOAD_PATH, form.image.data), 'w').write(image_data)
 
+.. autoclass:: MultipleFileField(default field arguments)
+
 .. autoclass:: FloatField(default field arguments)
 
    For the majority of uses, :class:`DecimalField` is preferable to FloatField,
@@ -290,11 +292,11 @@ refer to a single input from the form.
             form = UserDetails(request.POST, obj=user)
             form.group_id.choices = [(g.id, g.name) for g in Group.query.order_by('name')]
 
-    Note we didn't pass a `choices` to the :class:`~wtforms.fields.SelectField` 
-    constructor, but rather created the list in the view function. Also, the 
-    `coerce` keyword arg to :class:`~wtforms.fields.SelectField` says that we 
-    use :func:`int()` to coerce form data.  The default coerce is 
-    :func:`unicode()`. 
+    Note we didn't pass a `choices` to the :class:`~wtforms.fields.SelectField`
+    constructor, but rather created the list in the view function. Also, the
+    `coerce` keyword arg to :class:`~wtforms.fields.SelectField` says that we
+    use :func:`int()` to coerce form data.  The default coerce is
+    :func:`unicode()`.
 
     **Advanced functionality**
 
@@ -325,7 +327,7 @@ Convenience Fields
 
     HiddenField is useful for providing data from a model or the application to
     be used on the form handler side for making choices or finding records.
-    Very frequently, CRUD forms will use the hidden field for an object's id.   
+    Very frequently, CRUD forms will use the hidden field for an object's id.
 
     Hidden fields are like any other field in that they can take validators and
     values and be accessed on the form object.   You should consider validating
@@ -373,7 +375,7 @@ complex data structures such as lists and nested objects can be represented.
     In the example, we reused the TelephoneForm to encapsulate the common
     telephone entry instead of writing a custom field to handle the 3
     sub-fields. The `data` property of the mobile_phone field will return the
-    :attr:`~wtforms.form.Form.data` dict of the enclosed form. Similarly, the 
+    :attr:`~wtforms.form.Form.data` dict of the enclosed form. Similarly, the
     `errors` property encapsulate the forms' errors.
 
 .. autoclass:: FieldList(unbound_field, default field arguments, min_entries=0, max_entries=None)
diff --git a/wtforms/fields/simple.py b/wtforms/fields/simple.py
index c3d5d62..3983d9d 100644
--- a/wtforms/fields/simple.py
+++ b/wtforms/fields/simple.py
@@ -1,8 +1,8 @@
 from .. import widgets
-from .core import StringField, BooleanField
+from .core import Field, StringField, BooleanField
 
 __all__ = (
-    'BooleanField', 'TextAreaField', 'PasswordField', 'FileField',
+    'BooleanField', 'TextAreaField', 'PasswordField', 'FileField', 'MultipleFileField',
     'HiddenField', 'SubmitField'
 )
 
@@ -25,15 +25,30 @@ class PasswordField(StringField):
     widget = widgets.PasswordInput()
 
 
-class FileField(StringField):
-    """
-    Can render a file-upload field.  Will take any passed filename value, if
-    any is sent by the browser in the post params.  This field will NOT
-    actually handle the file upload portion, as wtforms does not deal with
-    individual frameworks' file handling capabilities.
+class FileField(Field):
+    """Renders a file upload field.
+
+    By default, the value will be the filename sent in the form data.
+    WTForms **does not** deal with frameworks' file handling capabilities.
+    A WTForms extension for a framework may replace the filename value
+    with an object representing the uploaded data.
     """
+
     widget = widgets.FileInput()
 
+    def _value(self):
+        # browser ignores value of file input for security
+        return False
+
+
+class MultipleFileField(FileField):
+    """A :class:`FileField` that allows choosing multiple files."""
+
+    widget = widgets.FileInput(multiple=True)
+
+    def process_formdata(self, valuelist):
+        self.data = valuelist
+
 
 class HiddenField(StringField):
     """
diff --git a/wtforms/widgets/core.py b/wtforms/widgets/core.py
index 7617744..176e834 100644
--- a/wtforms/widgets/core.py
+++ b/wtforms/widgets/core.py
@@ -227,14 +227,26 @@ class RadioInput(Input):
         return super(RadioInput, self).__call__(field, **kwargs)
 
 
-class FileInput(object):
-    """
-    Renders a file input chooser field.
+class FileInput(Input):
+    """Render a file chooser input.
+
+    :param multiple: allow choosing multiple files
     """
 
+    input_type = 'file'
+
+    def __init__(self, multiple=False):
+        super(FileInput, self).__init__()
+        self.multiple = multiple
+
     def __call__(self, field, **kwargs):
-        kwargs.setdefault('id', field.id)
-        return HTMLString('<input %s>' % html_params(name=field.name, type='file', **kwargs))
+        # browser ignores value of file input for security
+        kwargs['value'] = False
+
+        if self.multiple:
+            kwargs['multiple'] = True
+
+        return super(FileInput, self).__call__(field, **kwargs)
 
 
 class SubmitInput(Input):


Test patch:
diff --git a/tests/fields.py b/tests/fields.py
index b299134..b54833a 100644
--- a/tests/fields.py
+++ b/tests/fields.py
@@ -13,6 +13,7 @@ from wtforms.form import Form
 from wtforms.compat import text_type
 from wtforms.utils import unset_value
 from tests.common import DummyPostData
+from wtforms.widgets import TextInput
 
 PYTHON_VERSION = sys.version_info
 
@@ -416,12 +417,29 @@ class PasswordFieldTest(TestCase):
 
 
 class FileFieldTest(TestCase):
-    class F(Form):
-        a = FileField(default="LE DEFAULT")
+    def test_file_field(self):
+        class F(Form):
+            file = FileField()
 
-    def test(self):
-        form = self.F()
-        self.assertEqual(form.a(), """<input id="a" name="a" type="file">""")
+        self.assertEqual(F(DummyPostData(file=['test.txt'])).file.data, 'test.txt')
+        self.assertEqual(F(DummyPostData()).file.data, None)
+        self.assertEqual(F(DummyPostData(file=['test.txt', 'multiple.txt'])).file.data, 'test.txt')
+
+    def test_multiple_file_field(self):
+        class F(Form):
+            files = MultipleFileField()
+
+        self.assertEqual(F(DummyPostData(files=['test.txt'])).files.data, ['test.txt'])
+        self.assertEqual(F(DummyPostData()).files.data, [])
+        self.assertEqual(F(DummyPostData(files=['test.txt', 'multiple.txt'])).files.data, ['test.txt', 'multiple.txt'])
+
+    def test_file_field_without_file_input(self):
+        class F(Form):
+            file = FileField(widget=TextInput())
+
+        f = F(DummyPostData(file=['test.txt']))
+        self.assertEqual(f.file.data, 'test.txt')
+        self.assertEqual(f.file(), '<input id="file" name="file" type="text">')
 
 
 class IntegerFieldTest(TestCase):
diff --git a/tests/widgets.py b/tests/widgets.py
index 2a03771..a8598fa 100644
--- a/tests/widgets.py
+++ b/tests/widgets.py
@@ -110,6 +110,10 @@ class BasicWidgetsTest(TestCase):
         f = DummyField('hi<>bye')
         self.assertEqual(TextArea()(f), '<textarea id="" name="f">hi&lt;&gt;bye</textarea>')
 
+    def test_file(self):
+        self.assertEqual(FileInput()(self.field), '<input id="id" name="bar" type="file">')
+        self.assertEqual(FileInput(multiple=True)(self.field), '<input id="id" multiple name="bar" type="file">')
+
 
 class SelectTest(TestCase):
     field = DummyField([('foo', 'lfoo', True), ('bar', 'lbar', False)])