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

Proposed by Colin Watson
Status: Merged
Merged at revision: 18696
Proposed branch: lp:~cjwatson/launchpad/explicit-proxy-rt
Merge into: lp:launchpad
Prerequisite: lp:~cjwatson/launchpad/explicit-proxy-roundup
Diff against target: 425 lines (+121/-127)
4 files modified
lib/lp/bugs/doc/externalbugtracker-rt.txt (+39/-33)
lib/lp/bugs/externalbugtracker/rt.py (+46/-68)
lib/lp/bugs/tests/externalbugtracker.py (+30/-26)
lib/lp/services/timeout.py (+6/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/explicit-proxy-rt
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+348429@code.launchpad.net

Commit message

Convert the RequestTracker external bug tracker to urlfetch.

Description of the change

We need a bit of care to persist cookies in tests due to a bug in responses.

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-rt.txt'
2--- lib/lp/bugs/doc/externalbugtracker-rt.txt 2012-12-26 01:32:19 +0000
3+++ lib/lp/bugs/doc/externalbugtracker-rt.txt 2018-06-23 00:10:25 +0000
4@@ -21,23 +21,6 @@
5 ... RequestTracker('http://example.com/'))
6 True
7
8-The RequestTracker class offers an _opener property, an instance of
9-urllib2.OpenerDirector which will handle cookies and so allow the
10-RequestTracker instance to work correctly with RT cookies.
11-
12-We can demonstrate this by creating a test class which contains a stub
13-method for RequestTracker._logIn().
14-
15- >>> class NoLogInRequestTracker(RequestTracker):
16- ... def _logIn(self, opener):
17- ... """This method does nothing but say it's been called."""
18- ... print "_logIn() has been called."
19-
20- >>> request_tracker = NoLogInRequestTracker('http://example.com/')
21- >>> request_tracker._opener
22- _logIn() has been called.
23- <urllib2.OpenerDirector...>
24-
25
26 == Authentication Credentials ==
27
28@@ -108,13 +91,32 @@
29 of these tests, which allows us to not rely on a working network
30 connection.
31
32- >>> from lp.bugs.tests.externalbugtracker import (
33- ... TestRequestTracker)
34+ >>> from lp.bugs.tests.externalbugtracker import TestRequestTracker
35 >>> rt = TestRequestTracker('http://example.com/')
36- >>> rt.initializeRemoteBugDB([1585, 1586, 1587, 1588, 1589])
37+ >>> with rt.responses(trace_calls=True):
38+ ... rt.initializeRemoteBugDB([1585, 1586, 1587, 1588, 1589])
39+ GET http://example.com/?...
40+ GET http://example.com/REST/1.0/search/ticket/?...
41 >>> sorted(rt.bugs.keys())
42 [1585, 1586, 1587, 1588, 1589]
43
44+The first request logs into RT and saves the resulting cookie.
45+
46+ >>> def print_cookie_jar(jar):
47+ ... for name, value in sorted(jar.items()):
48+ ... print('%s=%s' % (name, value))
49+
50+ >>> print_cookie_jar(rt._cookie_jar)
51+ rt_credentials=guest:guest
52+
53+Subsequent requests use this.
54+
55+ >>> with rt.responses(trace_calls=True) as requests_mock:
56+ ... rt.initializeRemoteBugDB([1585, 1586, 1587, 1588, 1589])
57+ ... print(requests_mock.calls[0].request.headers['Cookie'])
58+ rt_credentials=guest:guest
59+ GET http://example.com/REST/1.0/search/ticket/?...
60+
61
62 == Export Methods ==
63
64@@ -126,9 +128,9 @@
65 >>> rt.batch_query_threshold
66 1
67
68- >>> rt.trace_calls = True
69- >>> rt.initializeRemoteBugDB([1585])
70- CALLED urlopen('REST/1.0/ticket/1585/show')
71+ >>> with rt.responses(trace_calls=True):
72+ ... rt.initializeRemoteBugDB([1585])
73+ GET http://example.com/REST/1.0/ticket/1585/show
74
75 >>> rt.bugs.keys()
76 [1585]
77@@ -136,8 +138,9 @@
78 If there are more than batch_query_threshold bugs to update then they are
79 fetched as a batch:
80
81- >>> rt.initializeRemoteBugDB([1585, 1586, 1587, 1588, 1589])
82- CALLED urlopen('REST/1.0/search/ticket/')
83+ >>> with rt.responses(trace_calls=True):
84+ ... rt.initializeRemoteBugDB([1585, 1586, 1587, 1588, 1589])
85+ GET http://example.com/REST/1.0/search/ticket/?...
86
87 >>> sorted(rt.bugs.keys())
88 [1585, 1586, 1587, 1588, 1589]
89@@ -146,19 +149,19 @@
90 BugTrackerConnectError will be raised. We can demonstrate this by making
91 our test RT instance simulate such a situation.
92
93- >>> rt.simulate_bad_response = True
94- >>> rt.initializeRemoteBugDB([1585])
95+ >>> with rt.responses(bad=True):
96+ ... rt.initializeRemoteBugDB([1585])
97 Traceback (most recent call last):
98 ...
99 BugTrackerConnectError...
100
101 This can also be demonstrated for importing bugs as a batch:
102
103- >>> rt.initializeRemoteBugDB([1585, 1586, 1587, 1588, 1589])
104+ >>> with rt.responses(bad=True):
105+ ... rt.initializeRemoteBugDB([1585, 1586, 1587, 1588, 1589])
106 Traceback (most recent call last):
107 ...
108 BugTrackerConnectError...
109- >>> rt.simulate_bad_response = False
110
111 == Updating Bug Watches ==
112
113@@ -198,7 +201,9 @@
114 >>> bug_watch_updater = CheckwatchesMaster(
115 ... txn, logger=FakeLogger())
116 >>> rt = TestRequestTracker(example_bug_tracker.baseurl)
117- >>> bug_watch_updater.updateBugWatches(rt, example_bug_tracker.watches)
118+ >>> with rt.responses():
119+ ... bug_watch_updater.updateBugWatches(
120+ ... rt, example_bug_tracker.watches)
121 INFO Updating 1 watches for 1 bugs on http://bugs.some.where
122
123 >>> print_bugwatches(example_bug_tracker.watches)
124@@ -225,10 +230,11 @@
125 ... bugtracker=example_bug_tracker,
126 ... remotebug=str(remote_bug_id))
127
128- >>> rt.trace_calls = True
129- >>> bug_watch_updater.updateBugWatches(rt, example_bug_tracker.watches)
130+ >>> with rt.responses(trace_calls=True):
131+ ... bug_watch_updater.updateBugWatches(
132+ ... rt, example_bug_tracker.watches)
133 INFO Updating 5 watches for 5 bugs on http://bugs.some.where
134- CALLED urlopen(u'REST/1.0/search/ticket/')
135+ GET http://bugs.some.where/REST/1.0/search/ticket/?...
136
137 The bug statuses have now been imported from the Example.com bug
138 tracker, so the bug watches should now have valid Launchpad bug
139
140=== modified file 'lib/lp/bugs/externalbugtracker/rt.py'
141--- lib/lp/bugs/externalbugtracker/rt.py 2013-04-22 06:43:16 +0000
142+++ lib/lp/bugs/externalbugtracker/rt.py 2018-06-23 00:10:25 +0000
143@@ -1,4 +1,4 @@
144-# Copyright 2009 Canonical Ltd. This software is licensed under the
145+# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
146 # GNU Affero General Public License version 3 (see the file LICENSE).
147
148 """RT ExternalBugTracker Utility."""
149@@ -7,13 +7,15 @@
150 __all__ = ['RequestTracker']
151
152 import email
153-import urllib
154-import urllib2
155+import re
156+
157+import requests
158+from requests.cookies import RequestsCookieJar
159
160 from lp.bugs.externalbugtracker import (
161 BugNotFound,
162 BugTrackerConnectError,
163- ExternalBugTracker,
164+ ExternalBugTrackerRequests,
165 InvalidBugId,
166 LookupTree,
167 UnknownRemoteStatusError,
168@@ -24,18 +26,24 @@
169 )
170 from lp.bugs.interfaces.externalbugtracker import UNKNOWN_REMOTE_IMPORTANCE
171 from lp.services.config import config
172-from lp.services.database.isolation import ensure_no_transaction
173-from lp.services.propertycache import cachedproperty
174 from lp.services.webapp.url import urlparse
175
176
177-class RequestTracker(ExternalBugTracker):
178+class RequestTracker(ExternalBugTrackerRequests):
179 """`ExternalBugTracker` subclass for handling RT imports."""
180
181 ticket_url = 'REST/1.0/ticket/%s/show'
182 batch_url = 'REST/1.0/search/ticket/'
183 batch_query_threshold = 1
184
185+ def __init__(self, baseurl, cookie_jar=None):
186+ super(RequestTracker, self).__init__(baseurl)
187+
188+ if cookie_jar is None:
189+ cookie_jar = RequestsCookieJar()
190+ self._cookie_jar = cookie_jar
191+ self._logged_in = False
192+
193 @property
194 def credentials(self):
195 """Return the authentication credentials needed to log in.
196@@ -53,68 +61,36 @@
197 except KeyError:
198 return {'user': 'guest', 'pass': 'guest'}
199
200- def _logIn(self, opener):
201- """Attempt to log in to the remote RT service.
202-
203- :param opener: An instance of urllib2.OpenerDirector
204- to be used to connect to the remote server.
205-
206- If HTTPError or URLErrors are encountered at any point in this
207- process, they will be raised to be caught at the callsite.
208-
209- This method is separate from the _opener property so as to allow
210- us to test the _opener property without having to connect to a
211- remote server.
212- """
213- # To log in to an RT instance we must pass a username and
214- # password to its login form, as a user would from the web.
215- opener.open('%s/' % self.baseurl, urllib.urlencode(
216- self.credentials))
217-
218- @cachedproperty
219- def _opener(self):
220- """Return a urllib2.OpenerDirector for the remote RT instance.
221-
222- An attempt will be made to log in to the remote instance before
223- the opener is returned. If logging in is not successful a
224- BugTrackerConnectError will be raised
225- """
226- opener = urllib2.build_opener(urllib2.HTTPCookieProcessor())
227-
228- # Attempt to log in to the remote system. Raise an error if we
229- # can't.
230- try:
231- self._logIn(opener)
232- except (urllib2.HTTPError, urllib2.URLError) as error:
233- raise BugTrackerConnectError('%s/' % self.baseurl,
234- "Unable to authenticate with remote RT service: "
235- "Could not submit login form: %s" % error)
236-
237- return opener
238-
239- @ensure_no_transaction
240- def urlopen(self, request, data=None):
241- """Return a handle to a remote resource.
242-
243- This method overrides that of `ExternalBugTracker` so that the
244- custom URL opener for RequestTracker instances can be used.
245- """
246- # We create our own opener so as to handle the RT authentication
247- # cookies that need to be passed around.
248- return self._opener.open(request, data)
249+ def makeRequest(self, method, url, **kwargs):
250+ """See `ExternalBugTracker`."""
251+ if not self._logged_in:
252+ # To log in to an RT instance we must pass a username and
253+ # password to its login form, as a user would from the web.
254+ try:
255+ super(RequestTracker, self).makeRequest(
256+ 'GET', '%s/' % self.baseurl,
257+ params=self.credentials, cookies=self._cookie_jar)
258+ except requests.RequestException as e:
259+ raise BugTrackerConnectError('%s/' % self.baseurl,
260+ "Unable to authenticate with remote RT service: "
261+ "Could not submit login form: %s" % e)
262+ self._logged_in = True
263+ return super(RequestTracker, self).makeRequest(
264+ method, url, cookies=self._cookie_jar, **kwargs)
265
266 def getRemoteBug(self, bug_id):
267 """See `ExternalBugTracker`."""
268 ticket_url = self.ticket_url % str(bug_id)
269 query_url = '%s/%s' % (self.baseurl, ticket_url)
270 try:
271- bug_data = self.urlopen(query_url)
272- except urllib2.HTTPError as error:
273- raise BugTrackerConnectError(ticket_url, str(error))
274+ bug_data = self.makeRequest('GET', query_url).text
275+ except requests.HTTPError as error:
276+ raise BugTrackerConnectError(ticket_url, error)
277
278 # We use the first line of the response to ensure that we've
279 # made a successful request.
280- firstline = bug_data.readline().strip().split(' ')
281+ bug_firstline, bug_rest = re.split(r'\r?\n', bug_data, maxsplit=1)
282+ firstline = bug_firstline.strip().split(' ')
283 if firstline[1] != '200':
284 # If anything goes wrong we raise a BugTrackerConnectError.
285 # We included in the error message the status code and error
286@@ -127,7 +103,7 @@
287
288 # RT's REST interface returns tickets in RFC822 format, so we
289 # can use the email module to parse them.
290- bug = email.message_from_string(bug_data.read().strip())
291+ bug = email.message_from_string(bug_rest.strip())
292 if bug.get('id') is None:
293 return None, None
294 else:
295@@ -138,19 +114,21 @@
296 """See `ExternalBugTracker`."""
297 # We need to ensure that all the IDs are strings first.
298 id_list = [str(id) for id in bug_ids]
299- query = "id = " + "OR id = ".join(id_list)
300+ query = "id = " + " OR id = ".join(id_list)
301
302 query_url = '%s/%s' % (self.baseurl, self.batch_url)
303 request_params = {'query': query, 'format': 'l'}
304 try:
305- bug_data = self.urlopen(query_url, urllib.urlencode(
306- request_params))
307- except urllib2.HTTPError as error:
308- raise BugTrackerConnectError(query_url, error.message)
309+ bug_data = self.makeRequest(
310+ 'GET', query_url, params=request_params,
311+ headers={'Referer': self.baseurl}).text
312+ except requests.HTTPError as error:
313+ raise BugTrackerConnectError(query_url, error)
314
315 # We use the first line of the response to ensure that we've
316 # made a successful request.
317- firstline = bug_data.readline().strip().split(' ')
318+ bug_firstline, bug_rest = re.split(r'\r?\n', bug_data, maxsplit=1)
319+ firstline = bug_firstline.strip().split(' ')
320 if firstline[1] != '200':
321 # If anything goes wrong we raise a BugTrackerConnectError.
322 # We included in the error message the status code and error
323@@ -164,7 +142,7 @@
324
325 # Tickets returned in RT multiline format are separated by lines
326 # containing only --\n.
327- tickets = bug_data.read().split("--\n")
328+ tickets = bug_rest.split("--\n")
329 bugs = {}
330 for ticket in tickets:
331 ticket = ticket.strip()
332
333=== modified file 'lib/lp/bugs/tests/externalbugtracker.py'
334--- lib/lp/bugs/tests/externalbugtracker.py 2018-06-23 00:10:25 +0000
335+++ lib/lp/bugs/tests/externalbugtracker.py 2018-06-23 00:10:25 +0000
336@@ -26,8 +26,6 @@
337 import responses
338 from six.moves.urllib_parse import (
339 parse_qs,
340- urljoin,
341- urlparse,
342 urlsplit,
343 )
344 from zope.component import getUtility
345@@ -1575,33 +1573,39 @@
346 'GET', re.compile(re.escape(self.baseurl)), self._getCallback)
347
348
349-class TestRequestTracker(RequestTracker):
350- """A Test-oriented `RequestTracker` implementation.
351+class TestRequestTracker(BugTrackerResponsesMixin, RequestTracker):
352+ """A Test-oriented `RequestTracker` implementation."""
353
354- Overrides _getPage() and _postPage() so that access to an RT
355- instance is not needed.
356- """
357- trace_calls = False
358 simulate_bad_response = False
359
360- def urlopen(self, page, data=None):
361- file_path = os.path.join(os.path.dirname(__file__), 'testfiles')
362- path = urlparse(page)[2].lstrip('/')
363- if self.trace_calls:
364- print "CALLED urlopen(%r)" % path
365-
366- if self.simulate_bad_response:
367- return open(file_path + '/' + 'rt-sample-bug-bad.txt')
368-
369- if path == self.batch_url:
370- return open(file_path + '/' + 'rt-sample-bug-batch.txt')
371- else:
372- # We extract the ticket ID from the url and use that to find
373- # the test file we want.
374- page_re = re.compile('REST/1.0/ticket/([0-9]+)/show')
375- bug_id = page_re.match(path).groups()[0]
376-
377- return open(file_path + '/' + 'rt-sample-bug-%s.txt' % bug_id)
378+ def _getCallback(self, request):
379+ url = urlsplit(request.url)
380+ headers = {}
381+ if url.path == '/':
382+ params = parse_qs(url.query)
383+ headers['Set-Cookie'] = 'rt_credentials=%s:%s' % (
384+ params['user'][0], params['pass'][0])
385+ body = ''
386+ else:
387+ if url.path == '/REST/1.0/search/ticket/':
388+ body = read_test_file('rt-sample-bug-batch.txt')
389+ else:
390+ # Extract the ticket ID from the URL and use that to
391+ # find the test file we want.
392+ bug_id = re.match(
393+ r'/REST/1\.0/ticket/([0-9]+)/show', url.path).group(1)
394+ body = read_test_file('rt-sample-bug-%s.txt' % bug_id)
395+ return 200, headers, body
396+
397+ def addResponses(self, requests_mock, bad=False):
398+ """Add test responses."""
399+ if bad:
400+ requests_mock.add(
401+ 'GET', re.compile(re.escape(self.baseurl)),
402+ body=read_test_file('rt-sample-bug-bad.txt'))
403+ else:
404+ requests_mock.add_callback(
405+ 'GET', re.compile(re.escape(self.baseurl)), self._getCallback)
406
407
408 class TestSourceForge(SourceForge):
409
410=== modified file 'lib/lp/services/timeout.py'
411--- lib/lp/services/timeout.py 2018-06-05 01:50:30 +0000
412+++ lib/lp/services/timeout.py 2018-06-23 00:10:25 +0000
413@@ -355,6 +355,12 @@
414 response.raise_for_status()
415 # Make sure the content has been consumed before returning.
416 response.content
417+ # The responses library doesn't persist cookies in the session
418+ # (https://github.com/getsentry/responses/issues/80). Work around
419+ # this.
420+ session_cookies = request_kwargs.get("cookies")
421+ if session_cookies is not None and response.cookies:
422+ session_cookies.update(response.cookies)
423 return response
424
425 def cleanup(self):