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
=== modified file 'lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.txt'
--- lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.txt 2011-02-07 20:41:06 +0000
+++ lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.txt 2011-03-02 00:14:04 +0000
@@ -40,7 +40,7 @@
40 ... self.headers = {}40 ... self.headers = {}
4141
42 >>> class TestTracLPPlugin(TracLPPlugin):42 >>> class TestTracLPPlugin(TracLPPlugin):
43 ... def urlopen(self, url):43 ... def urlopen(self, url, data=None):
44 ... with lp_dbuser():44 ... with lp_dbuser():
45 ... base_auth_url = urlappend(self.baseurl, 'launchpad-auth')45 ... base_auth_url = urlappend(self.baseurl, 'launchpad-auth')
46 ... if not url.startswith(base_auth_url + '/'):46 ... if not url.startswith(base_auth_url + '/'):
@@ -133,7 +133,7 @@
133133
134 >>> from urllib2 import HTTPError134 >>> from urllib2 import HTTPError
135 >>> class TestFailingTracLPPlugin(TracLPPlugin):135 >>> class TestFailingTracLPPlugin(TracLPPlugin):
136 ... def urlopen(self, url):136 ... def urlopen(self, url, data=None):
137 ... raise HTTPError(url, 401, "Denied!", {}, None)137 ... raise HTTPError(url, 401, "Denied!", {}, None)
138138
139 >>> test_trac = TestFailingTracLPPlugin(139 >>> test_trac = TestFailingTracLPPlugin(
140140
=== modified file 'lib/lp/bugs/doc/externalbugtracker-trac.txt'
--- lib/lp/bugs/doc/externalbugtracker-trac.txt 2011-01-19 00:10:48 +0000
+++ lib/lp/bugs/doc/externalbugtracker-trac.txt 2011-03-02 00:14:04 +0000
@@ -33,7 +33,7 @@
3333
34 >>> import urllib234 >>> import urllib2
35 >>> class TracHavingLPPlugin401(Trac):35 >>> class TracHavingLPPlugin401(Trac):
36 ... def urlopen(self, url):36 ... def urlopen(self, url, data=None):
37 ... print url37 ... print url
38 ... raise urllib2.HTTPError(38 ... raise urllib2.HTTPError(
39 ... url, 401, "Unauthorized", None, None)39 ... url, 401, "Unauthorized", None, None)
@@ -60,13 +60,13 @@
60 >>> from urllib import addinfourl60 >>> from urllib import addinfourl
6161
62 >>> class TracReturning200ForPageNotFound(Trac):62 >>> class TracReturning200ForPageNotFound(Trac):
63 ... def urlopen(self, url):63 ... def urlopen(self, url, data=None):
64 ... print url64 ... print url
65 ... return addinfourl(65 ... return addinfourl(
66 ... StringIO(''), HTTPMessage(StringIO('')), url)66 ... StringIO(''), HTTPMessage(StringIO('')), url)
6767
68 >>> class TracHavingLPPlugin200(Trac):68 >>> class TracHavingLPPlugin200(Trac):
69 ... def urlopen(self, url):69 ... def urlopen(self, url, data=None):
70 ... print url70 ... print url
71 ... return addinfourl(71 ... return addinfourl(
72 ... StringIO(''), HTTPMessage(72 ... StringIO(''), HTTPMessage(
@@ -99,7 +99,7 @@
99If a 404 is returned, the normal Trac instance is returned.99If a 404 is returned, the normal Trac instance is returned.
100100
101 >>> class TracNotHavingLPPlugin(Trac):101 >>> class TracNotHavingLPPlugin(Trac):
102 ... def urlopen(self, url):102 ... def urlopen(self, url, data=None):
103 ... print url103 ... print url
104 ... raise urllib2.HTTPError(104 ... raise urllib2.HTTPError(
105 ... url, 404, "Not found", None, None)105 ... url, 404, "Not found", None, None)
106106
=== modified file 'lib/lp/bugs/externalbugtracker/base.py'
--- lib/lp/bugs/externalbugtracker/base.py 2011-03-01 11:49:59 +0000
+++ lib/lp/bugs/externalbugtracker/base.py 2011-03-02 00:14:04 +0000
@@ -238,13 +238,13 @@
238 """238 """
239 return None239 return None
240240
241 def _fetchPage(self, page):241 def _fetchPage(self, page, data=None):
242 """Fetch a page from the remote server.242 """Fetch a page from the remote server.
243243
244 A BugTrackerConnectError will be raised if anything goes wrong.244 A BugTrackerConnectError will be raised if anything goes wrong.
245 """245 """
246 try:246 try:
247 return self.urlopen(page)247 return self.urlopen(page, data)
248 except (urllib2.HTTPError, urllib2.URLError), val:248 except (urllib2.HTTPError, urllib2.URLError), val:
249 raise BugTrackerConnectError(self.baseurl, val)249 raise BugTrackerConnectError(self.baseurl, val)
250250
@@ -260,7 +260,7 @@
260 def _post(self, url, data):260 def _post(self, url, data):
261 """Post to a given URL."""261 """Post to a given URL."""
262 request = urllib2.Request(url, headers={'User-agent': LP_USER_AGENT})262 request = urllib2.Request(url, headers={'User-agent': LP_USER_AGENT})
263 return self.urlopen(request, data=data)263 return self._fetchPage(request, data=data)
264264
265 def _postPage(self, page, form, repost_on_redirect=False):265 def _postPage(self, page, form, repost_on_redirect=False):
266 """POST to the specified page and form.266 """POST to the specified page and form.
267267
=== modified file 'lib/lp/bugs/externalbugtracker/mantis.py'
--- lib/lp/bugs/externalbugtracker/mantis.py 2011-03-01 11:49:59 +0000
+++ lib/lp/bugs/externalbugtracker/mantis.py 2011-03-02 00:14:04 +0000
@@ -8,6 +8,7 @@
88
9import cgi9import cgi
10import csv10import csv
11import logging
11import urllib12import urllib
12import urllib213import urllib2
13from urlparse import urlunparse14from urlparse import urlunparse
@@ -98,6 +99,70 @@
98 self, request, fp, code, msg, hdrs, self.rewrite_url(new_url))99 self, request, fp, code, msg, hdrs, self.rewrite_url(new_url))
99100
100101
102class MantisBugBatchParser:
103 """A class that parses the batch of bug data.
104
105 Using the CSV reader is pretty much essential since the data that comes
106 back can include title text which can in turn contain field separators.
107 You don't want to handle the unquoting yourself.
108 """
109
110 def __init__(self, csv_data, logger):
111 # Clean out stray, unquoted newlines inside csv_data to avoid the CSV
112 # module blowing up. IDEA: perhaps if the size of csv_data is large
113 # in the future, this could be moved into a generator.
114 csv_data = [s.replace("\r", "") for s in csv_data]
115 csv_data = [s.replace("\n", "") for s in csv_data]
116 self.reader = csv.reader(csv_data)
117 self.logger = logger
118
119 def processCSVBugLine(self, bug_line, headers):
120 """Processes a single line of the CSV."""
121 bug = {}
122 for index, header in enumerate(headers):
123 try:
124 data = bug_line[index]
125 except IndexError:
126 self.logger.warning("Line %r incomplete." % bug_line)
127 return None
128 bug[header] = data
129 try:
130 bug['id'] = int(bug['id'])
131 except ValueError:
132 self.logger.warning("Encountered invalid bug ID: %r." % bug['id'])
133 return None
134 return bug
135
136 def parseHeaderLine(self, reader):
137 # The first line of the CSV file is the header. We need to read
138 # it because different Mantis instances have different header
139 # ordering and even different columns in the export.
140 try:
141 headers = [h.lower() for h in reader.next()]
142 except StopIteration:
143 raise UnparsableBugData("Missing header line")
144 missing_headers = [
145 name for name in ('id', 'status', 'resolution')
146 if name not in headers]
147 if missing_headers:
148 raise UnparsableBugData(
149 "CSV header %r missing fields: %r" % (
150 headers, missing_headers))
151 return headers
152
153 def getBugs(self):
154 headers = self.parseHeaderLine(self.reader)
155 bugs = {}
156 try:
157 for bug_line in self.reader:
158 bug = self.processCSVBugLine(bug_line, headers)
159 if bug is not None:
160 bugs[bug['id']] = bug
161 return bugs
162 except csv.Error, error:
163 raise UnparsableBugData("Exception parsing CSV file: %s." % error)
164
165
101class Mantis(ExternalBugTracker):166class Mantis(ExternalBugTracker):
102 """An `ExternalBugTracker` for dealing with Mantis instances.167 """An `ExternalBugTracker` for dealing with Mantis instances.
103168
@@ -114,6 +179,7 @@
114 self._cookie_handler = urllib2.HTTPCookieProcessor()179 self._cookie_handler = urllib2.HTTPCookieProcessor()
115 self._opener = urllib2.build_opener(180 self._opener = urllib2.build_opener(
116 self._cookie_handler, MantisLoginHandler())181 self._cookie_handler, MantisLoginHandler())
182 self._logger = logging.getLogger()
117183
118 @ensure_no_transaction184 @ensure_no_transaction
119 def urlopen(self, request, data=None):185 def urlopen(self, request, data=None):
@@ -178,7 +244,10 @@
178 'search': '',244 'search': '',
179 'filter': 'Apply Filter',245 'filter': 'Apply Filter',
180 }246 }
181 self.page = self._postPage("view_all_set.php?f=3", data)247 try:
248 self._postPage("view_all_set.php?f=3", data)
249 except BugTrackerConnectError:
250 return None
182251
183 # Finally grab the full CSV export, which uses the252 # Finally grab the full CSV export, which uses the
184 # MANTIS_VIEW_ALL_COOKIE set in the previous step to specify253 # MANTIS_VIEW_ALL_COOKIE set in the previous step to specify
@@ -275,65 +344,8 @@
275 if not csv_data:344 if not csv_data:
276 raise UnparsableBugData("Empty CSV for %s" % self.baseurl)345 raise UnparsableBugData("Empty CSV for %s" % self.baseurl)
277346
278 # Clean out stray, unquoted newlines inside csv_data to avoid347 parser = MantisBugBatchParser(csv_data, self._logger)
279 # the CSV module blowing up.348 return parser.getBugs()
280 csv_data = [s.replace("\r", "") for s in csv_data]
281 csv_data = [s.replace("\n", "") for s in csv_data]
282
283 # The first line of the CSV file is the header. We need to read
284 # it because different Mantis instances have different header
285 # ordering and even different columns in the export.
286 self.headers = [h.lower() for h in csv_data.pop(0).split(",")]
287 if len(self.headers) < 2:
288 raise UnparsableBugData("CSV header mangled: %r" % self.headers)
289
290 if not csv_data:
291 # A file with a header and no bugs is also useless.
292 raise UnparsableBugData("CSV for %s contained no bugs!"
293 % self.baseurl)
294
295 try:
296 bugs = {}
297 # Using the CSV reader is pretty much essential since the
298 # data that comes back can include title text which can in
299 # turn contain field separators -- you don't want to handle
300 # the unquoting yourself.
301 for bug_line in csv.reader(csv_data):
302 bug = self._processCSVBugLine(bug_line)
303 bugs[int(bug['id'])] = bug
304
305 return bugs
306
307 except csv.Error, error:
308 raise UnparsableBugData("Exception parsing CSV file: %s." % error)
309
310 def _processCSVBugLine(self, bug_line):
311 """Processes a single line of the CSV.
312
313 Adds the bug it represents to self.bugs.
314 """
315 required_fields = ['id', 'status', 'resolution']
316 bug = {}
317 for header in self.headers:
318 try:
319 data = bug_line.pop(0)
320 except IndexError:
321 self.warning("Line '%r' incomplete." % bug_line)
322 return
323 bug[header] = data
324 for field in required_fields:
325 if field not in bug:
326 self.warning("Bug %s lacked field '%r'." % (bug['id'], field))
327 return
328 try:
329 # See __init__ for an explanation of why we use integer
330 # IDs in the internal data structure.
331 bug_id = int(bug['id'])
332 except ValueError:
333 self.warning("Encountered invalid bug ID: %r." % bug['id'])
334 return
335
336 return bug
337349
338 def _checkForApplicationError(self, page_soup):350 def _checkForApplicationError(self, page_soup):
339 """If Mantis does not find the bug it still returns a 200 OK351 """If Mantis does not find the bug it still returns a 200 OK
@@ -467,14 +479,6 @@
467 # it makes display of the data nicer.479 # it makes display of the data nicer.
468 return "%(status)s: %(resolution)s" % bug480 return "%(status)s: %(resolution)s" % bug
469481
470 def _getStatusFromCSV(self, bug_id):
471 try:
472 bug = self.bugs[int(bug_id)]
473 except KeyError:
474 raise BugNotFound(bug_id)
475 else:
476 return bug['status'], bug['resolution']
477
478 def convertRemoteImportance(self, remote_importance):482 def convertRemoteImportance(self, remote_importance):
479 """See `ExternalBugTracker`.483 """See `ExternalBugTracker`.
480484
481485
=== modified file 'lib/lp/bugs/externalbugtracker/tests/test_externalbugtracker.py'
--- lib/lp/bugs/externalbugtracker/tests/test_externalbugtracker.py 2011-03-01 11:49:59 +0000
+++ lib/lp/bugs/externalbugtracker/tests/test_externalbugtracker.py 2011-03-02 00:14:04 +0000
@@ -6,17 +6,25 @@
6__metaclass__ = type6__metaclass__ = type
77
8from StringIO import StringIO8from StringIO import StringIO
9import urllib2
910
10from zope.interface import implements11from zope.interface import implements
1112
12from lp.bugs.externalbugtracker.base import ExternalBugTracker13from canonical.testing.layers import ZopelessLayer
14from lp.bugs.externalbugtracker.base import (
15 BugTrackerConnectError,
16 ExternalBugTracker,
17 )
13from lp.bugs.externalbugtracker.debbugs import DebBugs18from lp.bugs.externalbugtracker.debbugs import DebBugs
14from lp.bugs.interfaces.externalbugtracker import (19from lp.bugs.interfaces.externalbugtracker import (
15 ISupportsBackLinking,20 ISupportsBackLinking,
16 ISupportsCommentImport,21 ISupportsCommentImport,
17 ISupportsCommentPushing,22 ISupportsCommentPushing,
18 )23 )
19from lp.testing import TestCase24from lp.testing import (
25 monkey_patch,
26 TestCase,
27 )
20from lp.testing.fakemethod import FakeMethod28from lp.testing.fakemethod import FakeMethod
2129
2230
@@ -144,3 +152,21 @@
144 self.assertEqual(2, bugtracker._post.call_count)152 self.assertEqual(2, bugtracker._post.call_count)
145 last_args, last_kwargs = bugtracker._post.calls[-1]153 last_args, last_kwargs = bugtracker._post.calls[-1]
146 self.assertEqual((fake_form.url, ), last_args)154 self.assertEqual((fake_form.url, ), last_args)
155
156
157class TestExternalBugTracker(TestCase):
158 """Tests for various methods of the ExternalBugTracker."""
159
160 layer = ZopelessLayer
161
162 def test_post_raises_on_404(self):
163 # When posting, a 404 is converted to a BugTrackerConnectError.
164 base_url = "http://example.com/"
165 bugtracker = ExternalBugTracker(base_url)
166 def raise404(request, data):
167 raise urllib2.HTTPError('url', 404, 'Not Found', None, None)
168 with monkey_patch(urllib2, urlopen=raise404):
169 self.assertRaises(
170 BugTrackerConnectError,
171 bugtracker._post,
172 'some-url', {'post-data': 'here'})
147173
=== added file 'lib/lp/bugs/externalbugtracker/tests/test_mantis.py'
--- lib/lp/bugs/externalbugtracker/tests/test_mantis.py 1970-01-01 00:00:00 +0000
+++ lib/lp/bugs/externalbugtracker/tests/test_mantis.py 2011-03-02 00:14:04 +0000
@@ -0,0 +1,110 @@
1# Copyright 2011 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Tests for the Mantis BugTracker."""
5
6__metaclass__ = type
7
8from testtools.matchers import Equals, Is
9import urllib2
10
11from canonical.testing.layers import ZopelessLayer
12from lp.bugs.externalbugtracker import UnparsableBugData
13from lp.bugs.externalbugtracker.mantis import (
14 Mantis,
15 MantisBugBatchParser,
16 )
17from lp.testing import (
18 monkey_patch,
19 TestCase,
20 )
21from lp.services.log.logger import BufferLogger
22
23
24class TestMantisBugBatchParser(TestCase):
25 """Test the MantisBugBatchParser class."""
26
27 def setUp(self):
28 super(TestMantisBugBatchParser, self).setUp()
29 self.logger = BufferLogger()
30
31 def test_empty(self):
32 data = []
33 parser = MantisBugBatchParser(data, self.logger)
34 exc = self.assertRaises(
35 UnparsableBugData,
36 parser.getBugs)
37 self.assertThat(
38 str(exc), Equals("Missing header line"))
39
40 def test_missing_headers(self):
41 data = ['some,headers']
42 parser = MantisBugBatchParser(data, self.logger)
43 exc = self.assertRaises(
44 UnparsableBugData,
45 parser.getBugs)
46 self.assertThat(
47 str(exc),
48 Equals("CSV header ['some', 'headers'] missing fields:"
49 " ['id', 'status', 'resolution']"))
50
51 def test_missing_some_headers(self):
52 data = ['some,headers,status,resolution']
53 parser = MantisBugBatchParser(data, self.logger)
54 exc = self.assertRaises(
55 UnparsableBugData,
56 parser.getBugs)
57 self.assertThat(
58 str(exc),
59 Equals("CSV header ['some', 'headers', 'status', 'resolution'] "
60 "missing fields: ['id']"))
61
62 def test_no_bugs(self):
63 data = ['other,fields,id,status,resolution']
64 parser = MantisBugBatchParser(data, self.logger)
65 self.assertThat(parser.getBugs(), Equals({}))
66
67 def test_passing(self):
68 data = ['ignored,id,resolution,status',
69 'foo,42,not,complete',
70 'boo,13,,confirmed']
71 parser = MantisBugBatchParser(data, self.logger)
72 bug_42 = dict(
73 id=42, status='complete', resolution='not', ignored='foo')
74 bug_13 = dict(
75 id=13, status='confirmed', resolution='', ignored='boo')
76 self.assertThat(parser.getBugs(), Equals({42: bug_42, 13: bug_13}))
77
78 def test_incomplete_line(self):
79 data = ['ignored,id,resolution,status',
80 '42,not,complete']
81 parser = MantisBugBatchParser(data, self.logger)
82 self.assertThat(parser.getBugs(), Equals({}))
83 log = self.logger.getLogBuffer()
84 self.assertThat(
85 log, Equals("WARNING Line ['42', 'not', 'complete'] incomplete.\n"))
86
87 def test_non_integer_id(self):
88 data = ['ignored,id,resolution,status',
89 'foo,bar,not,complete']
90 parser = MantisBugBatchParser(data, self.logger)
91 self.assertThat(parser.getBugs(), Equals({}))
92 log = self.logger.getLogBuffer()
93 self.assertThat(
94 log, Equals("WARNING Encountered invalid bug ID: 'bar'.\n"))
95
96
97class TestMantisBugTracker(TestCase):
98 """Tests for various methods of the Manits bug tracker."""
99
100 layer = ZopelessLayer
101
102 def test_csv_data_on_post_404(self):
103 # If the 'view_all_set.php' request raises a 404, then the csv_data
104 # attribute is None.
105 base_url = "http://example.com/"
106 def raise404(self, request, data):
107 raise urllib2.HTTPError('url', 404, 'Not Found', None, None)
108 with monkey_patch(Mantis, urlopen=raise404):
109 bugtracker = Mantis(base_url)
110 self.assertThat(bugtracker.csv_data, Is(None))
0111
=== modified file 'lib/lp/bugs/tests/externalbugtracker.py'
--- lib/lp/bugs/tests/externalbugtracker.py 2011-02-09 11:37:19 +0000
+++ lib/lp/bugs/tests/externalbugtracker.py 2011-03-02 00:14:04 +0000
@@ -1149,7 +1149,7 @@
1149 """See `Trac`."""1149 """See `Trac`."""
1150 return self.supports_single_exports1150 return self.supports_single_exports
11511151
1152 def urlopen(self, url):1152 def urlopen(self, url, data=None):
1153 file_path = os.path.join(os.path.dirname(__file__), 'testfiles')1153 file_path = os.path.join(os.path.dirname(__file__), 'testfiles')
11541154
1155 if self.trace_calls:1155 if self.trace_calls:
@@ -1489,7 +1489,7 @@
1489 batch_size = None1489 batch_size = None
1490 trace_calls = False1490 trace_calls = False
14911491
1492 def urlopen(self, url):1492 def urlopen(self, url, data=None):
1493 if self.trace_calls:1493 if self.trace_calls:
1494 print "CALLED urlopen(%r)" % (url)1494 print "CALLED urlopen(%r)" % (url)
14951495