Merge lp:~sinzui/launchpad/field-errors-0 into lp:launchpad

Proposed by Curtis Hovey
Status: Merged
Approved by: Graham Binns
Approved revision: no longer in the source branch.
Merged at revision: 12345
Proposed branch: lp:~sinzui/launchpad/field-errors-0
Merge into: lp:launchpad
Diff against target: 543 lines (+128/-228)
3 files modified
lib/lp/app/widgets/doc/image-widget.txt (+95/-227)
lib/lp/services/fields/__init__.py (+1/-1)
lib/lp/services/fields/tests/test_fields.py (+32/-0)
To merge this branch: bzr merge lp:~sinzui/launchpad/field-errors-0
Reviewer Review Type Date Requested Status
Graham Binns (community) code Approve
Review via email: mp+48800@code.launchpad.net

Description of the change

Catch the ValueError while validating image file

    Launchpad bug: https://bugs.launchpad.net/bugs/50616
    Pre-implementation: no one
    Test command: ./bin/test -vv \
      -t test_fields -t image-widget

Usually when the PIL can't identify an image type, it raises a IOError, but
it might raise a "ValueError: cannot read this XPM file" when uploading a
broken XPM file. The existing LaunchpadValidationError message is correct,
but it is not being raised because ValueError was not expected.

--------------------------------------------------------------------

RULES

    * Reproduce an XPM fragment that raises the ValueError.
    * Add ValueError to the except clause.

QA

    * Upload the abcardwindow.xpm file attached to the bug.
    * Verify the form explain that the image was not recognised.

LINT

    lib/lp/app/widgets/doc/image-widget.txt
    lib/lp/services/fields/__init__.py
    lib/lp/services/fields/tests/test_fields.py

./lib/lp/app/widgets/doc/image-widget.txt
       1: narrative uses a moin header.
      36: narrative uses a moin header.
      77: source exceeds 78 characters.
     233: narrative exceeds 78 characters.
     250: narrative uses a moin header.
     314: narrative exceeds 78 characters.
     317: narrative exceeds 78 characters.
     358: narrative uses a moin header.
     421: narrative exceeds 78 characters.
     476: narrative uses a moin header.
./lib/lp/services/fields/__init__.py
     350: E302 expected 2 blank lines, found 1

^ I will fix this before I land the branch. I do not want to clutter the
diff.

IMPLEMENTATION

Converted the existing BaseImageUpload IOError doctest into a unittest.
    lib/lp/app/widgets/doc/image-widget.txt
    lib/lp/services/fields/tests/test_fields.py

Reproduced the ValueError using a corrupt XPM and added the ValueError to
the exception.
    lib/lp/services/fields/tests/test_fields.py
    lib/lp/services/fields/__init__.py

To post a comment you must log in.
Revision history for this message
Graham Binns (gmb) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/app/widgets/doc/image-widget.txt'
2--- lib/lp/app/widgets/doc/image-widget.txt 2011-02-01 21:46:58 +0000
3+++ lib/lp/app/widgets/doc/image-widget.txt 2011-02-08 22:06:49 +0000
4@@ -1,24 +1,27 @@
5-= ImageChange widget =
6-
7-In Launchpad we have images associated with people, products, distributions,
8-etc, and we want to allow people to have full control over their images. That
9-is, they must be able to upload a new image and delete (or keep) an existing
10-one. For this we created this widget, which can be embeded into any form we
11-want, which doesn't require us to add any submit buttons to indicate that the
12-image should be kept, deleted or changed.
13-
14-The widget is composed by a RadioWidget and a FileWidget, where the radio
15-specifies the action that should be performed (keep the existing image, change
16-back to the default image or change it to an user-uploaded one) and the
17-FileWidget gives us the user-uploaded file, in case there is one.
18-
19-Whenever you have a form in which you want to use the image widget, you have
20-to explicitly say whether you want to use its ADD_STYLE or EDIT_STYLE
21-incarnation, by passing an extra argument to the widget's constructor
22-(or to custom_widget(), if you're using it).
23-
24-Our policy is not to ask people to upload images when creating a record, but
25-instead to expose this as an edit form after the object is created.
26+ImageChange widget
27+==================
28+
29+In Launchpad we have images associated with people, products,
30+distributions, etc, and we want to allow people to have full control
31+over their images. That is, they must be able to upload a new image and
32+delete (or keep) an existing one. For this we created this widget, which
33+can be embeded into any form we want, which doesn't require us to add
34+any submit buttons to indicate that the image should be kept, deleted or
35+changed.
36+
37+The widget is composed by a RadioWidget and a FileWidget, where the
38+radio specifies the action that should be performed (keep the existing
39+image, change back to the default image or change it to an user-uploaded
40+one) and the FileWidget gives us the user-uploaded file, in case there
41+is one.
42+
43+Whenever you have a form in which you want to use the image widget, you
44+have to explicitly say whether you want to use its ADD_STYLE or
45+EDIT_STYLE incarnation, by passing an extra argument to the widget's
46+constructor (or to custom_widget(), if you're using it).
47+
48+Our policy is not to ask people to upload images when creating a record,
49+but instead to expose this as an edit form after the object is created.
50
51 Let's use Salgado and the Launchpad Administrators team as an examples
52 here, since they haven't uploaded custom logos yet.
53@@ -27,22 +30,26 @@
54 >>> salgado = getUtility(IPersonSet).getByName('salgado')
55 >>> salgado.logo is None
56 True
57+
58 >>> admins_team = getUtility(IPersonSet).getByName('admins')
59 >>> admins_team.logo is None
60 True
61+
62 >>> admins_team.icon is None
63 True
64
65-== The ADD_STYLE/EDIT_STYLE incarnations ==
66+
67+The ADD_STYLE/EDIT_STYLE incarnations
68+-------------------------------------
69
70 The only difference between them is that the ADD_STYLE has a different
71-set of labels for its options and never returns our special flag to indicate
72-that the image should be kept, since there's nothing to be kept. For that
73-reason I'll only demonstrate the EDIT_STYLE here.
74+set of labels for its options and never returns our special flag to
75+indicate that the image should be kept, since there's nothing to be
76+kept. For that reason I'll only demonstrate the EDIT_STYLE here.
77
78-Since Salgado has no logo, the widget will display the default person-logo
79-image and the 'Keep' radio button will be selected. The
80-other radio button allows the user to upload a new image.
81+Since Salgado has no logo, the widget will display the default person-
82+logo image and the 'Keep' radio button will be selected. The other radio
83+button allows the user to upload a new image.
84
85 >>> from canonical.launchpad.webapp.servers import LaunchpadTestRequest
86 >>> from lp.app.widgets.image import ImageChangeWidget
87@@ -70,17 +77,18 @@
88 keep: SELECTED
89 change: NOT SELECTED
90
91-
92-If we set any random file as salgado's logo, we'll see it there, as
93-well as an option to delete the image that was just uploaded.
94-
95- >>> from canonical.launchpad.interfaces.librarian import ILibraryFileAliasSet
96+If we set any random file as salgado's logo, we'll see it there, as well
97+as an option to delete the image that was just uploaded.
98+
99+ >>> from canonical.launchpad.interfaces.librarian import (
100+ ... ILibraryFileAliasSet)
101 >>> login('guilherme.salgado@canonical.com')
102 >>> logo = getUtility(ILibraryFileAliasSet)[53]
103 >>> salgado.logo = logo
104
105 # Need to create a new widget instance since we changed our context
106 # manually.
107+
108 >>> widget = ImageChangeWidget(
109 ... person_logo, LaunchpadTestRequest(), edit_style)
110
111@@ -93,9 +101,8 @@
112 delete: NOT SELECTED
113 change: NOT SELECTED
114
115-
116-Now we'll stuff values in our request to simulate a user playing with the
117-widget. Let's see how it reacts.
118+Now we'll stuff values in our request to simulate a user playing with
119+the widget. Let's see how it reacts.
120
121 First, let's tell it to keep the existing image.
122
123@@ -131,24 +138,26 @@
124 >>> fileupload = widget.getInputValue()
125 >>> fileupload.filename
126 u'logo.png'
127+
128 >>> fileupload.content.filesize == logo.len
129 True
130
131-In order for this widget to work on add forms, we need to make sure it works
132-when its field is bounded to an object that doesn't have the attribute that
133-the field represents.
134+In order for this widget to work on add forms, we need to make sure it
135+works when its field is bounded to an object that doesn't have the
136+attribute that the field represents.
137
138 >>> personset_logo = IPerson['logo'].bind(getUtility(IPersonSet))
139 >>> form = {'field.logo.action': 'keep'}
140 >>> widget = ImageChangeWidget(
141 ... personset_logo, LaunchpadTestRequest(form=form), add_style)
142
143-Note that in this case the KEEP_SAME_IMAGE flag doesn't make sense, so we
144-return None, which is a sensible value that can be fed to a method which
145-creates a new database object for us.
146+Note that in this case the KEEP_SAME_IMAGE flag doesn't make sense, so
147+we return None, which is a sensible value that can be fed to a method
148+which creates a new database object for us.
149
150 >>> widget.getInputValue() == None
151 True
152+
153 >>> print_radio_items(widget())
154 keep: SELECTED
155 change: NOT SELECTED
156@@ -160,125 +169,47 @@
157 >>> print_radio_items(widget())
158 keep: NOT SELECTED
159 change: SELECTED
160+
161 >>> widget.getInputValue().content.filesize == logo.len
162 True
163
164
165-# This section is commented out because we are not doing the autoscaling
166-# magic for 1.0 (mark - 2007-03-29)
167-
168-#=== The LogoTiedWithMugshotWidget ===
169-#
170-#This is a specialized version of the ImageChangeWidget which also returns a
171-#resized version of the uploaded image, in case one was uploaded.
172-#
173-# >>> from lp.app.widgets.image import LogoTiedWithMugshotWidget
174-# >>> mugshot_file_name = os.path.join(
175-# ... os.path.dirname(canonical.launchpad.__file__),
176-# ... 'images/nyet-mugshot.png')
177-# >>> mugshot = StringIO(open(mugshot_file_name, 'r').read())
178-# >>> mugshot.filename = 'mugshot.png'
179-# >>> form = {'field.mugshot.action': 'change',
180-# ... 'field.mugshot.image': mugshot}
181-# >>> widget = LogoTiedWithMugshotWidget(
182-# ... person_mugshot, LaunchpadTestRequest(form=form), edit_style)
183-# >>> image, smaller_img = widget.getInputValue()
184-#
185-# >>> image.content.filesize == mugshot.len
186-# True
187-#
188-# # Need to commit so that we can fetch the content from the librarian.
189-# >>> import transaction
190-# >>> transaction.commit()
191-# >>> import PIL.Image
192-# >>> orig_image = PIL.Image.open(StringIO(image.read()))
193-# >>> smaller_image = PIL.Image.open(StringIO(smaller_img.read()))
194-#
195-#Since the original image's size was smaller than the size we wanted to resize
196-#it to, we don't resize it.
197-#
198-# >>> smaller_image.size == orig_image.size
199-# True
200-#
201-#Now that the original image is big, the other one will actually be resized.
202-#
203-# >>> big_mugshot_file_name = os.path.join(
204-# ... os.path.dirname(canonical.launchpad.__file__),
205-# ... 'pagetests/standalone/bigicon.jpg')
206-# >>> mugshot = StringIO(open(big_mugshot_file_name, 'r').read())
207-# >>> mugshot.filename = 'bigicon.jpg'
208-# >>> form = {'field.gotchi.action': 'change',
209-# ... 'field.gotchi.image': mugshot}
210-# >>> widget = GotchiTiedWithHeadingWidget(
211-# ... person_gotchi, LaunchpadTestRequest(form=form), edit_style)
212-# >>> image, smaller_img = widget.getInputValue()
213-# >>> transaction.commit()
214-# >>> orig_image = PIL.Image.open(StringIO(image.read()))
215-# >>> smaller_image = PIL.Image.open(StringIO(smaller_img.read()))
216-#
217-# >>> smaller_image.size != orig_image.size
218-# True
219-#
220-# >>> small_width, small_height = smaller_image.size
221-# >>> scale = "%.1f" % (float(small_width) / small_height)
222-# >>> width, height = orig_image.size
223-# >>> orig_scale = "%.1f" % (float(width) / height)
224-#
225-# >>> small_width < width and small_height < height
226-# True
227-# >>> scale == orig_scale
228-# True
229-#
230-#When we want to delete the image, it'll return a two-tuple of None, so that
231-#callsites can just unpack it like they would do if it had returned two images.
232-#
233-# >>> form = {'field.gotchi.action': 'delete'}
234-# >>> widget = GotchiTiedWithHeadingWidget(
235-# ... person_gotchi, LaunchpadTestRequest(form=form), edit_style)
236-# >>> widget.getInputValue()
237-# (None, None)
238-#
239-#The same holds for when we want to keep an image.
240-#
241-# >>> form = {'field.gotchi.action': 'keep'}
242-# >>> widget = GotchiTiedWithHeadingWidget(
243-# ... person_gotchi, LaunchpadTestRequest(form=form), edit_style)
244-# >>> widget.getInputValue() == (KEEP_SAME_IMAGE, KEEP_SAME_IMAGE)
245-# True
246-
247-
248-== The IconImageUpload, LogoImageUpload and MugshotImageUpload fields ==
249+The IconImageUpload, LogoImageUpload and MugshotImageUpload fields
250+------------------------------------------------------------------
251
252 There are three fields which are used for image uploads. They are all
253 subsclasses of the same BaseImageUpload class, and the only thing they
254-change in each case is the max_size exact dimensions. We will only test the
255-IconImageUpload and MugshotImageUpload widgets below.
256+change in each case is the max_size exact dimensions. We will only test
257+the IconImageUpload and MugshotImageUpload widgets below.
258
259 Since this is a special widget which returns a special object
260-(KEEP_SAME_IMAGE) to indicate that the image should be kept, we need to use
261-a custom field (IconImageUpload) together with it. That field should not be
262-used directly, since it specifies some constraints and defaults that are
263-specific to each image, so you must subclass it before using.
264-
265- >>> from lp.services.fields import (BaseImageUpload,
266- ... IconImageUpload)
267-
268-Note: the .bind method here is fetching the field from the IPerson schema
269-(which should be an IconImageUpload, a subclass of BaseImageUpload) and
270-binding it to Launchpad Administrators.
271+(KEEP_SAME_IMAGE) to indicate that the image should be kept, we need to
272+use a custom field (IconImageUpload) together with it. That field should
273+not be used directly, since it specifies some constraints and defaults
274+that are specific to each image, so you must subclass it before using.
275+
276+ >>> from lp.services.fields import (
277+ ... BaseImageUpload, IconImageUpload)
278+
279+Note: the .bind method here is fetching the field from the IPerson
280+schema (which should be an IconImageUpload, a subclass of
281+BaseImageUpload) and binding it to Launchpad Administrators.
282
283 >>> person_icon = IPerson['icon'].bind(admins_team)
284 >>> isinstance(person_icon, BaseImageUpload)
285 True
286+
287 >>> isinstance(person_icon, IconImageUpload)
288 True
289+
290 >>> person_icon.max_size
291 5120
292+
293 >>> person_icon.dimensions
294 (14, 14)
295
296-If we pass that special object (KEEP_SAME_IMAGE) to IconImageUpload's set()
297-method, the current image will be kept.
298+If we pass that special object (KEEP_SAME_IMAGE) to IconImageUpload's
299+set() method, the current image will be kept.
300
301 >>> admins_team.icon = getUtility(ILibraryFileAliasSet)[53]
302 >>> existing_img = admins_team.icon
303@@ -301,68 +232,20 @@
304 >>> person_icon.set(admins_team, fileupload)
305 >>> admins_team.icon is None
306 False
307+
308 >>> admins_team.icon == existing_img
309 False
310
311
312-# This section is disabled until we have implemented a similar but imrpved
313-# Wdiget, that lets you OPTIONALLY resize from the mugshot, OR upload a
314-# custom Logo
315-
316-#=== The LargeImageUpload field ===
317-#
318-#This is a special version of the BaseImageUpload one which knows how to handle
319-#the two images returned by GotchiTiedWithHeadingWidget. The first image is
320-#stored in the attribute the field represents and the other one is stored on
321-#an attribute called gotchi_heading of the same object. Any class in which this
322-#field is used must provide a gotchi_heading attribute.
323-#
324-# >>> from lp.services.fields import LargeImageUpload
325-# >>> person_gotchi.__class__ == LargeImageUpload
326-# True
327-#
328-# >>> existing_img = salgado.gotchi
329-# >>> existing_img is None
330-# False
331-#
332-# >>> person_gotchi.set(salgado, (KEEP_SAME_IMAGE, KEEP_SAME_IMAGE))
333-# >>> salgado.gotchi == existing_img
334-# True
335-#
336-# >>> person_gotchi.set(salgado, (None, None))
337-# >>> salgado.gotchi is None
338-#
339-#True
340-#
341-#Unlike the normal BaseImageUpload implementation, this one doesn't handle a
342-#single LibraryFileAlias --it only handles tuples of them.
343-#
344-# >>> person_gotchi.set(salgado, fileupload)
345-# Traceback (most recent call last):
346-# ...
347-# AssertionError
348-#
349-# >>> person_gotchi.set(
350-# ... salgado, (fileupload, getUtility(ILibraryFileAliasSet)[53]))
351-# >>> salgado.gotchi is None
352-# False
353-# >>> salgado.gotchi == existing_img
354-# False
355-#
356-# >>> salgado.gotchi_heading is None
357-# False
358-# >>> salgado.gotchi_heading == salgado.gotchi
359-# False
360-
361-
362-== Input validation ==
363+Input validation
364+----------------
365
366 The BaseImageUpload field expects an image with the exact dimensions and
367 within the stated constraints, so it won't accept anything else.
368
369-We will try submit a logo to the mugshot image upload widget. Since we have
370-an image with a byte size smaller than person_mugshot.max_size BUT dimensions
371-smaller than person_mugshot.dimensions, it must be rejected.
372+We will try submit a logo to the mugshot image upload widget. Since we
373+have an image with a byte size smaller than person_mugshot.max_size BUT
374+dimensions smaller than person_mugshot.dimensions, it must be rejected.
375
376 >>> import PIL.Image
377 >>> person_mugshot = IPerson['mugshot'].bind(salgado)
378@@ -373,6 +256,7 @@
379 >>> logo.filename = 'logo.png'
380 >>> logo.len <= person_mugshot.max_size
381 True
382+
383 >>> image = PIL.Image.open(logo)
384 >>> image.size <= person_mugshot.dimensions
385 True
386@@ -387,9 +271,8 @@
387 LaunchpadValidationError(u'\nThis image is not exactly
388 192x192\npixels in size.'))
389
390-
391-This is what we see when the image is the correct dimensions, and within the
392-max_size:
393+This is what we see when the image is the correct dimensions, and within
394+the max_size:
395
396 >>> mugshot_file_name = os.path.join(
397 ... os.path.dirname(canonical.launchpad.__file__),
398@@ -415,11 +298,12 @@
399 >>> fileupload = widget.getInputValue()
400 >>> fileupload.filename
401 u'mugshot.png'
402+
403 >>> fileupload.content.filesize == mugshot.len
404 True
405
406-If we change person_mugshot's max_size to be smaller than our test image, we'll
407-get a validation error.
408+If we change person_mugshot's max_size to be smaller than our test
409+image, we'll get a validation error.
410
411 >>> person_mugshot.max_size = mugshot.len - 1
412 >>> mugshot.seek(0)
413@@ -433,8 +317,8 @@
414 maximum allowed size in
415 bytes.'))
416
417-A similar error will be raised if the image's dimensions are bigger than the
418-maximum we allow.
419+A similar error will be raised if the image's dimensions are bigger than
420+the maximum we allow.
421
422 >>> person_mugshot.max_size = mugshot.len
423 >>> person_mugshot.dimensions = (image.size[0] - 1, image.size[1] + 1)
424@@ -459,26 +343,8 @@
425 LaunchpadValidationError(u'\nThis image is not exactly
426 193x191\npixels in size.'))
427
428-We also won't accept anything that is not an image; that is, we only accept
429-what can be parsed by the Python Imaging Library module.
430-
431- >>> mugshot = StringIO('foo bar bz')
432- >>> mugshot.filename = 'foo.jpg'
433- >>> form = {'field.mugshot.action': 'change',
434- ... 'field.mugshot.image': mugshot}
435- >>> widget = ImageChangeWidget(
436- ... person_mugshot, LaunchpadTestRequest(form=form), edit_style)
437- >>> widget.getInputValue()
438- Traceback (most recent call last):
439- ...
440- WidgetInputError: ('field.mugshot', u'Mugshot',
441- LaunchpadValidationError(u'\nThe file uploaded was not
442- recognized as an image;
443- please\ncheck it and
444- retry.'))
445-
446-Finally, if the user specifies the 'change' action he must also provide a file
447-to be uploaded.
448+Finally, if the user specifies the 'change' action he must also provide
449+a file to be uploaded.
450
451 >>> form = {'field.mugshot.action': 'change', 'field.mugshot.image': ''}
452 >>> widget = ImageChangeWidget(
453@@ -491,12 +357,12 @@
454 want to use.'))
455
456
457-== Non-exact Image Dimensions ==
458+Non-exact Image Dimensions
459+--------------------------
460
461-For some input fields, we don't require a particular size for an
462-image, but want to enforce a maximum size on the image. This can be
463-achieved by setting the exact_dimensions attribute of the field to
464-False:
465+For some input fields, we don't require a particular size for an image,
466+but want to enforce a maximum size on the image. This can be achieved
467+by setting the exact_dimensions attribute of the field to False:
468
469 >>> person_mugshot.exact_dimensions = False
470 >>> person_mugshot.dimensions = (64, 64)
471@@ -532,3 +398,5 @@
472 >>> fileupload = widget.getInputValue()
473 >>> fileupload.filename
474 u'mugshot.png'
475+
476+
477
478=== modified file 'lib/lp/services/fields/__init__.py'
479--- lib/lp/services/fields/__init__.py 2011-01-24 20:53:10 +0000
480+++ lib/lp/services/fields/__init__.py 2011-02-08 22:06:49 +0000
481@@ -717,7 +717,7 @@
482 This image exceeds the maximum allowed size in bytes.""")))
483 try:
484 pil_image = PIL.Image.open(StringIO(image))
485- except IOError:
486+ except (IOError, ValueError):
487 raise LaunchpadValidationError(_(dedent("""
488 The file uploaded was not recognized as an image; please
489 check it and retry.""")))
490
491=== modified file 'lib/lp/services/fields/tests/test_fields.py'
492--- lib/lp/services/fields/tests/test_fields.py 2011-01-06 19:29:11 +0000
493+++ lib/lp/services/fields/tests/test_fields.py 2011-02-08 22:06:49 +0000
494@@ -6,6 +6,7 @@
495 __metaclass__ = type
496
497 import datetime
498+from StringIO import StringIO
499 import time
500
501 from zope.interface import Interface
502@@ -15,6 +16,7 @@
503 from canonical.launchpad.validators import LaunchpadValidationError
504 from canonical.testing.layers import DatabaseFunctionalLayer
505 from lp.services.fields import (
506+ BaseImageUpload,
507 BlacklistableContentNameField,
508 FormattableDate,
509 StrippableText,
510@@ -128,3 +130,33 @@
511 date_value = u'fnord'
512 login_person(self.team.teamowner)
513 self.assertEqual(None, field.validate(date_value))
514+
515+
516+class TestBaseImageUpload(TestCase):
517+ """Test for the BaseImageUpload field."""
518+
519+ class ExampleImageUpload(BaseImageUpload):
520+ dimensions = (192, 192)
521+ max_size = 100*1024
522+
523+ def test_validation_corrupt_image(self):
524+ # ValueErrors raised by PIL become LaunchpadValidationErrors.
525+ field = self.ExampleImageUpload(default_image_resource='dummy')
526+ image = StringIO(
527+ '/* XPM */\n'
528+ 'static char *pixmap[] = {\n'
529+ '"32 32 253 2",\n'
530+ ' "00 c #01CAA3",\n'
531+ ' ".. s None c None",\n'
532+ '};')
533+ image.filename = 'foo.xpm'
534+ self.assertRaises(
535+ LaunchpadValidationError, field.validate, image)
536+
537+ def test_validation_non_image(self):
538+ # IOError raised by PIL become LaunchpadValidationErrors.
539+ field = self.ExampleImageUpload(default_image_resource='dummy')
540+ image = StringIO('foo bar bz')
541+ image.filename = 'foo.jpg'
542+ self.assertRaises(
543+ LaunchpadValidationError, field.validate, image)