Merge lp:~james-w/requestsplus/first-stab into lp:requestsplus
- first-stab
- Merge into trunk
Proposed by
James Westby
Status: | Merged |
---|---|
Approved by: | Natalia Bidart |
Approved revision: | 9 |
Merged at revision: | 9 |
Proposed branch: | lp:~james-w/requestsplus/first-stab |
Merge into: | lp:requestsplus |
Diff against target: |
501 lines (+465/-0) 7 files modified
.bzrignore (+1/-0) Makefile (+19/-0) README (+35/-0) requestsplus/__init__.py (+114/-0) requestsplus/tests/__init__.py (+282/-0) requirements.txt (+7/-0) tarmac_tests.sh (+7/-0) |
To merge this branch: | bzr merge lp:~james-w/requestsplus/first-stab |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Natalia Bidart (community) | Approve | ||
James Westby (community) | Approve | ||
Review via email: mp+239275@code.launchpad.net |
Commit message
First stab at a requests helper library.
Description of the change
Hi,
This is my first attempt at a requests wrapper library, as described in Malta.
It's not a lot of code.
It has a great name.
Thanks,
James
To post a comment you must log in.
Revision history for this message
Natalia Bidart (nataliabidart) wrote : | # |
Revision history for this message
James Westby (james-w) : | # |
review:
Approve
Revision history for this message
Ubuntu One Auto Pilot (otto-pilot) wrote : | # |
There are additional revisions which have not been approved in review. Please seek review and approval of these new revisions.
Revision history for this message
Natalia Bidart (nataliabidart) : | # |
review:
Approve
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === added file '.bzrignore' |
2 | --- .bzrignore 1970-01-01 00:00:00 +0000 |
3 | +++ .bzrignore 2014-10-22 22:01:51 +0000 |
4 | @@ -0,0 +1,1 @@ |
5 | +virtualenv |
6 | |
7 | === added file 'Makefile' |
8 | --- Makefile 1970-01-01 00:00:00 +0000 |
9 | +++ Makefile 2014-10-22 22:01:51 +0000 |
10 | @@ -0,0 +1,19 @@ |
11 | +ENV=virtualenv |
12 | +BIN=$(ENV)/bin |
13 | +PIP=$(BIN)/pip |
14 | +PYTHON=$(BIN)/python |
15 | + |
16 | +$(ENV): |
17 | + virtualenv $(ENV) |
18 | + |
19 | +deps: $(ENV) |
20 | + $(PIP) install -r requirements.txt |
21 | + |
22 | +test: |
23 | + $(PYTHON) -m testtools.run discover |
24 | + |
25 | +lint: |
26 | + $(BIN)/pyflakes requestsplus |
27 | + $(BIN)/pep8 requestsplus |
28 | + |
29 | +.PHONY: deps test |
30 | |
31 | === added file 'README' |
32 | --- README 1970-01-01 00:00:00 +0000 |
33 | +++ README 2014-10-22 22:01:51 +0000 |
34 | @@ -0,0 +1,35 @@ |
35 | +requestsplus |
36 | +============ |
37 | + |
38 | +To make full use of the features for an internal request |
39 | +made during a django request you would use: |
40 | + |
41 | +import logging |
42 | +from django_statsd.clients import statsd |
43 | +from timeline_django.wsgi import timeline_factory |
44 | +from requestsplus import ( |
45 | + get_request_id, |
46 | + Session, |
47 | +) |
48 | + |
49 | + |
50 | +logger = logging.getLogger(__name__) |
51 | + |
52 | + |
53 | +def some_view(request): |
54 | + request_id = get_request_id(request) |
55 | + session = Session( |
56 | + logger=logger, |
57 | + request_id=request_id, |
58 | + timeline_factory=timeline_factory, |
59 | + statsd=statsd, |
60 | + statsd_prefix='request.sso.validate_request', |
61 | + ) |
62 | + response = session.get('https://...') |
63 | + django_response = HttpResponse() |
64 | + forward_oops_ids(response, django_response) |
65 | + |
66 | + |
67 | +If you are making an external request (i.e. to a service we |
68 | +don't control) then omit the request_id, and don't bother |
69 | +forwarding oops ids in the response. |
70 | |
71 | === added directory 'requestsplus' |
72 | === added file 'requestsplus/__init__.py' |
73 | --- requestsplus/__init__.py 1970-01-01 00:00:00 +0000 |
74 | +++ requestsplus/__init__.py 2014-10-22 22:01:51 +0000 |
75 | @@ -0,0 +1,114 @@ |
76 | +from contextlib import contextmanager |
77 | + |
78 | +from requests import Session as _Session |
79 | + |
80 | + |
81 | +OOPS_HEADER = 'X-Oops-Id' |
82 | +REQUEST_ID_HEADER = 'X-Request-Id' |
83 | + |
84 | + |
85 | +def get_request_id(request): |
86 | + return request.META.get('HTTP_X_REQUEST_ID') |
87 | + |
88 | + |
89 | +def get_oops_ids(response): |
90 | + return response.headers.get(OOPS_HEADER, None) |
91 | + |
92 | + |
93 | +def forward_oops_ids(response, django_response): |
94 | + oops_ids = get_oops_ids(response) |
95 | + if oops_ids is not None: |
96 | + django_response[OOPS_HEADER] = oops_ids |
97 | + |
98 | + |
99 | +class Session(_Session): |
100 | + |
101 | + def __init__(self, *args, **kwargs): |
102 | + self.logger = kwargs.pop('logger', None) |
103 | + self.request_id = kwargs.pop('request_id', None) |
104 | + self.timeline_factory = kwargs.pop('timeline_factory', None) |
105 | + self.statsd = kwargs.pop('statsd', None) |
106 | + self.statsd_prefix = kwargs.pop('statsd_prefix', None) |
107 | + super(Session, self).__init__(*args, **kwargs) |
108 | + |
109 | + def request(self, method, url, **kwargs): |
110 | + request_id = kwargs.pop('request_id', self.request_id) |
111 | + kwargs.setdefault('headers', {}) |
112 | + kwargs['headers'][REQUEST_ID_HEADER] = request_id |
113 | + with maybe_timeline(self.timeline_factory, method, url) as action: |
114 | + resp = super(Session, self).request(method, url, **kwargs) |
115 | + if action is not None: |
116 | + action.detail += " {}".format(resp.status_code) |
117 | + if self.logger: |
118 | + log_response(self.logger, resp) |
119 | + if self.statsd and self.statsd_prefix: |
120 | + record_stats(self.statsd, self.statsd_prefix, resp) |
121 | + return resp |
122 | + |
123 | + |
124 | +def record_stats(statsd, prefix, response): |
125 | + duration = response.elapsed.total_seconds() * 1000 |
126 | + statsd.timing(prefix, duration) |
127 | + statsd.timing(prefix + '.' + str(response.status_code), duration) |
128 | + statsd.timing(prefix + '.' + response.request.method, duration) |
129 | + statsd.timing( |
130 | + prefix + '.' + response.request.method |
131 | + + '.' + str(response.status_code), duration) |
132 | + |
133 | + |
134 | +def get_extra_details(response): |
135 | + # TODO: Probably log If-* headers if present in request |
136 | + extra = dict() |
137 | + |
138 | + def get_response_header(name, fallback_to_request=False): |
139 | + value = response.headers.get(name, None) |
140 | + if fallback_to_request and not value: |
141 | + value = response.request.headers.get(name, None) |
142 | + if value: |
143 | + extra[name] = value |
144 | + |
145 | + response_headers = [ |
146 | + 'Age', |
147 | + 'Allow', |
148 | + 'Content-Length', |
149 | + 'Content-Type', |
150 | + 'ETag', |
151 | + 'Last-Modified', |
152 | + 'Location', |
153 | + OOPS_HEADER, |
154 | + ] |
155 | + |
156 | + map(get_response_header, response_headers) |
157 | + get_response_header(REQUEST_ID_HEADER, fallback_to_request=True) |
158 | + |
159 | + return extra |
160 | + |
161 | + |
162 | +def log_response(logger, response): |
163 | + request = response.request |
164 | + extra = get_extra_details(response) |
165 | + |
166 | + logger.info( |
167 | + "%(method)s %(url)s returned %(status_code)s " |
168 | + "in %(duration)s %(extra)s", |
169 | + dict(method=request.method, |
170 | + url=request.url, |
171 | + status_code=response.status_code, |
172 | + duration=str(response.elapsed), |
173 | + extra=["{}={}".format(key, value) |
174 | + for key, value in sorted(extra.items())], |
175 | + ) |
176 | + ) |
177 | + |
178 | + |
179 | +@contextmanager |
180 | +def maybe_timeline(factory, category, detail): |
181 | + if factory is not None: |
182 | + timeline = factory() |
183 | + action = timeline.start(category, detail) |
184 | + try: |
185 | + yield action |
186 | + finally: |
187 | + action.finish() |
188 | + else: |
189 | + yield |
190 | |
191 | === added directory 'requestsplus/tests' |
192 | === added file 'requestsplus/tests/__init__.py' |
193 | --- requestsplus/tests/__init__.py 1970-01-01 00:00:00 +0000 |
194 | +++ requestsplus/tests/__init__.py 2014-10-22 22:01:51 +0000 |
195 | @@ -0,0 +1,282 @@ |
196 | +from datetime import timedelta |
197 | +import logging |
198 | + |
199 | +import fixtures |
200 | +from testtools import TestCase |
201 | +from responses import RequestsMock |
202 | +from requests import ( |
203 | + Request, |
204 | + Response, |
205 | +) |
206 | +from timeline import Timeline |
207 | + |
208 | +from .. import ( |
209 | + forward_oops_ids, |
210 | + get_extra_details, |
211 | + get_oops_ids, |
212 | + get_request_id, |
213 | + record_stats, |
214 | + Session, |
215 | +) |
216 | + |
217 | + |
218 | +class MockStatsd(object): |
219 | + |
220 | + def __init__(self): |
221 | + self.timings = {} |
222 | + |
223 | + def timing(self, key, value): |
224 | + self.timings[key] = value |
225 | + |
226 | + |
227 | +class RecordStatsTests(TestCase): |
228 | + |
229 | + def get_response(self): |
230 | + response = Response() |
231 | + response.request = Request( |
232 | + method="GET", url="http://localhost/").prepare() |
233 | + response.elapsed = timedelta(seconds=2) |
234 | + response.status_code = 400 |
235 | + return response |
236 | + |
237 | + def test_records_global(self): |
238 | + statsd = MockStatsd() |
239 | + response = self.get_response() |
240 | + prefix = 'a' |
241 | + record_stats(statsd, prefix, response) |
242 | + self.assertEqual(2000, statsd.timings[prefix]) |
243 | + |
244 | + def test_records_status_code(self): |
245 | + statsd = MockStatsd() |
246 | + response = self.get_response() |
247 | + prefix = 'a' |
248 | + record_stats(statsd, prefix, response) |
249 | + key = prefix + '.' + str(response.status_code) |
250 | + self.assertEqual(2000, statsd.timings[key]) |
251 | + |
252 | + def test_records_method(self): |
253 | + statsd = MockStatsd() |
254 | + response = self.get_response() |
255 | + prefix = 'a' |
256 | + record_stats(statsd, prefix, response) |
257 | + key = prefix + '.' + response.request.method |
258 | + self.assertEqual(2000, statsd.timings[key]) |
259 | + |
260 | + def test_records_method_and_status_code(self): |
261 | + statsd = MockStatsd() |
262 | + response = self.get_response() |
263 | + prefix = 'a' |
264 | + record_stats(statsd, prefix, response) |
265 | + key = (prefix + '.' + response.request.method |
266 | + + '.' + str(response.status_code)) |
267 | + self.assertEqual(2000, statsd.timings[key]) |
268 | + |
269 | + |
270 | +class GetExtraDetailsTests(TestCase): |
271 | + |
272 | + def get_response(self): |
273 | + response = Response() |
274 | + response.request = Request( |
275 | + method="GET", url="http://localhost/").prepare() |
276 | + return response |
277 | + |
278 | + def test_empty(self): |
279 | + response = self.get_response() |
280 | + self.assertEqual({}, get_extra_details(response)) |
281 | + |
282 | + def assert_header_used(self, name, value, response=None): |
283 | + if response is None: |
284 | + response = self.get_response() |
285 | + response.headers[name] = value |
286 | + self.assertEqual({name: value}, get_extra_details(response)) |
287 | + |
288 | + def test_age_header(self): |
289 | + self.assert_header_used('Age', '2') |
290 | + |
291 | + def test_allow_header(self): |
292 | + self.assert_header_used('Allow', 'POST') |
293 | + |
294 | + def test_content_length_header(self): |
295 | + self.assert_header_used('Content-Length', '26') |
296 | + |
297 | + def test_content_type_header(self): |
298 | + self.assert_header_used('Content-Type', 'text/plain') |
299 | + |
300 | + def test_etag_header(self): |
301 | + self.assert_header_used('ETag', '12345') |
302 | + |
303 | + def test_last_modified_header(self): |
304 | + self.assert_header_used('Last-Modified', 'yesterday') |
305 | + |
306 | + def test_location_header(self): |
307 | + self.assert_header_used('Location', 'here') |
308 | + |
309 | + def test_oops_header(self): |
310 | + self.assert_header_used('X-Oops-Id', 'OOPS-12345') |
311 | + |
312 | + def test_request_id_header(self): |
313 | + response = self.get_response() |
314 | + response.request.headers['X-Request-Id'] = '98765' |
315 | + self.assert_header_used('X-Request-Id', '12345', response=response) |
316 | + |
317 | + def test_request_id_request_header_fallback(self): |
318 | + response = self.get_response() |
319 | + response.request.headers['X-Request-Id'] = '98765' |
320 | + self.assertEqual( |
321 | + {'X-Request-Id': '98765'}, get_extra_details(response)) |
322 | + |
323 | + |
324 | +class GetOopsTests(TestCase): |
325 | + |
326 | + def get_response(self): |
327 | + response = Response() |
328 | + response.request = Request( |
329 | + method="GET", url="http://localhost/").prepare() |
330 | + return response |
331 | + |
332 | + def test_no_oops_header(self): |
333 | + response = self.get_response() |
334 | + self.assertEqual(None, get_oops_ids(response)) |
335 | + |
336 | + def test_none_oops_header(self): |
337 | + response = self.get_response() |
338 | + response.headers['X-Oops-Id'] = None |
339 | + self.assertEqual(None, get_oops_ids(response)) |
340 | + |
341 | + def test_oops_header(self): |
342 | + oopses = "['OOPS-12345', 'OOPS-67890']" |
343 | + response = self.get_response() |
344 | + response.headers['X-Oops-Id'] = oopses |
345 | + self.assertEqual(oopses, get_oops_ids(response)) |
346 | + |
347 | + |
348 | +class ForwardOopsIdsTests(TestCase): |
349 | + |
350 | + def get_response(self): |
351 | + response = Response() |
352 | + response.request = Request( |
353 | + method="GET", url="http://localhost/").prepare() |
354 | + return response |
355 | + |
356 | + def test_no_oops_ids(self): |
357 | + response = self.get_response() |
358 | + django_response = dict() |
359 | + forward_oops_ids(response, django_response) |
360 | + self.assertNotIn('X-Oops-Id', django_response) |
361 | + |
362 | + def test_oops_ids(self): |
363 | + response = self.get_response() |
364 | + oops_ids = 'OOPS-1' |
365 | + response.headers['X-Oops-Id'] = oops_ids |
366 | + django_response = dict() |
367 | + forward_oops_ids(response, django_response) |
368 | + self.assertEqual(oops_ids, django_response['X-Oops-Id']) |
369 | + |
370 | + |
371 | +class FakeDjangoRequest(object): |
372 | + |
373 | + def __init__(self): |
374 | + self.META = dict() |
375 | + |
376 | + |
377 | +class GetRequestIdTests(TestCase): |
378 | + |
379 | + def test_no_header(self): |
380 | + request = FakeDjangoRequest() |
381 | + self.assertEqual(None, get_request_id(request)) |
382 | + |
383 | + def test_header(self): |
384 | + request = FakeDjangoRequest() |
385 | + request_id = 1 |
386 | + request.META['HTTP_X_REQUEST_ID'] = request_id |
387 | + self.assertEqual(request_id, get_request_id(request)) |
388 | + |
389 | + |
390 | +class SessionTests(TestCase): |
391 | + |
392 | + def test_sends_request_id_header(self): |
393 | + s = Session(request_id='b') |
394 | + request_id = 'a' |
395 | + responses = RequestsMock() |
396 | + responses.add('GET', 'http://localhost') |
397 | + with responses: |
398 | + s.get('http://localhost', request_id=request_id) |
399 | + self.assertEqual(1, len(responses.calls)) |
400 | + self.assertEqual( |
401 | + request_id, |
402 | + responses.calls[0].request.headers['X-Request-Id']) |
403 | + |
404 | + def test_sends_request_id_header_with_other_headers(self): |
405 | + s = Session() |
406 | + request_id = 'a' |
407 | + responses = RequestsMock() |
408 | + responses.add('GET', 'http://localhost') |
409 | + with responses: |
410 | + s.get('http://localhost', request_id=request_id, |
411 | + headers=dict(a='b')) |
412 | + self.assertEqual(1, len(responses.calls)) |
413 | + self.assertEqual( |
414 | + request_id, |
415 | + responses.calls[0].request.headers['X-Request-Id']) |
416 | + self.assertEqual( |
417 | + 'b', responses.calls[0].request.headers['a']) |
418 | + |
419 | + def test_uses_per_session_request_id(self): |
420 | + request_id = 'b' |
421 | + s = Session(request_id=request_id) |
422 | + responses = RequestsMock() |
423 | + responses.add('GET', 'http://localhost') |
424 | + with responses: |
425 | + s.get('http://localhost') |
426 | + self.assertEqual(1, len(responses.calls)) |
427 | + self.assertEqual( |
428 | + request_id, |
429 | + responses.calls[0].request.headers['X-Request-Id']) |
430 | + |
431 | + def test_no_request_id(self): |
432 | + s = Session() |
433 | + responses = RequestsMock() |
434 | + responses.add('GET', 'http://localhost') |
435 | + with responses: |
436 | + s.get('http://localhost') |
437 | + self.assertEqual(1, len(responses.calls)) |
438 | + self.assertNotIn( |
439 | + 'X-Request-Id', responses.calls[0].request.headers) |
440 | + |
441 | + def test_logs_request(self): |
442 | + log_capture = self.useFixture(fixtures.FakeLogger()) |
443 | + logger = logging.getLogger(__name__) |
444 | + s = Session(logger=logger) |
445 | + responses = RequestsMock() |
446 | + responses.add('GET', 'http://localhost') |
447 | + with responses: |
448 | + s.get('http://localhost') |
449 | + self.assertEqual( |
450 | + "GET http://localhost/ returned 200 " |
451 | + "in 0:00:00 ['Content-Type=text/plain']\n", |
452 | + log_capture.output) |
453 | + |
454 | + def test_uses_timeline_factory(self): |
455 | + timeline = Timeline() |
456 | + timeline_factory = lambda: timeline |
457 | + s = Session(timeline_factory=timeline_factory) |
458 | + responses = RequestsMock() |
459 | + url = 'http://localhost' |
460 | + responses.add('GET', url) |
461 | + with responses: |
462 | + s.get(url) |
463 | + self.assertEqual(1, len(timeline.actions)) |
464 | + action = timeline.actions[0] |
465 | + self.assertIsInstance(action.duration, timedelta) |
466 | + self.assertEqual('GET', action.category) |
467 | + self.assertEqual(url + " 200", action.detail) |
468 | + |
469 | + def test_records_stats(self): |
470 | + statsd = MockStatsd() |
471 | + s = Session(statsd=statsd, statsd_prefix='a') |
472 | + responses = RequestsMock() |
473 | + url = 'http://localhost' |
474 | + responses.add('GET', url) |
475 | + with responses: |
476 | + s.get(url) |
477 | + self.assertEqual(4, len(statsd.timings)) |
478 | |
479 | === added file 'requirements.txt' |
480 | --- requirements.txt 1970-01-01 00:00:00 +0000 |
481 | +++ requirements.txt 2014-10-22 22:01:51 +0000 |
482 | @@ -0,0 +1,7 @@ |
483 | +fixtures |
484 | +pyflakes |
485 | +pep8 |
486 | +requests |
487 | +responses |
488 | +testtools |
489 | +timeline |
490 | |
491 | === added file 'tarmac_tests.sh' |
492 | --- tarmac_tests.sh 1970-01-01 00:00:00 +0000 |
493 | +++ tarmac_tests.sh 2014-10-22 22:01:51 +0000 |
494 | @@ -0,0 +1,7 @@ |
495 | +#!/bin/bash |
496 | + |
497 | +set -e |
498 | + |
499 | +make deps |
500 | +make test |
501 | +make lint |
Overall looks good. I'm not sure the name is that great :-)