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

Proposed by Colin Watson
Status: Merged
Merged at revision: 18701
Proposed branch: lp:~cjwatson/launchpad/explicit-proxy-trac
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/explicit-proxy-bugzilla
Diff against target: 885 lines (+221/-226)
5 files modified
lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.txt (+70/-71)
lib/lp/bugs/doc/externalbugtracker-trac.txt (+67/-86)
lib/lp/bugs/externalbugtracker/trac.py (+46/-34)
lib/lp/bugs/tests/externalbugtracker.py (+37/-35)
setup.py (+1/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/explicit-proxy-trac
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+348433@code.launchpad.net

Commit message

Convert the Trac external bug tracker to urlfetch.

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-trac-lp-plugin.txt'
2--- lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.txt 2016-09-21 02:49:42 +0000
3+++ lib/lp/bugs/doc/externalbugtracker-trac-lp-plugin.txt 2018-06-23 02:06:14 +0000
4@@ -30,23 +30,22 @@
5 Trac to validate $token and return a Set-Cookie header.
6
7 >>> import random
8+ >>> import re
9+ >>> from six.moves.urllib_parse import (
10+ ... urljoin,
11+ ... urlsplit,
12+ ... )
13+ >>> from lp.bugs.tests.externalbugtracker import BugTrackerResponsesMixin
14 >>> from lp.services.verification.interfaces.logintoken import (
15 ... ILoginTokenSet)
16- >>> from lp.services.webapp.url import urlappend
17 >>> from lp.testing.dbuser import lp_dbuser
18
19- >>> class FakeResponse:
20- ... def __init__(self):
21- ... self.headers = {}
22-
23- >>> class TestTracLPPlugin(TracLPPlugin):
24- ... def urlopen(self, url, data=None):
25+ >>> class TestTracLPPlugin(BugTrackerResponsesMixin, TracLPPlugin):
26+ ... def _getCallback(self, request):
27 ... with lp_dbuser():
28- ... url = url.get_full_url()
29- ... base_auth_url = urlappend(self.baseurl, 'launchpad-auth')
30- ... if not url.startswith(base_auth_url + '/'):
31- ... raise AssertionError("Unexpected URL: %s" % url)
32- ... token_text = url.split('/')[-1]
33+ ... login(ANONYMOUS)
34+ ... url = urlsplit(request.url)
35+ ... token_text = url.path.split('/')[-1]
36 ... token = getUtility(ILoginTokenSet)[token_text]
37 ... if token.tokentype.name != 'BUGTRACKER':
38 ... raise AssertionError(
39@@ -59,20 +58,24 @@
40 ... cookie_string = (
41 ... 'trac_auth=random_token-' + str(random.random()))
42 ... self._xmlrpc_transport.setCookie(cookie_string)
43- ... response = FakeResponse()
44- ... response.headers['Set-Cookie'] = cookie_string
45+ ... return 200, {'Set-Cookie': cookie_string}, ''
46 ...
47- ... return response
48+ ... def addResponses(self, requests_mock):
49+ ... requests_mock.add_callback(
50+ ... 'GET',
51+ ... re.compile(
52+ ... re.escape(urljoin(self.baseurl, 'launchpad-auth/'))),
53+ ... self._getCallback)
54
55 To generate the token, the internal XML-RPC server is used. By using the
56-XML-RPC server rather than talking to the database directy means that we
57-don't have to bother about commiting the transaction to make the token
58+XML-RPC server rather than talking to the database directly means that we
59+don't have to bother about committing the transaction to make the token
60 visible to Trac.
61
62- >>> from cookielib import CookieJar
63+ >>> from requests.cookies import RequestsCookieJar
64 >>> from lp.bugs.tests.externalbugtracker import (
65 ... TestInternalXMLRPCTransport)
66- >>> cookie_jar = CookieJar()
67+ >>> cookie_jar = RequestsCookieJar()
68 >>> test_transport = TestTracXMLRPCTransport(
69 ... 'http://example.com/', cookie_jar)
70 >>> trac = TestTracLPPlugin(
71@@ -82,70 +85,57 @@
72
73 The method that authenticates with Trac is _authenticate().
74
75- >>> trac._authenticate()
76+ >>> with trac.responses():
77+ ... trac._authenticate()
78 Using XML-RPC to generate token.
79 Successfully validated the token.
80
81 After it has been called, the XML-RPC transport will have its
82 auth_cookie attribute set.
83
84- >>> test_transport.cookie_processor.cookiejar
85- <...CookieJar[Cookie(version=0, name='trac_auth'...
86-
87-The XML-RPC transport's cookie_processor shares its cookiejar with the
88-TracLPPlugin instance. This is so that the TracLPPlugin can use the
89-cookiejar when authenticating with the remote Trac and then pass it to
90-the XML-RPC transport for further use, meaning that there's no need to
91-manually manipulate cookies.
92-
93- >>> test_transport.cookie_processor.cookiejar == trac._cookie_jar
94+ >>> test_transport.cookie_jar
95+ <RequestsCookieJar[Cookie(version=0, name='trac_auth'...
96+
97+The XML-RPC transport shares its cookiejar with the TracLPPlugin instance.
98+This is so that the TracLPPlugin can use the cookiejar when authenticating
99+with the remote Trac and then pass it to the XML-RPC transport for further
100+use, meaning that there's no need to manually manipulate cookies.
101+
102+ >>> test_transport.cookie_jar == trac._cookie_jar
103 True
104
105 So if we look in the TracLPPlugin's CookieJar we'll see the same cookie:
106
107 >>> trac._cookie_jar
108- <...CookieJar[Cookie(version=0, name='trac_auth'...
109+ <RequestsCookieJar[Cookie(version=0, name='trac_auth'...
110
111 And altering the cookie in the TracLPPlugin's CookieJar will mean, of
112 course, that it's altered in the XML-RPC transport's CookieJar, too.
113
114- >>> from cookielib import Cookie
115- >>> new_cookie = Cookie(
116- ... name="trac_auth",
117- ... value="Look ma, a new cookie!",
118- ... version=0, port=None, port_specified=False,
119- ... domain='http://example.com', domain_specified=True,
120- ... domain_initial_dot=None, path='', path_specified=False,
121- ... secure=False, expires=False, discard=None, comment=None,
122- ... comment_url=None, rest=None)
123-
124 >>> trac._cookie_jar.clear()
125- >>> trac._cookie_jar.set_cookie(new_cookie)
126+ >>> _ = trac._cookie_jar.set(
127+ ... 'trac_auth', 'Look ma, a new cookie!',
128+ ... domain='http://example.com', path='')
129
130 >>> trac._cookie_jar
131 <...CookieJar[Cookie(version=0, name='trac_auth',
132 value='Look ma, a new cookie!'...>
133
134- >>> test_transport.cookie_processor.cookiejar
135+ >>> test_transport.cookie_jar
136 <...CookieJar[Cookie(version=0, name='trac_auth',
137 value='Look ma, a new cookie!'...>
138
139 If authentication fails, a BugTrackerAuthenticationError will be raised.
140
141- >>> from urllib2 import HTTPError
142- >>> class TestFailingTracLPPlugin(TracLPPlugin):
143- ... def urlopen(self, url, data=None):
144- ... raise HTTPError(url, 401, "Denied!", {}, None)
145-
146- >>> test_trac = TestFailingTracLPPlugin(
147- ... 'http://example.com', xmlrpc_transport=test_transport,
148- ... internal_xmlrpc_transport=TestInternalXMLRPCTransport(),
149- ... cookie_jar=cookie_jar)
150- >>> test_trac._authenticate()
151+ >>> with trac.responses() as requests_mock:
152+ ... requests_mock.reset()
153+ ... requests_mock.add(
154+ ... 'GET', re.compile(r'.*/launchpad-auth/.*'), status=401)
155+ ... trac._authenticate()
156 Traceback (most recent call last):
157 ...
158 BugTrackerAuthenticationError: http://example.com:
159- HTTP Error 401: Denied!
160+ 401 Client Error: Unauthorized
161
162
163 Current time
164@@ -169,7 +159,8 @@
165 >>> test_transport.seconds_since_epoch = 1207706521 + HOUR
166 >>> test_transport.local_timezone = 'CET'
167 >>> test_transport.utc_offset = HOUR
168- >>> trac.getCurrentDBTime()
169+ >>> with trac.responses():
170+ ... trac.getCurrentDBTime()
171 Using XML-RPC to generate token.
172 Successfully validated the token.
173 datetime.datetime(2008, 4, 9, 2, 2, 1, tzinfo=<UTC>)
174@@ -187,7 +178,8 @@
175 sent again.
176
177 >>> test_transport.expireCookie(test_transport.auth_cookie)
178- >>> trac.getCurrentDBTime()
179+ >>> with trac.responses():
180+ ... trac.getCurrentDBTime()
181 Using XML-RPC to generate token.
182 Successfully validated the token.
183 datetime.datetime(2008, 4, 9, 2, 2, 1, tzinfo=<UTC>)
184@@ -220,8 +212,8 @@
185 >>> bug_ids_to_check = ['1', '2', '3']
186 >>> last_checked = datetime(2008, 1, 1, 0, 0, 0)
187 >>> test_transport.expireCookie(test_transport.auth_cookie)
188- >>> sorted(trac.getModifiedRemoteBugs(
189- ... bug_ids_to_check, last_checked))
190+ >>> with trac.responses():
191+ ... sorted(trac.getModifiedRemoteBugs(bug_ids_to_check, last_checked))
192 Using XML-RPC to generate token.
193 Successfully validated the token.
194 ['1', '3']
195@@ -231,8 +223,8 @@
196
197 >>> last_checked = datetime(2008, 2, 1, 0, 0, 0)
198 >>> test_transport.expireCookie(test_transport.auth_cookie)
199- >>> trac.getModifiedRemoteBugs(
200- ... bug_ids_to_check, last_checked)
201+ >>> with trac.responses():
202+ ... trac.getModifiedRemoteBugs(bug_ids_to_check, last_checked)
203 Using XML-RPC to generate token.
204 Successfully validated the token.
205 ['1']
206@@ -242,8 +234,8 @@
207
208 >>> last_checked = datetime(2008, 5, 1, 0, 0, 0)
209 >>> test_transport.expireCookie(test_transport.auth_cookie)
210- >>> trac.getModifiedRemoteBugs(
211- ... bug_ids_to_check, last_checked)
212+ >>> with trac.responses():
213+ ... trac.getModifiedRemoteBugs(bug_ids_to_check, last_checked)
214 Using XML-RPC to generate token.
215 Successfully validated the token.
216 []
217@@ -255,8 +247,8 @@
218 >>> bug_ids_to_check = ['1', '2', '3', '99', '100']
219 >>> last_checked = datetime(2008, 1, 1, 0, 0, 0)
220 >>> test_transport.expireCookie(test_transport.auth_cookie)
221- >>> sorted(trac.getModifiedRemoteBugs(
222- ... bug_ids_to_check, last_checked))
223+ >>> with trac.responses():
224+ ... sorted(trac.getModifiedRemoteBugs(bug_ids_to_check, last_checked))
225 Using XML-RPC to generate token.
226 Successfully validated the token.
227 ['1', '100', '3', '99']
228@@ -281,7 +273,8 @@
229 >>> bugs_to_update = trac.getModifiedRemoteBugs(
230 ... bug_ids_to_check, last_checked)
231 >>> test_transport.expireCookie(test_transport.auth_cookie)
232- >>> trac.initializeRemoteBugDB(bugs_to_update)
233+ >>> with trac.responses():
234+ ... trac.initializeRemoteBugDB(bugs_to_update)
235 Using XML-RPC to generate token.
236 Successfully validated the token.
237
238@@ -368,7 +361,8 @@
239
240 >>> test_transport.expireCookie(test_transport.auth_cookie)
241 >>> bugs_to_update = ['1', '2', '3']
242- >>> trac.initializeRemoteBugDB(bugs_to_update)
243+ >>> with trac.responses():
244+ ... trac.initializeRemoteBugDB(bugs_to_update)
245 Using XML-RPC to generate token.
246 Successfully validated the token.
247
248@@ -410,7 +404,8 @@
249 >>> transaction.commit()
250
251 >>> test_transport.expireCookie(test_transport.auth_cookie)
252- >>> trac.fetchComments(remote_bug, ['1-1'])
253+ >>> with trac.responses():
254+ ... trac.fetchComments(remote_bug, ['1-1'])
255 Using XML-RPC to generate token.
256 Successfully validated the token.
257
258@@ -512,8 +507,9 @@
259 >>> message_rfc822msgid = message.rfc822msgid
260 >>> transaction.commit()
261
262- >>> remote_comment_id = trac.addRemoteComment(
263- ... '3', message_text_contents, message_rfc822msgid)
264+ >>> with trac.responses():
265+ ... remote_comment_id = trac.addRemoteComment(
266+ ... '3', message_text_contents, message_rfc822msgid)
267 Using XML-RPC to generate token.
268 Successfully validated the token.
269
270@@ -553,7 +549,8 @@
271 getLaunchpadBugId() requires authentication.
272
273 >>> test_transport.expireCookie(test_transport.auth_cookie)
274- >>> launchpad_bug_id = trac.getLaunchpadBugId('3')
275+ >>> with trac.responses():
276+ ... launchpad_bug_id = trac.getLaunchpadBugId('3')
277 Using XML-RPC to generate token.
278 Successfully validated the token.
279
280@@ -564,7 +561,9 @@
281 bug. setLaunchpadBugId() also requires authentication.
282
283 >>> test_transport.expireCookie(test_transport.auth_cookie)
284- >>> trac.setLaunchpadBugId('3', 15, 'http://bugs.launchpad.dev/bugs/xxx')
285+ >>> with trac.responses():
286+ ... trac.setLaunchpadBugId(
287+ ... '3', 15, 'http://bugs.launchpad.dev/bugs/xxx')
288 Using XML-RPC to generate token.
289 Successfully validated the token.
290
291
292=== modified file 'lib/lp/bugs/doc/externalbugtracker-trac.txt'
293--- lib/lp/bugs/doc/externalbugtracker-trac.txt 2016-07-04 17:08:29 +0000
294+++ lib/lp/bugs/doc/externalbugtracker-trac.txt 2018-06-23 02:06:14 +0000
295@@ -31,17 +31,14 @@
296 If the LP plugin is installed, the URL will return 401, since it fails
297 to validate the token.
298
299- >>> import urllib2
300- >>> class TracHavingLPPlugin401(Trac):
301- ... def urlopen(self, url, data=None):
302- ... print url
303- ... raise urllib2.HTTPError(
304- ... url, 401, "Unauthorized", None, None)
305- >>> trac = TracHavingLPPlugin401('http://example.com/')
306- >>> chosen_trac = trac.getExternalBugTrackerToUse()
307- http://example.com/launchpad-auth/check
308-
309+ >>> import responses
310 >>> from lp.bugs.externalbugtracker.trac import TracLPPlugin
311+
312+ >>> trac = Trac('http://example.com/')
313+ >>> with responses.RequestsMock() as requests_mock:
314+ ... requests_mock.add(
315+ ... 'GET', 'http://example.com/launchpad-auth/check', status=401)
316+ ... chosen_trac = trac.getExternalBugTrackerToUse()
317 >>> isinstance(chosen_trac, TracLPPlugin)
318 True
319 >>> chosen_trac.baseurl
320@@ -54,30 +51,13 @@
321 a valid token, which is very unlikely), is that the broken Trac will
322 not include a "trac_auth" cookie.
323
324- >>> from StringIO import StringIO
325- >>> from httplib import HTTPMessage
326- >>> from urllib import addinfourl
327-
328- >>> class TracReturning200ForPageNotFound(Trac):
329- ... def urlopen(self, url, data=None):
330- ... print url
331- ... return addinfourl(
332- ... StringIO(''), HTTPMessage(StringIO('')), url)
333-
334- >>> class TracHavingLPPlugin200(Trac):
335- ... def urlopen(self, url, data=None):
336- ... print url
337- ... return addinfourl(
338- ... StringIO(''), HTTPMessage(
339- ... StringIO('Set-Cookie: trac_auth=1234')), url)
340-
341 The plain, non-plugin, external bug tracker is selected for broken
342 Trac installations:
343
344- >>> trac = TracReturning200ForPageNotFound('http://example.com/')
345- >>> chosen_trac = trac.getExternalBugTrackerToUse()
346- http://example.com/launchpad-auth/check
347-
348+ >>> with responses.RequestsMock() as requests_mock:
349+ ... requests_mock.add(
350+ ... 'GET', 'http://example.com/launchpad-auth/check')
351+ ... chosen_trac = trac.getExternalBugTrackerToUse()
352 >>> isinstance(chosen_trac, TracLPPlugin)
353 False
354 >>> chosen_trac.baseurl
355@@ -86,10 +66,11 @@
356 In the event that our deliberately bogus token is considered valid,
357 the external bug tracker that groks the plugin is selected:
358
359- >>> trac = TracHavingLPPlugin200('http://example.com/')
360- >>> chosen_trac = trac.getExternalBugTrackerToUse()
361- http://example.com/launchpad-auth/check
362-
363+ >>> with responses.RequestsMock() as requests_mock:
364+ ... requests_mock.add(
365+ ... 'GET', 'http://example.com/launchpad-auth/check',
366+ ... headers={'Set-Cookie': 'trac_auth=1234'})
367+ ... chosen_trac = trac.getExternalBugTrackerToUse()
368 >>> isinstance(chosen_trac, TracLPPlugin)
369 True
370 >>> chosen_trac.baseurl
371@@ -97,15 +78,10 @@
372
373 If a 404 is returned, the normal Trac instance is returned.
374
375- >>> class TracNotHavingLPPlugin(Trac):
376- ... def urlopen(self, url, data=None):
377- ... print url
378- ... raise urllib2.HTTPError(
379- ... url, 404, "Not found", None, None)
380- >>> trac = TracNotHavingLPPlugin('http://example.com/')
381- >>> chosen_trac = trac.getExternalBugTrackerToUse()
382- http://example.com/launchpad-auth/check
383-
384+ >>> with responses.RequestsMock() as requests_mock:
385+ ... requests_mock.add(
386+ ... 'GET', 'http://example.com/launchpad-auth/check', status=404)
387+ ... chosen_trac = trac.getExternalBugTrackerToUse()
388 >>> chosen_trac is trac
389 True
390
391@@ -113,14 +89,12 @@
392 instance. It will deal with the connection error later, if the situation
393 persists.
394
395- >>> class TracWithConnectionError(Trac):
396- ... def urlopen(self, url, data=None):
397- ... print url
398- ... raise urllib2.URLError("Connection timed out")
399- >>> trac = TracWithConnectionError('http://example.com/')
400- >>> chosen_trac = trac.getExternalBugTrackerToUse()
401- http://example.com/launchpad-auth/check
402-
403+ >>> from requests.exceptions import ConnectTimeout
404+ >>> with responses.RequestsMock() as requests_mock:
405+ ... requests_mock.add(
406+ ... 'GET', 'http://example.com/launchpad-auth/check',
407+ ... body=ConnectTimeout())
408+ ... chosen_trac = trac.getExternalBugTrackerToUse()
409 >>> chosen_trac is trac
410 True
411
412@@ -172,18 +146,19 @@
413 local variable for later use.
414
415 We use a test-oriented implementation for the purposes of these tests, which
416-overrides ExternalBugTracker.urlopen() so that we don't have to rely on a
417-working network connection.
418+avoids relying on a network connection.
419
420 >>> from lp.bugs.tests.externalbugtracker import TestTrac
421 >>> trac = TestTrac(u'http://test.trac/')
422- >>> trac.initializeRemoteBugDB([1])
423+ >>> with trac.responses():
424+ ... trac.initializeRemoteBugDB([1])
425 >>> sorted(trac.bugs.keys())
426 [1]
427
428 If we initialize with a different set of keys we overwrite the first set:
429
430- >>> trac.initializeRemoteBugDB([6,7,8,9,10,11,12])
431+ >>> with trac.responses():
432+ ... trac.initializeRemoteBugDB([6, 7, 8, 9, 10, 11, 12])
433 >>> sorted(trac.bugs.keys())
434 [6, 7, 8, 9, 10, 11, 12]
435
436@@ -198,36 +173,41 @@
437 >>> trac.batch_query_threshold
438 10
439
440- >>> trac.trace_calls = True
441- >>> trac.initializeRemoteBugDB([6, 7, 8, 9, 10])
442- CALLED urlopen(u'http://test.trac/ticket/6?format=csv')
443- CALLED urlopen(u'http://test.trac/ticket/7?format=csv')
444- CALLED urlopen(u'http://test.trac/ticket/8?format=csv')
445- CALLED urlopen(u'http://test.trac/ticket/9?format=csv')
446- CALLED urlopen(u'http://test.trac/ticket/10?format=csv')
447+ >>> with trac.responses(trace_calls=True):
448+ ... trac.initializeRemoteBugDB([6, 7, 8, 9, 10])
449+ GET http://test.trac/ticket/6
450+ GET http://test.trac/ticket/6?format=csv
451+ GET http://test.trac/ticket/6?format=csv
452+ GET http://test.trac/ticket/7?format=csv
453+ GET http://test.trac/ticket/8?format=csv
454+ GET http://test.trac/ticket/9?format=csv
455+ GET http://test.trac/ticket/10?format=csv
456
457 If there are more than batch_query_threshold bugs to update then they are
458 fetched as a batch:
459
460 >>> trac.batch_query_threshold = 4
461- >>> trac.initializeRemoteBugDB([6, 7, 8, 9, 10])
462- CALLED urlopen(u'http://test.trac/query?id=6&id=7...&format=csv')
463+ >>> with trac.responses(trace_calls=True):
464+ ... trac.initializeRemoteBugDB([6, 7, 8, 9, 10])
465+ GET http://test.trac/query?id=6&id=7...&format=csv
466
467 The batch updating method will also be used in cases where the Trac instance
468 doesn't support CSV exports of individual tickets:
469
470 >>> trac.batch_query_threshold = 10
471- >>> trac.supports_single_exports = False
472- >>> trac.initializeRemoteBugDB([6, 7, 8, 9, 10])
473- CALLED urlopen(u'http://test.trac/query?id=6&id=7...&format=csv')
474+ >>> with trac.responses(trace_calls=True, supports_single_exports=False):
475+ ... trac.initializeRemoteBugDB([6, 7, 8, 9, 10])
476+ GET http://test.trac/ticket/6
477+ GET http://test.trac/ticket/6?format=csv
478+ GET http://test.trac/query?id=6&id=7...&format=csv
479
480 If, when using the batch export method, the Trac instance comes across
481 invalid data, it will raise an UnparsableBugData exception. We will
482 force our trac instance to use invalid data for the purposes of this
483 test.
484
485- >>> trac.csv_export_file = 'trac_example_broken_ticket_export.csv'
486- >>> trac.initializeRemoteBugDB([6, 7, 8, 9, 10])
487+ >>> with trac.responses(broken=True):
488+ ... trac.initializeRemoteBugDB([6, 7, 8, 9, 10])
489 Traceback (most recent call last):
490 ...
491 UnparsableBugData: External bugtracker http://test.trac does not
492@@ -236,8 +216,8 @@
493
494 This is also true of the single bug export mode.
495
496- >>> trac.supports_single_exports = True
497- >>> trac.initializeRemoteBugDB([6])
498+ >>> with trac.responses(broken=True):
499+ ... trac.initializeRemoteBugDB([6])
500 Traceback (most recent call last):
501 ...
502 UnparsableBugData: External bugtracker http://test.trac does not
503@@ -256,15 +236,13 @@
504 method to retrieve the CSV data from the remote Trac instance. This
505 method accepts a URL from which to retrieve the data as a parameter.
506
507- >>> trac.supports_single_exports = False
508- >>> trac.csv_export_file = None
509-
510- >>> query_url = 'http://example.com/query?id=%s&format=csv'
511+ >>> query_url = 'http://test.trac/query?id=%s&format=csv'
512 >>> query_string = '&id='.join(['1', '2', '3', '4', '5'])
513 >>> query_url = query_url % query_string
514
515- >>> remote_bugs = trac._fetchBugData(query_url)
516- CALLED urlopen('http://example.com/query?id=1&id=2...&format=csv')
517+ >>> with trac.responses(trace_calls=True, supports_single_exports=False):
518+ ... remote_bugs = trac._fetchBugData(query_url)
519+ GET http://test.trac/query?id=1&id=2...&format=csv
520
521 However, _fetchBugData() doesn't actually check the results it returns
522 except for checking that they are valid Trac CSV exports. in this case,
523@@ -277,8 +255,8 @@
524 If _fetchBugData() receives a response that isn't a valid Trac CSV
525 export, it will raise an UnparsableBugData error.
526
527- >>> trac.csv_export_file = 'trac_example_broken_ticket_export.csv'
528- >>> trac._fetchBugData(query_url)
529+ >>> with trac.responses(broken=True):
530+ ... trac._fetchBugData(query_url)
531 Traceback (most recent call last):
532 ...
533 UnparsableBugData: External bugtracker http://test.trac does not
534@@ -322,7 +300,9 @@
535 >>> bug_watch_updater = CheckwatchesMaster(
536 ... txn, FakeLogger())
537 >>> trac = TestTrac(example_bug_tracker.baseurl)
538- >>> bug_watch_updater.updateBugWatches(trac, example_bug_tracker.watches)
539+ >>> with trac.responses():
540+ ... bug_watch_updater.updateBugWatches(
541+ ... trac, example_bug_tracker.watches)
542 INFO Updating 1 watches for 1 bugs on http://bugs.some.where
543
544 >>> for bug_watch in example_bug_tracker.watches:
545@@ -364,10 +344,11 @@
546 ... remotebug=str(remote_bug_id))
547 ... bug_watches[remote_bug_id] = bug_watch.id
548
549- >>> trac.trace_calls = True
550- >>> bug_watch_updater.updateBugWatches(trac, example_bug_tracker.watches)
551+ >>> with trac.responses(trace_calls=True):
552+ ... bug_watch_updater.updateBugWatches(
553+ ... trac, example_bug_tracker.watches)
554 INFO Updating 12 watches for 12 bugs on http://bugs.some.where
555- CALLED urlopen(u'http://.../query?id=...
556+ GET http://bugs.some.where/query?id=...
557
558 >>> for remote_bug_id in sorted(bug_watches.keys()):
559 ... bug_watch = getUtility(IBugWatchSet).get(
560@@ -415,8 +396,8 @@
561 u'new'
562
563 >>> trac.batch_query_threshold = 0
564- >>> trac.trace_calls = False
565- >>> bug_watch_updater.updateBugWatches(trac, [bug_watch])
566+ >>> with trac.responses():
567+ ... bug_watch_updater.updateBugWatches(trac, [bug_watch])
568 INFO Updating 1 watches for 1 bugs on http://bugs.some.where
569
570 >>> bug_watch.lastchanged == old_last_changed
571
572=== modified file 'lib/lp/bugs/externalbugtracker/trac.py'
573--- lib/lp/bugs/externalbugtracker/trac.py 2015-07-08 16:05:11 +0000
574+++ lib/lp/bugs/externalbugtracker/trac.py 2018-06-23 02:06:14 +0000
575@@ -6,16 +6,16 @@
576 __metaclass__ = type
577 __all__ = ['Trac', 'TracLPPlugin']
578
579-from Cookie import SimpleCookie
580-from cookielib import CookieJar
581 import csv
582 from datetime import datetime
583 from email.utils import parseaddr
584 import time
585-import urllib2
586 import xmlrpclib
587
588+from mimeparse import parse_mime_type
589 import pytz
590+import requests
591+from requests.cookies import RequestsCookieJar
592 from zope.component import getUtility
593 from zope.interface import implementer
594
595@@ -24,13 +24,13 @@
596 BugNotFound,
597 BugTrackerAuthenticationError,
598 BugTrackerConnectError,
599- ExternalBugTracker,
600+ ExternalBugTrackerRequests,
601 InvalidBugId,
602 LookupTree,
603 UnknownRemoteStatusError,
604 UnparsableBugData,
605 )
606-from lp.bugs.externalbugtracker.xmlrpc import UrlLib2Transport
607+from lp.bugs.externalbugtracker.xmlrpc import RequestsTransport
608 from lp.bugs.interfaces.bugtask import (
609 BugTaskImportance,
610 BugTaskStatus,
611@@ -44,6 +44,10 @@
612 from lp.services.config import config
613 from lp.services.database.isolation import ensure_no_transaction
614 from lp.services.messages.interfaces.message import IMessageSet
615+from lp.services.timeout import (
616+ override_timeout,
617+ urlfetch,
618+ )
619 from lp.services.webapp.url import urlappend
620
621 # Symbolic constants used for the Trac LP plugin.
622@@ -56,7 +60,7 @@
623 FAULT_TICKET_NOT_FOUND = 1001
624
625
626-class Trac(ExternalBugTracker):
627+class Trac(ExternalBugTrackerRequests):
628 """An ExternalBugTracker instance for handling Trac bugtrackers."""
629
630 ticket_url = 'ticket/%i?format=csv'
631@@ -69,15 +73,16 @@
632 # Any token will do.
633 auth_url = urlappend(base_auth_url, 'check')
634 try:
635- response = self.urlopen(auth_url)
636- except urllib2.HTTPError as error:
637+ with override_timeout(config.checkwatches.default_socket_timeout):
638+ response = urlfetch(auth_url, trust_env=False, use_proxy=True)
639+ except requests.HTTPError as e:
640 # If the error is HTTP 401 Unauthorized then we're
641 # probably talking to the LP plugin.
642- if error.code == 401:
643+ if e.response.status_code == 401:
644 return TracLPPlugin(self.baseurl)
645 else:
646 return self
647- except urllib2.URLError as error:
648+ except requests.RequestException:
649 return self
650 else:
651 # If the response contains a trac_auth cookie then we're
652@@ -85,9 +90,8 @@
653 # the remote system will authorize the bogus auth token we
654 # sent, so this check is really intended to detect broken
655 # Trac instances that return HTTP 200 for a missing page.
656- for set_cookie in response.headers.getheaders('Set-Cookie'):
657- cookie = SimpleCookie(set_cookie)
658- if 'trac_auth' in cookie:
659+ for cookie in response.cookies:
660+ if cookie.name == 'trac_auth':
661 return TracLPPlugin(self.baseurl)
662 else:
663 return self
664@@ -98,31 +102,37 @@
665
666 :bug_ids: A list of bug IDs that we can use for discovery purposes.
667 """
668- valid_ticket = False
669 html_ticket_url = '%s/%s' % (
670 self.baseurl, self.ticket_url.replace('?format=csv', ''))
671
672- bug_ids = list(bug_ids)
673- while not valid_ticket and len(bug_ids) > 0:
674+ for bug_id in bug_ids:
675 try:
676- # We try to retrive the ticket in HTML form, since that will
677- # tell us whether or not it is actually a valid ticket
678- ticket_id = int(bug_ids.pop())
679- self._fetchPage(html_ticket_url % ticket_id)
680- except (ValueError, urllib2.HTTPError):
681- # If we get an HTTP error we can consider the ticket to be
682- # invalid. If we get a ValueError then the ticket_id couldn't
683- # be identified and it's of no use to us anyway.
684+ # We try to retrieve the ticket in HTML form, since that
685+ # will tell us whether or not it is actually a valid ticket.
686+ ticket_id = int(bug_id)
687+ self._getPage(html_ticket_url % ticket_id)
688+ except BugTrackerConnectError as e:
689+ if isinstance(e.error, requests.HTTPError):
690+ # We can consider the ticket to be invalid.
691+ pass
692+ else:
693+ raise
694+ except ValueError:
695+ # The ticket_id couldn't be identified and it's of no use to
696+ # us anyway.
697 pass
698 else:
699 # If we didn't get an error we can try to get the ticket in
700 # CSV form. If this fails then we can consider single ticket
701 # exports to be unsupported.
702 try:
703- csv_data = self._fetchPage(
704- "%s/%s" % (self.baseurl, self.ticket_url % ticket_id))
705- return csv_data.headers.subtype == 'csv'
706- except (urllib2.HTTPError, urllib2.URLError):
707+ response = self._getPage(
708+ "%s/%s" %
709+ (self.baseurl, self.ticket_url % ticket_id))
710+ subtype = parse_mime_type(
711+ response.headers.get('Content-Type', ''))[1]
712+ return subtype == 'csv'
713+ except BugTrackerConnectError:
714 return False
715 else:
716 # If we reach this point then we likely haven't had any valid
717@@ -140,7 +150,7 @@
718 """
719 # We read the remote bugs into a list so that we can check that
720 # the data we're getting back from the remote server are valid.
721- csv_reader = csv.DictReader(self._fetchPage(query_url))
722+ csv_reader = csv.DictReader(self._getPage(query_url).iter_lines())
723 remote_bugs = [csv_reader.next()]
724
725 # We consider the data we're getting from the remote server to
726@@ -320,9 +330,9 @@
727 super(TracLPPlugin, self).__init__(baseurl)
728
729 if cookie_jar is None:
730- cookie_jar = CookieJar()
731+ cookie_jar = RequestsCookieJar()
732 if xmlrpc_transport is None:
733- xmlrpc_transport = UrlLib2Transport(baseurl, cookie_jar)
734+ xmlrpc_transport = RequestsTransport(baseurl, cookie_jar)
735
736 self._cookie_jar = cookie_jar
737 self._xmlrpc_transport = xmlrpc_transport
738@@ -332,8 +342,10 @@
739 self._server = xmlrpclib.ServerProxy(
740 xmlrpc_endpoint, transport=self._xmlrpc_transport)
741
742- self.url_opener = urllib2.build_opener(
743- urllib2.HTTPCookieProcessor(cookie_jar))
744+ def makeRequest(self, method, url, **kwargs):
745+ """See `ExternalBugTracker`."""
746+ return super(TracLPPlugin, self).makeRequest(
747+ method, url, cookies=self._cookie_jar, **kwargs)
748
749 @ensure_no_transaction
750 @needs_authentication
751@@ -364,7 +376,7 @@
752 auth_url = urlappend(base_auth_url, token_text)
753
754 try:
755- self._fetchPage(auth_url)
756+ self._getPage(auth_url)
757 except BugTrackerConnectError as e:
758 raise BugTrackerAuthenticationError(self.baseurl, e.error)
759
760
761=== modified file 'lib/lp/bugs/tests/externalbugtracker.py'
762--- lib/lp/bugs/tests/externalbugtracker.py 2018-06-23 02:06:14 +0000
763+++ lib/lp/bugs/tests/externalbugtracker.py 2018-06-23 02:06:14 +0000
764@@ -52,10 +52,7 @@
765 LP_PLUGIN_METADATA_AND_COMMENTS,
766 LP_PLUGIN_METADATA_ONLY,
767 )
768-from lp.bugs.externalbugtracker.xmlrpc import (
769- RequestsTransport,
770- UrlLib2Transport,
771- )
772+from lp.bugs.externalbugtracker.xmlrpc import RequestsTransport
773 from lp.bugs.interfaces.bugtask import (
774 BugTaskImportance,
775 BugTaskStatus,
776@@ -1199,44 +1196,45 @@
777 requests_mock.add('POST', re.compile(re.escape(self.baseurl)))
778
779
780-class TestTrac(Trac):
781- """Trac ExternalBugTracker for testing purposes.
782-
783- It overrides urlopen, so that access to a real Trac instance isn't needed,
784- and supportsSingleExports so that the tests don't fail due to the lack of
785- a network connection. Also, it overrides the default batch_query_threshold
786- for the sake of making test data sane.
787- """
788+class TestTrac(BugTrackerResponsesMixin, Trac):
789+ """Trac ExternalBugTracker for testing purposes."""
790
791 # We remove the batch_size limit for the purposes of the tests so
792 # that we can test batching and not batching correctly.
793 batch_size = None
794 batch_query_threshold = 10
795 csv_export_file = None
796- supports_single_exports = True
797- trace_calls = False
798
799 def getExternalBugTrackerToUse(self):
800 return self
801
802- def supportsSingleExports(self, bug_ids):
803- """See `Trac`."""
804- return self.supports_single_exports
805-
806- def urlopen(self, url, data=None):
807- file_path = os.path.join(os.path.dirname(__file__), 'testfiles')
808- url = url.get_full_url()
809- if self.trace_calls:
810- print "CALLED urlopen(%r)" % (url)
811-
812- if self.csv_export_file is not None:
813- csv_export_file = self.csv_export_file
814- elif re.match('.*/ticket/[0-9]+\?format=csv$', url):
815- csv_export_file = 'trac_example_single_ticket_export.csv'
816- else:
817- csv_export_file = 'trac_example_ticket_export.csv'
818-
819- return open(file_path + '/' + csv_export_file, 'r')
820+ def _getCallback(self, request, supports_single_exports):
821+ url = urlsplit(request.url)
822+ if parse_qs(url.query).get('format') == ['csv']:
823+ mimetype = 'text/csv'
824+ if url.path.startswith('/ticket/'):
825+ if not supports_single_exports:
826+ return 404, {}, ''
827+ body = read_test_file('trac_example_single_ticket_export.csv')
828+ else:
829+ body = read_test_file('trac_example_ticket_export.csv')
830+ else:
831+ mimetype = 'text/html'
832+ body = ''
833+ return 200, {'Content-Type': mimetype}, body
834+
835+ def addResponses(self, requests_mock, supports_single_exports=True,
836+ broken=False):
837+ url_pattern = re.compile(re.escape(self.baseurl))
838+ if broken:
839+ requests_mock.add(
840+ 'GET', url_pattern,
841+ body=read_test_file('trac_example_broken_ticket_export.csv'))
842+ else:
843+ requests_mock.add_callback(
844+ 'GET', url_pattern,
845+ lambda request: self._getCallback(
846+ request, supports_single_exports))
847
848
849 class MockTracRemoteBug:
850@@ -1297,7 +1295,7 @@
851 return comment
852
853
854-class TestTracXMLRPCTransport(UrlLib2Transport):
855+class TestTracXMLRPCTransport(RequestsTransport):
856 """An XML-RPC transport to be used when testing Trac."""
857
858 remote_bugs = {}
859@@ -1313,8 +1311,12 @@
860
861 @property
862 def auth_cookie(self):
863- cookies = self.cookie_processor.cookiejar._cookies
864- return cookies.get('example.com', {}).get('', {}).get('trac_auth')
865+ for cookie in self.cookie_jar:
866+ if (cookie.domain == 'example.com' and cookie.path == '' and
867+ cookie.name == 'trac_auth'):
868+ return cookie
869+ else:
870+ return None
871
872 @property
873 def has_valid_auth_cookie(self):
874
875=== modified file 'setup.py'
876--- setup.py 2018-06-06 09:01:15 +0000
877+++ setup.py 2018-06-23 02:06:14 +0000
878@@ -201,6 +201,7 @@
879 'python-debian',
880 'python-keystoneclient',
881 'python-memcached',
882+ 'python-mimeparse',
883 'python-openid',
884 'python-subunit',
885 'python-swiftclient',