Merge lp:~thumper/launchpad/fix-mantis-warnings-bug-218384 into lp:launchpad

Proposed by Tim Penhey
Status: Merged
Approved by: Robert Collins
Approved revision: no longer in the source branch.
Merged at revision: 12508
Proposed branch: lp:~thumper/launchpad/fix-mantis-warnings-bug-218384
Merge into: lp:launchpad
Diff against target: 464 lines (+221/-81)
7 files modified
lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.txt (+2/-2)
lib/lp/bugs/doc/externalbugtracker-trac.txt (+4/-4)
lib/lp/bugs/externalbugtracker/base.py (+3/-3)
lib/lp/bugs/externalbugtracker/mantis.py (+72/-68)
lib/lp/bugs/externalbugtracker/tests/test_externalbugtracker.py (+28/-2)
lib/lp/bugs/externalbugtracker/tests/test_mantis.py (+110/-0)
lib/lp/bugs/tests/externalbugtracker.py (+2/-2)
To merge this branch: bzr merge lp:~thumper/launchpad/fix-mantis-warnings-bug-218384
Reviewer Review Type Date Requested Status
Robert Collins (community) Approve
William Grant code* Approve
Review via email: mp+51839@code.launchpad.net

This proposal supersedes a proposal from 2011-03-01.

Commit message

[r=lifeless,wgrant][bug=214982,218384] Stop two Mantis OOPSes that occur during batch processing.

Description of the change

This branch fixes two old Mantis issues.

HTTPError when trying to determine if the remote site can
handle batches, and the oops handled when trying to use
a non-existant warning method.

The HTTPError is handled by wrapping the urlopen method with
the _fetchPage method, which translates the urllib2 errors to
a BugTrackerConnectionError.

Fixing the warning usage was trivial, but I refactored the
code somewhat to make testing easier. A class was created
that is responsible for parsing the CVS data.

Also an unused method (_getStatusFromCSV) was deleted.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) : Posted in a previous version of this proposal
review: Approve (code*)
Revision history for this message
Robert Collins (lifeless) : Posted in a previous version of this proposal
review: Approve
Revision history for this message
William Grant (wgrant) wrote :

As long as you land it through ec2 this time :)

review: Approve (code*)
Revision history for this message
Robert Collins (lifeless) wrote :

Sure, lets try this again.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.txt'
2--- lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.txt 2011-02-07 20:41:06 +0000
3+++ lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.txt 2011-03-02 00:14:04 +0000
4@@ -40,7 +40,7 @@
5 ... self.headers = {}
6
7 >>> class TestTracLPPlugin(TracLPPlugin):
8- ... def urlopen(self, url):
9+ ... def urlopen(self, url, data=None):
10 ... with lp_dbuser():
11 ... base_auth_url = urlappend(self.baseurl, 'launchpad-auth')
12 ... if not url.startswith(base_auth_url + '/'):
13@@ -133,7 +133,7 @@
14
15 >>> from urllib2 import HTTPError
16 >>> class TestFailingTracLPPlugin(TracLPPlugin):
17- ... def urlopen(self, url):
18+ ... def urlopen(self, url, data=None):
19 ... raise HTTPError(url, 401, "Denied!", {}, None)
20
21 >>> test_trac = TestFailingTracLPPlugin(
22
23=== modified file 'lib/lp/bugs/doc/externalbugtracker-trac.txt'
24--- lib/lp/bugs/doc/externalbugtracker-trac.txt 2011-01-19 00:10:48 +0000
25+++ lib/lp/bugs/doc/externalbugtracker-trac.txt 2011-03-02 00:14:04 +0000
26@@ -33,7 +33,7 @@
27
28 >>> import urllib2
29 >>> class TracHavingLPPlugin401(Trac):
30- ... def urlopen(self, url):
31+ ... def urlopen(self, url, data=None):
32 ... print url
33 ... raise urllib2.HTTPError(
34 ... url, 401, "Unauthorized", None, None)
35@@ -60,13 +60,13 @@
36 >>> from urllib import addinfourl
37
38 >>> class TracReturning200ForPageNotFound(Trac):
39- ... def urlopen(self, url):
40+ ... def urlopen(self, url, data=None):
41 ... print url
42 ... return addinfourl(
43 ... StringIO(''), HTTPMessage(StringIO('')), url)
44
45 >>> class TracHavingLPPlugin200(Trac):
46- ... def urlopen(self, url):
47+ ... def urlopen(self, url, data=None):
48 ... print url
49 ... return addinfourl(
50 ... StringIO(''), HTTPMessage(
51@@ -99,7 +99,7 @@
52 If a 404 is returned, the normal Trac instance is returned.
53
54 >>> class TracNotHavingLPPlugin(Trac):
55- ... def urlopen(self, url):
56+ ... def urlopen(self, url, data=None):
57 ... print url
58 ... raise urllib2.HTTPError(
59 ... url, 404, "Not found", None, None)
60
61=== modified file 'lib/lp/bugs/externalbugtracker/base.py'
62--- lib/lp/bugs/externalbugtracker/base.py 2011-03-01 11:49:59 +0000
63+++ lib/lp/bugs/externalbugtracker/base.py 2011-03-02 00:14:04 +0000
64@@ -238,13 +238,13 @@
65 """
66 return None
67
68- def _fetchPage(self, page):
69+ def _fetchPage(self, page, data=None):
70 """Fetch a page from the remote server.
71
72 A BugTrackerConnectError will be raised if anything goes wrong.
73 """
74 try:
75- return self.urlopen(page)
76+ return self.urlopen(page, data)
77 except (urllib2.HTTPError, urllib2.URLError), val:
78 raise BugTrackerConnectError(self.baseurl, val)
79
80@@ -260,7 +260,7 @@
81 def _post(self, url, data):
82 """Post to a given URL."""
83 request = urllib2.Request(url, headers={'User-agent': LP_USER_AGENT})
84- return self.urlopen(request, data=data)
85+ return self._fetchPage(request, data=data)
86
87 def _postPage(self, page, form, repost_on_redirect=False):
88 """POST to the specified page and form.
89
90=== modified file 'lib/lp/bugs/externalbugtracker/mantis.py'
91--- lib/lp/bugs/externalbugtracker/mantis.py 2011-03-01 11:49:59 +0000
92+++ lib/lp/bugs/externalbugtracker/mantis.py 2011-03-02 00:14:04 +0000
93@@ -8,6 +8,7 @@
94
95 import cgi
96 import csv
97+import logging
98 import urllib
99 import urllib2
100 from urlparse import urlunparse
101@@ -98,6 +99,70 @@
102 self, request, fp, code, msg, hdrs, self.rewrite_url(new_url))
103
104
105+class MantisBugBatchParser:
106+ """A class that parses the batch of bug data.
107+
108+ Using the CSV reader is pretty much essential since the data that comes
109+ back can include title text which can in turn contain field separators.
110+ You don't want to handle the unquoting yourself.
111+ """
112+
113+ def __init__(self, csv_data, logger):
114+ # Clean out stray, unquoted newlines inside csv_data to avoid the CSV
115+ # module blowing up. IDEA: perhaps if the size of csv_data is large
116+ # in the future, this could be moved into a generator.
117+ csv_data = [s.replace("\r", "") for s in csv_data]
118+ csv_data = [s.replace("\n", "") for s in csv_data]
119+ self.reader = csv.reader(csv_data)
120+ self.logger = logger
121+
122+ def processCSVBugLine(self, bug_line, headers):
123+ """Processes a single line of the CSV."""
124+ bug = {}
125+ for index, header in enumerate(headers):
126+ try:
127+ data = bug_line[index]
128+ except IndexError:
129+ self.logger.warning("Line %r incomplete." % bug_line)
130+ return None
131+ bug[header] = data
132+ try:
133+ bug['id'] = int(bug['id'])
134+ except ValueError:
135+ self.logger.warning("Encountered invalid bug ID: %r." % bug['id'])
136+ return None
137+ return bug
138+
139+ def parseHeaderLine(self, reader):
140+ # The first line of the CSV file is the header. We need to read
141+ # it because different Mantis instances have different header
142+ # ordering and even different columns in the export.
143+ try:
144+ headers = [h.lower() for h in reader.next()]
145+ except StopIteration:
146+ raise UnparsableBugData("Missing header line")
147+ missing_headers = [
148+ name for name in ('id', 'status', 'resolution')
149+ if name not in headers]
150+ if missing_headers:
151+ raise UnparsableBugData(
152+ "CSV header %r missing fields: %r" % (
153+ headers, missing_headers))
154+ return headers
155+
156+ def getBugs(self):
157+ headers = self.parseHeaderLine(self.reader)
158+ bugs = {}
159+ try:
160+ for bug_line in self.reader:
161+ bug = self.processCSVBugLine(bug_line, headers)
162+ if bug is not None:
163+ bugs[bug['id']] = bug
164+ return bugs
165+ except csv.Error, error:
166+ raise UnparsableBugData("Exception parsing CSV file: %s." % error)
167+
168+
169 class Mantis(ExternalBugTracker):
170 """An `ExternalBugTracker` for dealing with Mantis instances.
171
172@@ -114,6 +179,7 @@
173 self._cookie_handler = urllib2.HTTPCookieProcessor()
174 self._opener = urllib2.build_opener(
175 self._cookie_handler, MantisLoginHandler())
176+ self._logger = logging.getLogger()
177
178 @ensure_no_transaction
179 def urlopen(self, request, data=None):
180@@ -178,7 +244,10 @@
181 'search': '',
182 'filter': 'Apply Filter',
183 }
184- self.page = self._postPage("view_all_set.php?f=3", data)
185+ try:
186+ self._postPage("view_all_set.php?f=3", data)
187+ except BugTrackerConnectError:
188+ return None
189
190 # Finally grab the full CSV export, which uses the
191 # MANTIS_VIEW_ALL_COOKIE set in the previous step to specify
192@@ -275,65 +344,8 @@
193 if not csv_data:
194 raise UnparsableBugData("Empty CSV for %s" % self.baseurl)
195
196- # Clean out stray, unquoted newlines inside csv_data to avoid
197- # the CSV module blowing up.
198- csv_data = [s.replace("\r", "") for s in csv_data]
199- csv_data = [s.replace("\n", "") for s in csv_data]
200-
201- # The first line of the CSV file is the header. We need to read
202- # it because different Mantis instances have different header
203- # ordering and even different columns in the export.
204- self.headers = [h.lower() for h in csv_data.pop(0).split(",")]
205- if len(self.headers) < 2:
206- raise UnparsableBugData("CSV header mangled: %r" % self.headers)
207-
208- if not csv_data:
209- # A file with a header and no bugs is also useless.
210- raise UnparsableBugData("CSV for %s contained no bugs!"
211- % self.baseurl)
212-
213- try:
214- bugs = {}
215- # Using the CSV reader is pretty much essential since the
216- # data that comes back can include title text which can in
217- # turn contain field separators -- you don't want to handle
218- # the unquoting yourself.
219- for bug_line in csv.reader(csv_data):
220- bug = self._processCSVBugLine(bug_line)
221- bugs[int(bug['id'])] = bug
222-
223- return bugs
224-
225- except csv.Error, error:
226- raise UnparsableBugData("Exception parsing CSV file: %s." % error)
227-
228- def _processCSVBugLine(self, bug_line):
229- """Processes a single line of the CSV.
230-
231- Adds the bug it represents to self.bugs.
232- """
233- required_fields = ['id', 'status', 'resolution']
234- bug = {}
235- for header in self.headers:
236- try:
237- data = bug_line.pop(0)
238- except IndexError:
239- self.warning("Line '%r' incomplete." % bug_line)
240- return
241- bug[header] = data
242- for field in required_fields:
243- if field not in bug:
244- self.warning("Bug %s lacked field '%r'." % (bug['id'], field))
245- return
246- try:
247- # See __init__ for an explanation of why we use integer
248- # IDs in the internal data structure.
249- bug_id = int(bug['id'])
250- except ValueError:
251- self.warning("Encountered invalid bug ID: %r." % bug['id'])
252- return
253-
254- return bug
255+ parser = MantisBugBatchParser(csv_data, self._logger)
256+ return parser.getBugs()
257
258 def _checkForApplicationError(self, page_soup):
259 """If Mantis does not find the bug it still returns a 200 OK
260@@ -467,14 +479,6 @@
261 # it makes display of the data nicer.
262 return "%(status)s: %(resolution)s" % bug
263
264- def _getStatusFromCSV(self, bug_id):
265- try:
266- bug = self.bugs[int(bug_id)]
267- except KeyError:
268- raise BugNotFound(bug_id)
269- else:
270- return bug['status'], bug['resolution']
271-
272 def convertRemoteImportance(self, remote_importance):
273 """See `ExternalBugTracker`.
274
275
276=== modified file 'lib/lp/bugs/externalbugtracker/tests/test_externalbugtracker.py'
277--- lib/lp/bugs/externalbugtracker/tests/test_externalbugtracker.py 2011-03-01 11:49:59 +0000
278+++ lib/lp/bugs/externalbugtracker/tests/test_externalbugtracker.py 2011-03-02 00:14:04 +0000
279@@ -6,17 +6,25 @@
280 __metaclass__ = type
281
282 from StringIO import StringIO
283+import urllib2
284
285 from zope.interface import implements
286
287-from lp.bugs.externalbugtracker.base import ExternalBugTracker
288+from canonical.testing.layers import ZopelessLayer
289+from lp.bugs.externalbugtracker.base import (
290+ BugTrackerConnectError,
291+ ExternalBugTracker,
292+ )
293 from lp.bugs.externalbugtracker.debbugs import DebBugs
294 from lp.bugs.interfaces.externalbugtracker import (
295 ISupportsBackLinking,
296 ISupportsCommentImport,
297 ISupportsCommentPushing,
298 )
299-from lp.testing import TestCase
300+from lp.testing import (
301+ monkey_patch,
302+ TestCase,
303+ )
304 from lp.testing.fakemethod import FakeMethod
305
306
307@@ -144,3 +152,21 @@
308 self.assertEqual(2, bugtracker._post.call_count)
309 last_args, last_kwargs = bugtracker._post.calls[-1]
310 self.assertEqual((fake_form.url, ), last_args)
311+
312+
313+class TestExternalBugTracker(TestCase):
314+ """Tests for various methods of the ExternalBugTracker."""
315+
316+ layer = ZopelessLayer
317+
318+ def test_post_raises_on_404(self):
319+ # When posting, a 404 is converted to a BugTrackerConnectError.
320+ base_url = "http://example.com/"
321+ bugtracker = ExternalBugTracker(base_url)
322+ def raise404(request, data):
323+ raise urllib2.HTTPError('url', 404, 'Not Found', None, None)
324+ with monkey_patch(urllib2, urlopen=raise404):
325+ self.assertRaises(
326+ BugTrackerConnectError,
327+ bugtracker._post,
328+ 'some-url', {'post-data': 'here'})
329
330=== added file 'lib/lp/bugs/externalbugtracker/tests/test_mantis.py'
331--- lib/lp/bugs/externalbugtracker/tests/test_mantis.py 1970-01-01 00:00:00 +0000
332+++ lib/lp/bugs/externalbugtracker/tests/test_mantis.py 2011-03-02 00:14:04 +0000
333@@ -0,0 +1,110 @@
334+# Copyright 2011 Canonical Ltd. This software is licensed under the
335+# GNU Affero General Public License version 3 (see the file LICENSE).
336+
337+"""Tests for the Mantis BugTracker."""
338+
339+__metaclass__ = type
340+
341+from testtools.matchers import Equals, Is
342+import urllib2
343+
344+from canonical.testing.layers import ZopelessLayer
345+from lp.bugs.externalbugtracker import UnparsableBugData
346+from lp.bugs.externalbugtracker.mantis import (
347+ Mantis,
348+ MantisBugBatchParser,
349+ )
350+from lp.testing import (
351+ monkey_patch,
352+ TestCase,
353+ )
354+from lp.services.log.logger import BufferLogger
355+
356+
357+class TestMantisBugBatchParser(TestCase):
358+ """Test the MantisBugBatchParser class."""
359+
360+ def setUp(self):
361+ super(TestMantisBugBatchParser, self).setUp()
362+ self.logger = BufferLogger()
363+
364+ def test_empty(self):
365+ data = []
366+ parser = MantisBugBatchParser(data, self.logger)
367+ exc = self.assertRaises(
368+ UnparsableBugData,
369+ parser.getBugs)
370+ self.assertThat(
371+ str(exc), Equals("Missing header line"))
372+
373+ def test_missing_headers(self):
374+ data = ['some,headers']
375+ parser = MantisBugBatchParser(data, self.logger)
376+ exc = self.assertRaises(
377+ UnparsableBugData,
378+ parser.getBugs)
379+ self.assertThat(
380+ str(exc),
381+ Equals("CSV header ['some', 'headers'] missing fields:"
382+ " ['id', 'status', 'resolution']"))
383+
384+ def test_missing_some_headers(self):
385+ data = ['some,headers,status,resolution']
386+ parser = MantisBugBatchParser(data, self.logger)
387+ exc = self.assertRaises(
388+ UnparsableBugData,
389+ parser.getBugs)
390+ self.assertThat(
391+ str(exc),
392+ Equals("CSV header ['some', 'headers', 'status', 'resolution'] "
393+ "missing fields: ['id']"))
394+
395+ def test_no_bugs(self):
396+ data = ['other,fields,id,status,resolution']
397+ parser = MantisBugBatchParser(data, self.logger)
398+ self.assertThat(parser.getBugs(), Equals({}))
399+
400+ def test_passing(self):
401+ data = ['ignored,id,resolution,status',
402+ 'foo,42,not,complete',
403+ 'boo,13,,confirmed']
404+ parser = MantisBugBatchParser(data, self.logger)
405+ bug_42 = dict(
406+ id=42, status='complete', resolution='not', ignored='foo')
407+ bug_13 = dict(
408+ id=13, status='confirmed', resolution='', ignored='boo')
409+ self.assertThat(parser.getBugs(), Equals({42: bug_42, 13: bug_13}))
410+
411+ def test_incomplete_line(self):
412+ data = ['ignored,id,resolution,status',
413+ '42,not,complete']
414+ parser = MantisBugBatchParser(data, self.logger)
415+ self.assertThat(parser.getBugs(), Equals({}))
416+ log = self.logger.getLogBuffer()
417+ self.assertThat(
418+ log, Equals("WARNING Line ['42', 'not', 'complete'] incomplete.\n"))
419+
420+ def test_non_integer_id(self):
421+ data = ['ignored,id,resolution,status',
422+ 'foo,bar,not,complete']
423+ parser = MantisBugBatchParser(data, self.logger)
424+ self.assertThat(parser.getBugs(), Equals({}))
425+ log = self.logger.getLogBuffer()
426+ self.assertThat(
427+ log, Equals("WARNING Encountered invalid bug ID: 'bar'.\n"))
428+
429+
430+class TestMantisBugTracker(TestCase):
431+ """Tests for various methods of the Manits bug tracker."""
432+
433+ layer = ZopelessLayer
434+
435+ def test_csv_data_on_post_404(self):
436+ # If the 'view_all_set.php' request raises a 404, then the csv_data
437+ # attribute is None.
438+ base_url = "http://example.com/"
439+ def raise404(self, request, data):
440+ raise urllib2.HTTPError('url', 404, 'Not Found', None, None)
441+ with monkey_patch(Mantis, urlopen=raise404):
442+ bugtracker = Mantis(base_url)
443+ self.assertThat(bugtracker.csv_data, Is(None))
444
445=== modified file 'lib/lp/bugs/tests/externalbugtracker.py'
446--- lib/lp/bugs/tests/externalbugtracker.py 2011-02-09 11:37:19 +0000
447+++ lib/lp/bugs/tests/externalbugtracker.py 2011-03-02 00:14:04 +0000
448@@ -1149,7 +1149,7 @@
449 """See `Trac`."""
450 return self.supports_single_exports
451
452- def urlopen(self, url):
453+ def urlopen(self, url, data=None):
454 file_path = os.path.join(os.path.dirname(__file__), 'testfiles')
455
456 if self.trace_calls:
457@@ -1489,7 +1489,7 @@
458 batch_size = None
459 trace_calls = False
460
461- def urlopen(self, url):
462+ def urlopen(self, url, data=None):
463 if self.trace_calls:
464 print "CALLED urlopen(%r)" % (url)
465