Merge lp:~cjwatson/launchpad/explicit-proxy-bugzilla into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 18700
Proposed branch: lp:~cjwatson/launchpad/explicit-proxy-bugzilla
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/explicit-proxy-mantis
Diff against target: 986 lines (+393/-138)
12 files modified
lib/lp/bugs/doc/externalbugtracker-bugzilla-api.txt (+3/-3)
lib/lp/bugs/doc/externalbugtracker-bugzilla-lp-plugin.txt (+3/-3)
lib/lp/bugs/doc/externalbugtracker-bugzilla-oddities.txt (+24/-10)
lib/lp/bugs/doc/externalbugtracker-bugzilla.txt (+45/-35)
lib/lp/bugs/externalbugtracker/bugzilla.py (+10/-9)
lib/lp/bugs/externalbugtracker/tests/test_bugzilla.py (+18/-20)
lib/lp/bugs/externalbugtracker/tests/test_xmlrpc.py (+45/-2)
lib/lp/bugs/externalbugtracker/xmlrpc.py (+70/-2)
lib/lp/bugs/tests/bugzilla-api-xmlrpc-transport.txt (+3/-3)
lib/lp/bugs/tests/bugzilla-xmlrpc-transport.txt (+3/-3)
lib/lp/bugs/tests/externalbugtracker-xmlrpc-transport.txt (+122/-0)
lib/lp/bugs/tests/externalbugtracker.py (+47/-48)
To merge this branch: bzr merge lp:~cjwatson/launchpad/explicit-proxy-bugzilla
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+348432@code.launchpad.net

Commit message

Convert the Bugzilla external bug tracker to urlfetch.

Description of the change

I had to write a new XML-RPC transport that uses requests. It's a relatively small variation on the urllib2-based one, and I'll delete that once I've converted its other user.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
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/bugs/doc/externalbugtracker-bugzilla-api.txt'
2--- lib/lp/bugs/doc/externalbugtracker-bugzilla-api.txt 2018-06-05 12:18:58 +0000
3+++ lib/lp/bugs/doc/externalbugtracker-bugzilla-api.txt 2018-06-23 00:58:53 +0000
4@@ -51,9 +51,9 @@
5 The authorisation cookie will be stored in the auth_cookie property of
6 the XML-RPC transport.
7
8- >>> test_transport.cookie_processor.cookiejar
9- <...CookieJar[Cookie(version=0, name='Bugzilla_login'...),
10- Cookie(version=0, name='Bugzilla_logincookie'...)]>
11+ >>> test_transport.cookie_jar
12+ <RequestsCookieJar[Cookie(version=0, name='Bugzilla_login'...),
13+ Cookie(version=0, name='Bugzilla_logincookie'...)]>
14
15 Trying to log in to a Bugzilla instance for which we have no credentials
16 will raise an error:
17
18=== modified file 'lib/lp/bugs/doc/externalbugtracker-bugzilla-lp-plugin.txt'
19--- lib/lp/bugs/doc/externalbugtracker-bugzilla-lp-plugin.txt 2018-06-05 12:18:58 +0000
20+++ lib/lp/bugs/doc/externalbugtracker-bugzilla-lp-plugin.txt 2018-06-23 00:58:53 +0000
21@@ -78,9 +78,9 @@
22 The authorisation cookie will be stored in the auth_cookie property of
23 the XML-RPC transport.
24
25- >>> test_transport.cookie_processor.cookiejar
26- <...CookieJar[Cookie(version=0, name='Bugzilla_login'...),
27- Cookie(version=0, name='Bugzilla_logincookie'...)]>
28+ >>> test_transport.cookie_jar
29+ <RequestsCookieJar[Cookie(version=0, name='Bugzilla_login'...),
30+ Cookie(version=0, name='Bugzilla_logincookie'...)]>
31
32 The externalbugtracker.bugzilla module contains a decorator,
33 needs_authentication, which can be used to ensure that a
34
35=== modified file 'lib/lp/bugs/doc/externalbugtracker-bugzilla-oddities.txt'
36--- lib/lp/bugs/doc/externalbugtracker-bugzilla-oddities.txt 2012-04-12 11:38:44 +0000
37+++ lib/lp/bugs/doc/externalbugtracker-bugzilla-oddities.txt 2018-06-23 00:58:53 +0000
38@@ -21,7 +21,9 @@
39 >>> txn = LaunchpadZopelessLayer.txn
40 >>> mozilla_bugzilla = getUtility(IBugTrackerSet).getByName('mozilla.org')
41 >>> issuezilla = TestIssuezilla(mozilla_bugzilla.baseurl)
42- >>> issuezilla._probe_version()
43+ >>> transaction.commit()
44+ >>> with issuezilla.responses(post=False):
45+ ... issuezilla._probe_version()
46 (2, 11)
47 >>> for bug_watch in mozilla_bugzilla.watches:
48 ... print "%s: %s %s" % (bug_watch.remotebug,
49@@ -32,8 +34,9 @@
50 42: FUBAR BAZBAZ
51 >>> transaction.commit()
52 >>> bug_watch_updater = CheckwatchesMaster(txn, logger=FakeLogger())
53- >>> bug_watch_updater.updateBugWatches(
54- ... issuezilla, mozilla_bugzilla.watches)
55+ >>> with issuezilla.responses():
56+ ... bug_watch_updater.updateBugWatches(
57+ ... issuezilla, mozilla_bugzilla.watches)
58 INFO Updating 4 watches for 3 bugs on https://bugzilla.mozilla.org
59 INFO Didn't find bug u'42' on https://bugzilla.mozilla.org
60 (local bugs: 1, 2).
61@@ -58,7 +61,9 @@
62
63 a) The version is way old:
64
65- >>> old_bugzilla._probe_version()
66+ >>> transaction.commit()
67+ >>> with old_bugzilla.responses(post=False):
68+ ... old_bugzilla._probe_version()
69 (2, 10)
70
71 b) The tags are not prefixed with the bz: namespace:
72@@ -72,7 +77,8 @@
73 We support them just fine:
74
75 >>> remote_bugs = ['42', '123543']
76- >>> old_bugzilla.initializeRemoteBugDB(remote_bugs)
77+ >>> with old_bugzilla.responses():
78+ ... old_bugzilla.initializeRemoteBugDB(remote_bugs)
79 >>> for remote_bug in remote_bugs:
80 ... print "%s: %s %s" % (
81 ... remote_bug,
82@@ -90,7 +96,9 @@
83 >>> from lp.bugs.tests.externalbugtracker import (
84 ... TestWeirdBugzilla)
85 >>> weird_bugzilla = TestWeirdBugzilla(mozilla_bugzilla.baseurl)
86- >>> weird_bugzilla._probe_version()
87+ >>> transaction.commit()
88+ >>> with weird_bugzilla.responses(post=False):
89+ ... weird_bugzilla._probe_version()
90 (2, 20)
91
92 a) The bug status tag is <bz:status> and not <bz:bug_status>
93@@ -109,7 +117,8 @@
94 Yet everything still works as expected:
95
96 >>> remote_bugs = ['2000', '123543']
97- >>> weird_bugzilla.initializeRemoteBugDB(remote_bugs)
98+ >>> with weird_bugzilla.responses():
99+ ... weird_bugzilla.initializeRemoteBugDB(remote_bugs)
100 >>> for remote_bug in remote_bugs:
101 ... print "%s: %s %s" % (
102 ... remote_bug,
103@@ -128,13 +137,16 @@
104 >>> from lp.bugs.tests.externalbugtracker import (
105 ... TestBrokenBugzilla)
106 >>> broken_bugzilla = TestBrokenBugzilla(mozilla_bugzilla.baseurl)
107- >>> broken_bugzilla._probe_version()
108+ >>> transaction.commit()
109+ >>> with broken_bugzilla.responses(post=False):
110+ ... broken_bugzilla._probe_version()
111 (2, 20)
112 >>> "</foobar>" in broken_bugzilla._readBugItemFile()
113 True
114
115 >>> remote_bugs = ['42', '2000']
116- >>> broken_bugzilla.initializeRemoteBugDB(remote_bugs)
117+ >>> with broken_bugzilla.responses():
118+ ... broken_bugzilla.initializeRemoteBugDB(remote_bugs)
119 Traceback (most recent call last):
120 ...
121 UnparsableBugData: Failed to parse XML description...
122@@ -148,4 +160,6 @@
123 True
124
125 >>> remote_bugs = ['42', '2000']
126- >>> broken_bugzilla.initializeRemoteBugDB(remote_bugs) # no exception
127+ >>> transaction.commit()
128+ >>> with broken_bugzilla.responses():
129+ ... broken_bugzilla.initializeRemoteBugDB(remote_bugs) # no exception
130
131=== modified file 'lib/lp/bugs/doc/externalbugtracker-bugzilla.txt'
132--- lib/lp/bugs/doc/externalbugtracker-bugzilla.txt 2017-10-24 08:11:47 +0000
133+++ lib/lp/bugs/doc/externalbugtracker-bugzilla.txt 2018-06-23 00:58:53 +0000
134@@ -63,7 +63,9 @@
135 >>> gnome_bugzilla = (
136 ... getUtility(IBugTrackerSet).getByName('gnome-bugzilla'))
137 >>> external_bugzilla = TestBugzilla(gnome_bugzilla.baseurl)
138- >>> version = external_bugzilla._probe_version()
139+ >>> transaction.commit()
140+ >>> with external_bugzilla.responses(post=False):
141+ ... version = external_bugzilla._probe_version()
142 >>> version
143 (2, 20)
144
145@@ -459,8 +461,9 @@
146 ... bug_watch.remote_importance)
147 304070: None None
148 3224: None
149- >>> bug_watch_updater.updateBugWatches(
150- ... external_bugzilla, gnome_bugzilla.watches)
151+ >>> with external_bugzilla.responses():
152+ ... bug_watch_updater.updateBugWatches(
153+ ... external_bugzilla, gnome_bugzilla.watches)
154 INFO Updating 2 watches for 2 bugs on http://bugzilla.gnome.org/bugs
155 INFO Didn't find bug u'304070' on
156 http://bugzilla.gnome.org/bugs (local bugs: 15).
157@@ -503,19 +506,19 @@
158
159 Then updateBugWatches() will make one request per bug watch:
160
161- >>> external_bugzilla.trace_calls = True
162- >>> bug_watch_updater.updateBugWatches(
163- ... external_bugzilla, gnome_bugzilla.watches)
164+ >>> with external_bugzilla.responses(trace_calls=True, get=False):
165+ ... bug_watch_updater.updateBugWatches(
166+ ... external_bugzilla, gnome_bugzilla.watches)
167 INFO Updating 7 watches for 7 bugs on http://bugzilla.gnome.org/bugs
168- CALLED _postPage()
169- CALLED _postPage()
170- CALLED _postPage()
171- CALLED _postPage()
172- CALLED _postPage()
173- CALLED _postPage()
174- CALLED _postPage()
175 INFO Didn't find bug u'304070' on
176 http://bugzilla.gnome.org/bugs (local bugs: 15).
177+ POST http://bugzilla.gnome.org/bugs/buglist.cgi
178+ POST http://bugzilla.gnome.org/bugs/buglist.cgi
179+ POST http://bugzilla.gnome.org/bugs/buglist.cgi
180+ POST http://bugzilla.gnome.org/bugs/buglist.cgi
181+ POST http://bugzilla.gnome.org/bugs/buglist.cgi
182+ POST http://bugzilla.gnome.org/bugs/buglist.cgi
183+ POST http://bugzilla.gnome.org/bugs/buglist.cgi
184
185 >>> remote_statuses = dict(
186 ... [(int(bug_watch.remotebug), bug_watch.remotestatus)
187@@ -529,8 +532,6 @@
188 >>> remote_importances == expected_remote_importances
189 True
190
191- >>> external_bugzilla.trace_calls = False
192-
193 Let's add a few more watches:
194
195 >>> expected_remote_statuses = dict(
196@@ -557,13 +558,13 @@
197 Instead of issuing one request per bug watch, like was done before,
198 updateBugWatches() issues only one request to update all watches:
199
200- >>> external_bugzilla.trace_calls = True
201- >>> bug_watch_updater.updateBugWatches(
202- ... external_bugzilla, gnome_bugzilla.watches)
203+ >>> with external_bugzilla.responses(trace_calls=True, get=False):
204+ ... bug_watch_updater.updateBugWatches(
205+ ... external_bugzilla, gnome_bugzilla.watches)
206 INFO Updating 207 watches for 207 bugs...
207- CALLED _postPage()
208 INFO Didn't find bug u'304070' on
209 http://bugzilla.gnome.org/bugs (local bugs: 15).
210+ POST http://bugzilla.gnome.org/bugs/buglist.cgi
211
212 >>> remote_statuses = dict(
213 ... [(int(bug_watch.remotebug), bug_watch.remotestatus)
214@@ -577,8 +578,6 @@
215 >>> remote_importances == expected_remote_importances
216 True
217
218- >>> external_bugzilla.trace_calls = False
219-
220 updateBugWatches() updates the lastchecked attribute on the watches, so
221 now no bug watches are in need of updating:
222
223@@ -596,7 +595,8 @@
224 >>> now = datetime.now(pytz.timezone('UTC'))
225 >>> bug_watch.lastchanged = now - timedelta(weeks=2)
226 >>> old_last_changed = bug_watch.lastchanged
227- >>> bug_watch_updater.updateBugWatches(external_bugzilla, [bug_watch])
228+ >>> with external_bugzilla.responses(get=False):
229+ ... bug_watch_updater.updateBugWatches(external_bugzilla, [bug_watch])
230 INFO Updating 1 watches for 1 bugs on http://bugzilla.gnome.org/bugs
231 >>> bug_watch.lastchanged == old_last_changed
232 True
233@@ -632,8 +632,9 @@
234 Let's update the bug watch, and see that the linked bug watch got
235 synced:
236
237- >>> bug_watch_updater.updateBugWatches(
238- ... external_bugzilla, [thunderbird_task.bugwatch])
239+ >>> with external_bugzilla.responses(get=False):
240+ ... bug_watch_updater.updateBugWatches(
241+ ... external_bugzilla, [thunderbird_task.bugwatch])
242 INFO Updating 1 watches for 1 bugs on https://bugzilla.mozilla.org
243
244 >>> bug_nine = getUtility(IBugSet).get(9)
245@@ -655,8 +656,9 @@
246 >>> thunderbird_task.transitionToStatus(
247 ... BugTaskStatus.CONFIRMED,
248 ... getUtility(IPersonSet).getByName('no-priv'))
249- >>> bug_watch_updater.updateBugWatches(
250- ... external_bugzilla, [thunderbird_task.bugwatch])
251+ >>> with external_bugzilla.responses(get=False):
252+ ... bug_watch_updater.updateBugWatches(
253+ ... external_bugzilla, [thunderbird_task.bugwatch])
254 INFO Updating 1 watches for 1 bugs on https://bugzilla.mozilla.org
255
256 >>> bug_nine = getUtility(IBugSet).get(9)
257@@ -684,8 +686,9 @@
258 ... bug=bug_two, owner=sample_person, bugtracker=mozilla_bugzilla,
259 ... remotebug='42')
260 >>> bug_watch2_id = bug_watch2.id
261- >>> bug_watch_updater.updateBugWatches(
262- ... external_bugzilla, [bug_watch1, bug_watch2])
263+ >>> with external_bugzilla.responses(get=False):
264+ ... bug_watch_updater.updateBugWatches(
265+ ... external_bugzilla, [bug_watch1, bug_watch2])
266 INFO Updating 2 watches for 1 bugs on https://bugzilla.mozilla.org
267
268 >>> bug_watch1 = getUtility(IBugWatchSet).get(bug_watch1_id)
269@@ -702,10 +705,12 @@
270 If updateBugWatches() can't parse the XML file returned from the remote
271 bug tracker, an error is logged.
272
273- >>> external_bugzilla._postPage = (
274- ... lambda self, data, repost_on_redirect: '<invalid xml>')
275- >>> bug_watch_updater.updateBugWatches(
276- ... external_bugzilla, [bug_watch1, bug_watch2])
277+ >>> import re
278+ >>> with external_bugzilla.responses() as requests_mock:
279+ ... requests_mock.reset()
280+ ... requests_mock.add('POST', re.compile(r'.*'), body='<invalid xml>')
281+ ... bug_watch_updater.updateBugWatches(
282+ ... external_bugzilla, [bug_watch1, bug_watch2])
283 Traceback (most recent call last):
284 ...
285 UnparsableBugData:
286@@ -730,10 +735,13 @@
287 initializeRemoteBugDB() has been called, in order for the bug
288 information to be fetched from the external Bugzilla instance.
289
290+ >>> transaction.commit()
291+
292 >>> external_bugzilla = TestBugzilla()
293 >>> external_bugzilla.bugzilla_bugs = {84: (
294 ... 'RESOLVED', 'FIXED', 'MEDIUM', 'NORMAL')}
295- >>> external_bugzilla.initializeRemoteBugDB(['84'])
296+ >>> with external_bugzilla.responses():
297+ ... external_bugzilla.initializeRemoteBugDB(['84'])
298 >>> external_bugzilla.remote_bug_product['84']
299 u'product-84'
300 >>> external_bugzilla.getRemoteProduct('84')
301@@ -747,7 +755,8 @@
302 ... 'RESOLVED', 'FIXED', 'MEDIUM', 'NORMAL')}
303 >>> # Make the buglist XML not include the product tag.
304 >>> external_bugzilla.bug_item_file = 'gnome_bug_li_item_noproduct.xml'
305- >>> external_bugzilla.initializeRemoteBugDB(['84'])
306+ >>> with external_bugzilla.responses():
307+ ... external_bugzilla.initializeRemoteBugDB(['84'])
308 >>> print external_bugzilla.getRemoteProduct('84')
309 None
310
311@@ -756,7 +765,8 @@
312 >>> external_bugzilla = TestBugzilla()
313 >>> external_bugzilla.bugzilla_bugs = {84: (
314 ... 'RESOLVED', 'FIXED', 'MEDIUM', 'NORMAL')}
315- >>> external_bugzilla.initializeRemoteBugDB(['84'])
316+ >>> with external_bugzilla.responses():
317+ ... external_bugzilla.initializeRemoteBugDB(['84'])
318 >>> external_bugzilla.getRemoteProduct('42')
319 Traceback (most recent call last):
320 ...
321
322=== modified file 'lib/lp/bugs/externalbugtracker/bugzilla.py'
323--- lib/lp/bugs/externalbugtracker/bugzilla.py 2018-06-05 12:18:58 +0000
324+++ lib/lp/bugs/externalbugtracker/bugzilla.py 2018-06-23 00:58:53 +0000
325@@ -15,12 +15,12 @@
326 from httplib import BadStatusLine
327 import re
328 import string
329-from urllib2 import URLError
330 from xml.dom import minidom
331 import xml.parsers.expat
332 import xmlrpclib
333
334 import pytz
335+import requests
336 import six
337 from zope.component import getUtility
338 from zope.interface import (
339@@ -32,7 +32,7 @@
340 BugNotFound,
341 BugTrackerAuthenticationError,
342 BugTrackerConnectError,
343- ExternalBugTracker,
344+ ExternalBugTrackerRequests,
345 InvalidBugId,
346 LookupTree,
347 UnknownRemoteImportanceError,
348@@ -40,7 +40,7 @@
349 UnparsableBugData,
350 UnparsableBugTrackerVersion,
351 )
352-from lp.bugs.externalbugtracker.xmlrpc import UrlLib2Transport
353+from lp.bugs.externalbugtracker.xmlrpc import RequestsTransport
354 from lp.bugs.interfaces.bugtask import (
355 BugTaskImportance,
356 BugTaskStatus,
357@@ -61,8 +61,8 @@
358 )
359
360
361-class Bugzilla(ExternalBugTracker):
362- """An ExternalBugTrack for dealing with remote Bugzilla systems."""
363+class Bugzilla(ExternalBugTrackerRequests):
364+ """An ExternalBugTracker for dealing with remote Bugzilla systems."""
365
366 batch_query_threshold = 0 # Always use the batch method.
367 _test_xmlrpc_proxy = None
368@@ -171,7 +171,8 @@
369 return BugzillaLPPlugin(self.baseurl)
370 elif self._remoteSystemHasBugzillaAPI():
371 return BugzillaAPI(self.baseurl)
372- except (xmlrpclib.ProtocolError, URLError, BadStatusLine):
373+ except (xmlrpclib.ProtocolError, requests.RequestException,
374+ BadStatusLine):
375 pass
376 return self
377
378@@ -201,7 +202,7 @@
379 server cannot be reached `BugTrackerConnectError` will be
380 raised.
381 """
382- version_xml = self._getPage('xml.cgi?id=1')
383+ version_xml = self._getPage('xml.cgi?id=1').content
384 try:
385 document = self._parseDOMString(version_xml)
386 except xml.parsers.expat.ExpatError as e:
387@@ -410,7 +411,7 @@
388 severity_tag = 'bz:bug_severity'
389
390 buglist_xml = self._postPage(
391- buglist_page, data, repost_on_redirect=True)
392+ buglist_page, data, repost_on_redirect=True).content
393
394 try:
395 document = self._parseDOMString(buglist_xml)
396@@ -568,7 +569,7 @@
397
398 self.internal_xmlrpc_transport = internal_xmlrpc_transport
399 if xmlrpc_transport is None:
400- self.xmlrpc_transport = UrlLib2Transport(self.xmlrpc_endpoint)
401+ self.xmlrpc_transport = RequestsTransport(self.xmlrpc_endpoint)
402 else:
403 self.xmlrpc_transport = xmlrpc_transport
404
405
406=== modified file 'lib/lp/bugs/externalbugtracker/tests/test_bugzilla.py'
407--- lib/lp/bugs/externalbugtracker/tests/test_bugzilla.py 2012-01-01 02:58:52 +0000
408+++ lib/lp/bugs/externalbugtracker/tests/test_bugzilla.py 2018-06-23 00:58:53 +0000
409@@ -1,14 +1,14 @@
410-# Copyright 2010-2011 Canonical Ltd. This software is licensed under the
411+# Copyright 2010-2018 Canonical Ltd. This software is licensed under the
412 # GNU Affero General Public License version 3 (see the file LICENSE).
413
414 """Tests for the Bugzilla BugTracker."""
415
416 __metaclass__ = type
417
418-from StringIO import StringIO
419 from xml.parsers.expat import ExpatError
420 import xmlrpclib
421
422+import responses
423 import transaction
424
425 from lp.bugs.externalbugtracker.base import UnparsableBugData
426@@ -17,7 +17,6 @@
427 TestCase,
428 TestCaseWithFactory,
429 )
430-from lp.testing.fakemethod import FakeMethod
431 from lp.testing.layers import ZopelessLayer
432
433
434@@ -28,28 +27,26 @@
435
436 base_url = "http://example.com/"
437
438- def _makeInstrumentedBugzilla(self, page=None, content=None):
439- """Create a `Bugzilla` with a fake urlopen."""
440- if page is None:
441- page = self.factory.getUniqueString()
442- bugzilla = Bugzilla(self.base_url)
443- if content is None:
444- content = "<bugzilla>%s</bugzilla>" % (
445- self.factory.getUniqueString())
446- fake_page = StringIO(content)
447- fake_page.url = self.base_url + page
448- bugzilla.urlopen = FakeMethod(result=fake_page)
449- return bugzilla
450-
451+ @responses.activate
452 def test_post_to_search_form_does_not_crash(self):
453- page = self.factory.getUniqueString()
454- bugzilla = self._makeInstrumentedBugzilla(page)
455+ responses.add(
456+ "POST", self.base_url + "xml.cgi",
457+ body="<bugzilla>%s</bugzilla>" % self.factory.getUniqueString())
458+ bugzilla = Bugzilla(self.base_url)
459 bugzilla.getRemoteBugBatch([])
460
461+ @responses.activate
462 def test_repost_on_redirect_does_not_crash(self):
463- bugzilla = self._makeInstrumentedBugzilla()
464+ responses.add(
465+ "POST", self.base_url + "xml.cgi", status=302,
466+ headers={"Location": self.base_url + "buglist.cgi"})
467+ responses.add(
468+ "POST", self.base_url + "buglist.cgi",
469+ body="<bugzilla>%s</bugzilla>" % self.factory.getUniqueString())
470+ bugzilla = Bugzilla(self.base_url)
471 bugzilla.getRemoteBugBatch([])
472
473+ @responses.activate
474 def test_reports_invalid_search_result(self):
475 # Sometimes bug searches may go wrong, yielding an HTML page
476 # instead. getRemoteBugBatch rejects and reports search results
477@@ -61,7 +58,8 @@
478 </body>
479 </html>
480 """
481- bugzilla = self._makeInstrumentedBugzilla(content=result_text)
482+ responses.add("POST", self.base_url + "xml.cgi", body=result_text)
483+ bugzilla = Bugzilla(self.base_url)
484 self.assertRaises(UnparsableBugData, bugzilla.getRemoteBugBatch, [])
485
486
487
488=== modified file 'lib/lp/bugs/externalbugtracker/tests/test_xmlrpc.py'
489--- lib/lp/bugs/externalbugtracker/tests/test_xmlrpc.py 2018-04-05 16:18:34 +0000
490+++ lib/lp/bugs/externalbugtracker/tests/test_xmlrpc.py 2018-06-23 00:58:53 +0000
491@@ -1,4 +1,4 @@
492-# Copyright 2011 Canonical Ltd. This software is licensed under the
493+# Copyright 2011-2018 Canonical Ltd. This software is licensed under the
494 # GNU Affero General Public License version 3 (see the file LICENSE).
495
496 """Tests for `lp.bugs.externalbugtracker.xmlrpc`."""
497@@ -10,8 +10,13 @@
498 from xml.parsers.expat import ExpatError
499
500 from fixtures import MockPatch
501+import requests
502+import responses
503
504-from lp.bugs.externalbugtracker.xmlrpc import UrlLib2Transport
505+from lp.bugs.externalbugtracker.xmlrpc import (
506+ RequestsTransport,
507+ UrlLib2Transport,
508+ )
509 from lp.bugs.tests.externalbugtracker import (
510 ensure_response_parser_is_expat,
511 UrlLib2TransportTestHandler,
512@@ -51,3 +56,41 @@
513 URLError, '<urlopen error [Errno -2] Name or service not known>',
514 transport.request, u"test.invalid", u"xmlrpc",
515 u"\N{SNOWMAN}".encode('utf-8'))
516+
517+
518+class TestRequestsTransport(TestCase):
519+ """Tests for `RequestsTransport`."""
520+
521+ @responses.activate
522+ def test_expat_error(self):
523+ # Malformed XML-RPC responses cause xmlrpclib to raise an ExpatError.
524+ responses.add(
525+ "POST", "http://www.example.com/xmlrpc",
526+ body="<params><mis></match></params>")
527+ transport = RequestsTransport("http://not.real/")
528+
529+ # The Launchpad production environment selects Expat at present. This
530+ # is quite strict compared to the other parsers that xmlrpclib can
531+ # possibly select.
532+ ensure_response_parser_is_expat(transport)
533+
534+ self.assertRaises(
535+ ExpatError, transport.request,
536+ 'www.example.com', 'xmlrpc', "<methodCall />")
537+
538+ def test_unicode_url(self):
539+ # Python's httplib doesn't like Unicode URLs much. Ensure that
540+ # they don't cause it to crash, and we get a post-serialisation
541+ # connection error instead.
542+ self.useFixture(MockPatch(
543+ "socket.getaddrinfo",
544+ side_effect=socket.gaierror(
545+ socket.EAI_NONAME, "Name or service not known")))
546+ transport = RequestsTransport(u"http://test.invalid/")
547+ for proxy in (None, "http://squid.internal:3128/"):
548+ self.pushConfig("launchpad", http_proxy=proxy)
549+ e = self.assertRaises(
550+ requests.ConnectionError,
551+ transport.request, u"test.invalid", u"xmlrpc",
552+ u"\N{SNOWMAN}".encode('utf-8'))
553+ self.assertIn("Name or service not known", str(e))
554
555=== modified file 'lib/lp/bugs/externalbugtracker/xmlrpc.py'
556--- lib/lp/bugs/externalbugtracker/xmlrpc.py 2015-07-10 07:48:06 +0000
557+++ lib/lp/bugs/externalbugtracker/xmlrpc.py 2018-06-23 00:58:53 +0000
558@@ -1,10 +1,11 @@
559-# Copyright 2009-2013 Canonical Ltd. This software is licensed under the
560+# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
561 # GNU Affero General Public License version 3 (see the file LICENSE).
562
563-"""An XMLRPC transport which uses urllib2."""
564+"""XMLRPC transports which use urllib2 or requests."""
565
566 __metaclass__ = type
567 __all__ = [
568+ 'RequestsTransport',
569 'UrlLib2Transport',
570 'XMLRPCRedirectHandler',
571 ]
572@@ -12,6 +13,7 @@
573
574 from cookielib import Cookie
575 from cStringIO import StringIO
576+from io import BytesIO
577 from urllib2 import (
578 build_opener,
579 HTTPCookieProcessor,
580@@ -28,7 +30,15 @@
581 Transport,
582 )
583
584+import requests
585+from requests.cookies import RequestsCookieJar
586+
587+from lp.bugs.externalbugtracker.base import repost_on_redirect_hook
588 from lp.services.config import config
589+from lp.services.timeout import (
590+ override_timeout,
591+ urlfetch,
592+ )
593 from lp.services.utils import traceback_info
594
595
596@@ -122,3 +132,61 @@
597 else:
598 traceback_info(response)
599 return self.parse_response(StringIO(response))
600+
601+
602+class RequestsTransport(Transport):
603+ """An XML-RPC transport which uses requests.
604+
605+ This XML-RPC transport uses the Python requests module to make the
606+ request. (In fact, it uses lp.services.timeout.urlfetch, which wraps
607+ requests and deals with timeout handling.)
608+
609+ Note: this transport isn't fit for general XML-RPC use. It is just good
610+ enough for some of our external bug tracker implementations.
611+
612+ :param endpoint: The URL of the XML-RPC server.
613+ """
614+
615+ verbose = False
616+
617+ def __init__(self, endpoint, cookie_jar=None):
618+ Transport.__init__(self, use_datetime=True)
619+ self.scheme, self.host = urlparse(endpoint)[:2]
620+ assert self.scheme in ('http', 'https'), (
621+ "Unsupported URL scheme: %s" % self.scheme)
622+ if cookie_jar is None:
623+ cookie_jar = RequestsCookieJar()
624+ self.cookie_jar = cookie_jar
625+ self.timeout = config.checkwatches.default_socket_timeout
626+
627+ def setCookie(self, cookie_str):
628+ """Set a cookie for the transport to use in future connections."""
629+ name, value = cookie_str.split('=')
630+ self.cookie_jar.set(
631+ name, value, domain=self.host, path='', expires=False,
632+ discard=None, rest=None)
633+
634+ def request(self, host, handler, request_body, verbose=0):
635+ """Make an XMLRPC request.
636+
637+ Uses the configured proxy server to make the connection.
638+ """
639+ url = urlunparse((self.scheme, host, handler, '', '', ''))
640+ # httplib can raise a UnicodeDecodeError when using a Unicode
641+ # URL, a non-ASCII body and a proxy. http://bugs.python.org/issue12398
642+ if not isinstance(url, bytes):
643+ url = url.encode('utf-8')
644+ try:
645+ with override_timeout(self.timeout):
646+ response = urlfetch(
647+ url, method='POST', headers={'Content-Type': 'text/xml'},
648+ data=request_body, cookies=self.cookie_jar,
649+ hooks={'response': repost_on_redirect_hook},
650+ trust_env=False, use_proxy=True)
651+ except requests.HTTPError as e:
652+ raise ProtocolError(
653+ url.decode('utf-8'), e.response.status_code, e.response.reason,
654+ e.response.headers)
655+ else:
656+ traceback_info(response.text)
657+ return self.parse_response(BytesIO(response.content))
658
659=== modified file 'lib/lp/bugs/tests/bugzilla-api-xmlrpc-transport.txt'
660--- lib/lp/bugs/tests/bugzilla-api-xmlrpc-transport.txt 2018-06-05 12:18:58 +0000
661+++ lib/lp/bugs/tests/bugzilla-api-xmlrpc-transport.txt 2018-06-23 00:58:53 +0000
662@@ -43,9 +43,9 @@
663
664 The Bugzilla_logincookie will now have been set for the transport, too.
665
666- >>> print bugzilla_transport.cookie_processor.cookiejar
667- <...CookieJar[<Cookie Bugzilla_login=...>,
668- <Cookie Bugzilla_logincookie=...>]>
669+ >>> print bugzilla_transport.cookie_jar
670+ <RequestsCookieJar[<Cookie Bugzilla_login=...>,
671+ <Cookie Bugzilla_logincookie=...>]>
672
673 Trying to log in with an incorrect username or password will result in
674 an error being raised.
675
676=== modified file 'lib/lp/bugs/tests/bugzilla-xmlrpc-transport.txt'
677--- lib/lp/bugs/tests/bugzilla-xmlrpc-transport.txt 2018-06-05 12:18:58 +0000
678+++ lib/lp/bugs/tests/bugzilla-xmlrpc-transport.txt 2018-06-23 00:58:53 +0000
679@@ -90,9 +90,9 @@
680
681 The login cookies are in the transport's cookie jar.
682
683- >>> print bugzilla_transport.cookie_processor.cookiejar
684- <...CookieJar[<Cookie Bugzilla_login=...>,
685- <Cookie Bugzilla_logincookie=...>]>
686+ >>> print bugzilla_transport.cookie_jar
687+ <RequestsCookieJar[<Cookie Bugzilla_login=...>,
688+ <Cookie Bugzilla_logincookie=...>]>
689
690
691 Launchpad.time()
692
693=== modified file 'lib/lp/bugs/tests/externalbugtracker-xmlrpc-transport.txt'
694--- lib/lp/bugs/tests/externalbugtracker-xmlrpc-transport.txt 2016-09-21 02:49:42 +0000
695+++ lib/lp/bugs/tests/externalbugtracker-xmlrpc-transport.txt 2018-06-23 00:58:53 +0000
696@@ -193,3 +193,125 @@
697
698 >>> print redirected_request.data
699 None
700+
701+
702+XMLRPC requests transport
703+-------------------------
704+
705+When using XMLRPC for connecting to external bug trackers, we need to
706+use a special transport, which processes http cookies correctly, and
707+which can connect through an http proxy server.
708+
709+ >>> from lp.bugs.externalbugtracker.xmlrpc import RequestsTransport
710+
711+RequestsTransport accepts a CookieJar as an optional parameter upon creation.
712+This allows us to share a CookieJar - and therefore the cookie it contains -
713+between different transports or URL openers.
714+
715+ >>> from requests.cookies import RequestsCookieJar
716+ >>> jar = RequestsCookieJar()
717+ >>> transport = RequestsTransport('http://example.com', jar)
718+ >>> transport.cookie_jar == jar
719+ True
720+
721+We define a test response callback that returns the request-url and any
722+request parameters as an XMLRPC parameter, and sets a cookie from the
723+server, 'foo=bar'.
724+
725+ >>> import responses
726+ >>> from six.moves import xmlrpc_client
727+
728+ >>> def test_callback(request):
729+ ... params = xmlrpc_client.loads(request.body)[0]
730+ ... return (
731+ ... 200, {'Set-Cookie': 'foo=bar'},
732+ ... xmlrpc_client.dumps(
733+ ... ([request.url] + list(params),), methodresponse=True))
734+
735+Before sending the request, the transport's cookie jar is empty.
736+
737+ >>> def print_cookie_jar(jar):
738+ ... for name, value in sorted(jar.items()):
739+ ... print('%s=%s' % (name, value))
740+
741+ >>> print_cookie_jar(transport.cookie_jar)
742+
743+ >>> request_body = """<?xml version="1.0"?>
744+ ... <methodCall>
745+ ... <methodName>examples.testMethod</methodName>
746+ ... <params>
747+ ... <param>
748+ ... <value>
749+ ... <int>42</int>
750+ ... </value>
751+ ... </param>
752+ ... </params>
753+ ... </methodCall>
754+ ... """
755+ >>> with responses.RequestsMock() as requests_mock:
756+ ... requests_mock.add_callback(
757+ ... 'POST', 'http://www.example.com/xmlrpc', test_callback)
758+ ... transport.request('www.example.com', 'xmlrpc', request_body)
759+ (['http://www.example.com/xmlrpc', 42],)
760+
761+We received the url as the single XMLRPC result, and the cookie jar now
762+contains the 'foo=bar' cookie sent by the server.
763+
764+ >>> print_cookie_jar(transport.cookie_jar)
765+ foo=bar
766+
767+In addition to cookies sent by the server, we can set cookies locally.
768+
769+ >>> transport.setCookie('ding=dong')
770+ >>> print_cookie_jar(transport.cookie_jar)
771+ ding=dong
772+ foo=bar
773+
774+If an error occurs trying to make the request, an
775+``xmlrpclib.ProtocolError`` is raised.
776+
777+ >>> request_body = """<?xml version="1.0"?>
778+ ... <methodCall>
779+ ... <methodName>examples.testError</methodName>
780+ ... <params>
781+ ... <param>
782+ ... <value>
783+ ... <int>42</int>
784+ ... </value>
785+ ... </param>
786+ ... </params>
787+ ... </methodCall>
788+ ... """
789+ >>> with responses.RequestsMock() as requests_mock:
790+ ... requests_mock.add(
791+ ... 'POST', 'http://www.example.com/xmlrpc', status=500)
792+ ... transport.request('www.example.com', 'xmlrpc', request_body)
793+ Traceback (most recent call last):
794+ ...
795+ ProtocolError: <ProtocolError for http://www.example.com/xmlrpc: 500
796+ Internal Server Error>
797+
798+If the transport encounters a redirect response it will make its request
799+to the location indicated in that response rather than the original
800+location.
801+
802+ >>> request_body = """<?xml version="1.0"?>
803+ ... <methodCall>
804+ ... <methodName>examples.whatever</methodName>
805+ ... <params>
806+ ... <param>
807+ ... <value>
808+ ... <int>42</int>
809+ ... </value>
810+ ... </param>
811+ ... </params>
812+ ... </methodCall>
813+ ... """
814+ >>> with responses.RequestsMock() as requests_mock:
815+ ... target_url = 'http://www.example.com/xmlrpc/redirected'
816+ ... requests_mock.add(
817+ ... 'POST', 'http://www.example.com/xmlrpc', status=302,
818+ ... headers={'Location': target_url})
819+ ... requests_mock.add_callback('POST', target_url, test_callback)
820+ ... transport.request('www.example.com', 'xmlrpc', request_body)
821+ (['http://www.example.com/xmlrpc/redirected', 42],)
822
823=== modified file 'lib/lp/bugs/tests/externalbugtracker.py'
824--- lib/lp/bugs/tests/externalbugtracker.py 2018-06-23 00:58:53 +0000
825+++ lib/lp/bugs/tests/externalbugtracker.py 2018-06-23 00:58:53 +0000
826@@ -52,7 +52,10 @@
827 LP_PLUGIN_METADATA_AND_COMMENTS,
828 LP_PLUGIN_METADATA_ONLY,
829 )
830-from lp.bugs.externalbugtracker.xmlrpc import UrlLib2Transport
831+from lp.bugs.externalbugtracker.xmlrpc import (
832+ RequestsTransport,
833+ UrlLib2Transport,
834+ )
835 from lp.bugs.interfaces.bugtask import (
836 BugTaskImportance,
837 BugTaskStatus,
838@@ -260,12 +263,8 @@
839 raise self.get_remote_status_error("Testing")
840
841
842-class TestBugzilla(Bugzilla):
843- """Bugzilla ExternalSystem for use in tests.
844-
845- It overrides _getPage and _postPage, so that access to a real Bugzilla
846- instance isn't needed.
847- """
848+class TestBugzilla(BugTrackerResponsesMixin, Bugzilla):
849+ """Bugzilla ExternalSystem for use in tests."""
850 # We set the batch_query_threshold to zero so that only
851 # getRemoteBugBatch() is used to retrieve bugs, since getRemoteBug()
852 # calls getRemoteBugBatch() anyway.
853@@ -301,40 +300,38 @@
854 """
855 return read_test_file(self.bug_item_file)
856
857- def _getPage(self, page):
858- """GET a page.
859+ def _getCallback(self, request):
860+ """Handle a test GET request.
861
862 Only handles xml.cgi?id=1 so far.
863 """
864- if self.trace_calls:
865- print "CALLED _getPage()"
866- if page == 'xml.cgi?id=1':
867- data = read_test_file(self.version_file)
868+ url = urlsplit(request.url)
869+ if (url.path == urlsplit(self.baseurl).path + '/xml.cgi' and
870+ parse_qs(url.query).get('id') == ['1']):
871+ body = read_test_file(self.version_file)
872 # Add some latin1 to test bug 61129
873- return data % dict(non_ascii_latin1="\xe9")
874+ return 200, {}, body % {'non_ascii_latin1': b'\xe9'}
875 else:
876- raise AssertionError('Unknown page: %s' % page)
877-
878- def _postPage(self, page, form, repost_on_redirect=False):
879- """POST to the specified page.
880-
881- :form: is a dict of form variables being POSTed.
882+ raise AssertionError('Unknown URL: %s' % request.url)
883+
884+ def _postCallback(self, request):
885+ """Handle a test POST request.
886
887 Only handles buglist.cgi so far.
888 """
889- if self.trace_calls:
890- print "CALLED _postPage()"
891- if page == self.buglist_page:
892+ url = urlsplit(request.url)
893+ if url.path == urlsplit(self.baseurl).path + '/' + self.buglist_page:
894 buglist_xml = read_test_file(self.buglist_file)
895- bug_ids = str(form[self.bug_id_form_element]).split(',')
896+ form = parse_qs(request.body)
897+ bug_ids = str(form[self.bug_id_form_element][0]).split(',')
898 bug_li_items = []
899 for bug_id in bug_ids:
900 bug_id = int(bug_id)
901 if bug_id not in self.bugzilla_bugs:
902- #Unknown bugs aren't included in the resulting xml.
903+ # Unknown bugs aren't included in the resulting xml.
904 continue
905- bug_status, bug_resolution, bug_priority, bug_severity = \
906- self.bugzilla_bugs[int(bug_id)]
907+ bug_status, bug_resolution, bug_priority, bug_severity = (
908+ self.bugzilla_bugs[int(bug_id)])
909 bug_item = self._readBugItemFile() % {
910 'bug_id': bug_id,
911 'status': bug_status,
912@@ -343,12 +340,22 @@
913 'severity': bug_severity,
914 }
915 bug_li_items.append(bug_item)
916- return buglist_xml % {
917+ body = buglist_xml % {
918 'bug_li_items': '\n'.join(bug_li_items),
919- 'page': page,
920+ 'page': url.path.lstrip('/'),
921 }
922+ return 200, {}, body
923 else:
924- raise AssertionError('Unknown page: %s' % page)
925+ raise AssertionError('Unknown URL: %s' % request.url)
926+
927+ def addResponses(self, requests_mock, get=True, post=True):
928+ """Add test responses."""
929+ if get:
930+ requests_mock.add_callback(
931+ 'GET', re.compile(r'.*'), self._getCallback)
932+ if post:
933+ requests_mock.add_callback(
934+ 'POST', re.compile(r'.*'), self._postCallback)
935
936
937 class TestWeirdBugzilla(TestBugzilla):
938@@ -406,14 +413,7 @@
939 123543: ('ASSIGNED', '', 'HIGH', 'BLOCKER')}
940
941
942-class FakeHTTPConnection:
943- """A fake HTTP connection."""
944-
945- def putheader(self, header, value):
946- print "%s: %s" % (header, value)
947-
948-
949-class TestBugzillaXMLRPCTransport(UrlLib2Transport):
950+class TestBugzillaXMLRPCTransport(RequestsTransport):
951 """A test implementation of the Bugzilla XML-RPC interface."""
952
953 local_datetime = None
954@@ -538,9 +538,9 @@
955
956 def __init__(self, *args, **kwargs):
957 """Ensure mutable class data is copied to the instance."""
958- # UrlLib2Transport is not a new style class so 'super' cannot be
959+ # RequestsTransport is not a new-style class so 'super' cannot be
960 # used.
961- UrlLib2Transport.__init__(self, *args, **kwargs)
962+ RequestsTransport.__init__(self, *args, **kwargs)
963 self.bugs = deepcopy(TestBugzillaXMLRPCTransport._bugs)
964 self.bug_aliases = deepcopy(self._bug_aliases)
965 self.bug_comments = deepcopy(self._bug_comments)
966@@ -551,14 +551,13 @@
967
968 @property
969 def auth_cookie(self):
970- cookies = self.cookie_processor.cookiejar._cookies
971-
972- assert len(cookies) < 2, (
973- "There should only be cookies for one domain.")
974-
975- if len(cookies) == 1:
976- [(domain, domain_cookies)] = cookies.items()
977- return domain_cookies.get('', {}).get('Bugzilla_logincookie')
978+ if len(set(cookie.domain for cookie in self.cookie_jar)) > 1:
979+ raise AssertionError(
980+ "There should only be cookies for one domain.")
981+
982+ for cookie in self.cookie_jar:
983+ if cookie.name == 'Bugzilla_logincookie':
984+ return cookie
985 else:
986 return None
987