Merge lp:~james-w/requestsplus/first-stab into lp:requestsplus

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
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 :

Overall looks good. I'm not sure the name is that great :-)

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

Subscribers

People subscribed via source and target branches