Merge lp:~cjwatson/launchpad/explicit-proxy-rt into lp:launchpad
- explicit-proxy-rt
- Merge into devel
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 |
Related bugs: |
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): |