Repository: kopf/httsleep

Issue:
Support passing in a `Session` object
Users may already have a `requests.Session` object and wish to reuse that rather than having `httsleep` create a blank one.  This would save users from re-entering standard headers, settings etc. that they've already configured elsewhere.

### Ideal usage afterwards:
```python
    httsleep(url, until=until, alarms=alarms, session=my_session)
```

### ~~~Workaround~~~ Not usable
```python
    poller = HttSleeper(url, until=until, alarms=alarms)
    poller.session = my_session
    poller.run()
```
Not usable because [here](https://github.com/kopf/httsleep/blob/master/httsleep/main.py#L148) the request is prepared without the context of the Session:
```python
response = self.session.send(self.request.prepare()
```

The workaround would be usable if this line was instead:
```python
response = self.session.send(self.session.prepare_request(request))
```


Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index a162d14..836d8b5 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -3,8 +3,8 @@ httsleep Changelog
 
 Version NEXT
 -------------
-* The kwarg ``verify`` is now supported, allowing users of httsleep to specify
-  ``verify=False`` in the same way as when directly using the ``requests`` library.
+* The kwargs ``verify`` and ``session`` are now supported, allowing users of httsleep to
+  specify these in the same way as when directly using the ``requests`` library.
 
 Version 0.2.0
 -------------
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
index 7c8588b..5624d1e 100644
--- a/docs/tutorial.rst
+++ b/docs/tutorial.rst
@@ -40,6 +40,23 @@ pass a :class:`requests.Request` object in place of the URL in more specific cas
                  data={'payload': 'here'})
    response = httsleep(req, status_code=200)
 
+.. _Session object: http://docs.python-requests.org/en/master/user/advanced/#session-objects
+
+If you want to share headers, cookies etc. across multiple different HTTP requests (e.g.
+to maintain auth credentials), you might make use of a `Session object`_.
+
+.. code-block:: python
+
+   import requests
+   session = requests.Session()
+   session.verify = False
+   session.headers.update({'Authorization': 'token=%s' % auth_token,
+                           'Content-Type': 'application/json'})
+
+   response = session.post('http://server/jobs/create', data=data)
+   response = httsleep('http://server/jobs/1', session=session, until={'status_code': 200})
+   response = session.get('http://server/jobs/1/output')
+
 If we're polling a server with a dodgy network connection, we might not want to
 break on a :class:`requests.exceptions.ConnectionError`, but instead keep polling:
 
diff --git a/httsleep/main.py b/httsleep/main.py
index 649d5ed..00ac861 100644
--- a/httsleep/main.py
+++ b/httsleep/main.py
@@ -12,6 +12,7 @@ from ._compat import string_types
 DEFAULT_POLLING_INTERVAL = 2 # in seconds
 DEFAULT_MAX_RETRIES = 50
 VALID_CONDITIONS = ['status_code', 'json', 'jsonpath', 'text', 'callback']
+DEFAULT_SESSION = requests.Session()
 
 
 class HttSleeper(object):
@@ -33,10 +34,14 @@ class HttSleeper(object):
     :param callback: shorthand for a success condition dependent on a callback
                      function that takes the response as an argument returning True.
     :param auth: a (username, password) tuple for HTTP authentication.
-    :param headers: a dict of HTTP headers.
+    :param headers: a dict of HTTP headers.  If specified, these will be merged with (and take
+                    precedence over) any headers provided in the session.
+    :param session: a Requests session, providing cookie persistence, connection-pooling, and
+                    configuration (e.g. headers).
     :param verify: Either a boolean, in which case it controls whether we verify the server's
                    TLS certificate, or a string, in which case it must be a path to a CA
-                   bundle to use. Defaults to ``True``.
+                   bundle to use. If specified, this takes precedence over any value defined
+                   in the session (which itself would be ``True``, by default).
     :param polling_interval: how many seconds to sleep between requests.
     :param max_retries: the maximum number of retries to make, after which
                         a StopIteration exception is raised.
@@ -49,7 +54,7 @@ class HttSleeper(object):
     """
     def __init__(self, url_or_request, until=None, alarms=None,
                  status_code=None, json=None, jsonpath=None, text=None, callback=None,
-                 auth=None, headers=None, verify=True,
+                 auth=None, headers=None, session=DEFAULT_SESSION, verify=None,
                  polling_interval=DEFAULT_POLLING_INTERVAL,
                  max_retries=DEFAULT_MAX_RETRIES,
                  ignore_exceptions=None,
@@ -87,11 +92,13 @@ class HttSleeper(object):
                          'jsonpath': jsonpath, 'text': text, 'callback': callback}
             until.append({k: v for k, v in condition.items() if v})
 
-        self.verify = verify
+        self.kwargs = {}
+        if verify is not None:
+            self.kwargs['verify'] = verify
         self.until = until
         self.alarms = alarms
         self.polling_interval = int(polling_interval)
-        self.session = requests.Session()
+        self.session = session
         self.log = logging.getLogger()
         self.log.setLevel(loglevel)
 
@@ -149,7 +156,7 @@ class HttSleeper(object):
         """
         while True:
             try:
-                response = self.session.send(self.request.prepare(), verify=self.verify)
+                response = self.session.send(self.session.prepare_request(self.request), **self.kwargs)
                 for condition in self.alarms:
                     if self.meets_condition(response, condition):
                         raise Alarm(response, condition)
@@ -198,7 +205,7 @@ class HttSleeper(object):
 
 def httsleep(url_or_request, until=None, alarms=None, status_code=None,
              json=None, jsonpath=None, text=None, callback=None,
-             auth=None, headers=None, verify=True,
+             auth=None, headers=None, session=DEFAULT_SESSION, verify=None,
              polling_interval=DEFAULT_POLLING_INTERVAL,
              max_retries=DEFAULT_MAX_RETRIES,
              ignore_exceptions=None,
@@ -211,7 +218,7 @@ def httsleep(url_or_request, until=None, alarms=None, status_code=None,
     return HttSleeper(
         url_or_request, until=until, alarms=alarms, status_code=status_code,
         json=json, jsonpath=jsonpath, text=text, callback=callback,
-        auth=auth, headers=headers, verify=verify,
+        auth=auth, headers=headers, session=session, verify=verify,
         polling_interval=polling_interval,
         max_retries=max_retries,
         ignore_exceptions=ignore_exceptions,


Test patch:
diff --git a/tests/test_init.py b/tests/test_init.py
index d367d77..93cce22 100644
--- a/tests/test_init.py
+++ b/tests/test_init.py
@@ -35,14 +35,28 @@ def test_headers():
     assert obj.request.headers == headers
 
 
-def test_default_verify():
+def test_default_kwargs():
     obj = HttSleeper(URL, CONDITION)
-    assert obj.verify == True
+    assert obj.kwargs == {}
 
 
 def test_verify():
     obj = HttSleeper(URL, CONDITION, verify=False)
-    assert obj.verify == False
+    assert obj.kwargs == {'verify': False}
+
+
+def test_default_session():
+    obj = HttSleeper(URL, CONDITION)
+    assert obj.session.headers == requests.utils.default_headers()
+
+
+def test_session():
+    session = requests.Session()
+    session.headers = {'Content-Type': 'test/type'}
+    session.verify = False
+    obj = HttSleeper(URL, CONDITION, session=session)
+    assert obj.session.headers == session.headers
+    assert obj.session.verify == session.verify
 
 
 def test_ignore_exceptions_default_value():
diff --git a/tests/test_run.py b/tests/test_run.py
index 24f59ff..dbd4350 100644
--- a/tests/test_run.py
+++ b/tests/test_run.py
@@ -4,6 +4,7 @@ import httpretty
 from jsonpath_rw.jsonpath import Fields
 import mock
 import pytest
+import requests
 from requests.exceptions import ConnectionError
 from requests import Response
 
@@ -29,11 +30,11 @@ def test_propagate_verify():
     resp = Response()
     resp.status_code = 200
     httsleep = HttSleeper(URL, {'status_code': 200}, verify=False)
-    with mock.patch('requests.sessions.Session.send') as mock_session_send:
-        mock_session_send.return_value = resp
+    with mock.patch('requests.adapters.HTTPAdapter.send') as mock_adapter_send:
+        mock_adapter_send.return_value = resp
         httsleep.run()
-        assert mock_session_send.called
-        args, kwargs = mock_session_send.call_args
+        assert mock_adapter_send.called
+        args, kwargs = mock_adapter_send.call_args
     assert 'verify' in kwargs
     assert kwargs['verify'] == False
 
@@ -44,15 +45,85 @@ def test_default_sends_verify_true():
     resp = Response()
     resp.status_code = 200
     httsleep = HttSleeper(URL, {'status_code': 200})
-    with mock.patch('requests.sessions.Session.send') as mock_session_send:
-        mock_session_send.return_value = resp
+    with mock.patch('requests.adapters.HTTPAdapter.send') as mock_adapter_send:
+        mock_adapter_send.return_value = resp
         httsleep.run()
-        assert mock_session_send.called
-        args, kwargs = mock_session_send.call_args
+        assert mock_adapter_send.called
+        args, kwargs = mock_adapter_send.call_args
     assert 'verify' in kwargs
     assert kwargs['verify'] == True
 
 
+@httpretty.activate
+def test_default_uses_default_session():
+    """Should use a default Session object unless one is specified"""
+    resp = Response()
+    resp.status_code = 200
+    httsleep = HttSleeper(URL, {'status_code': 200})
+    with mock.patch('requests.adapters.HTTPAdapter.send') as mock_adapter_send:
+        mock_adapter_send.return_value = resp
+        httsleep.run()
+        assert mock_adapter_send.called
+        args, kwargs = mock_adapter_send.call_args
+    assert args[0].headers == requests.utils.default_headers()
+    assert 'verify' in kwargs
+    assert kwargs['verify'] == True
+
+
+@httpretty.activate
+def test_propagate_session():
+    """Should propagate a Session's headers, verify setting, cookies etc. when specified"""
+    session = requests.Session()
+    session.cookies = {'tasty-cookie': 'chocolate'}
+    session.headers = {'Content-Type': 'test/type'}
+    session.verify = '/session/verify'
+    resp = Response()
+    resp.status_code = 200
+    httsleep = HttSleeper(URL, {'status_code': 200}, session=session)
+    with mock.patch('requests.adapters.HTTPAdapter.send') as mock_adapter_send:
+        mock_adapter_send.return_value = resp
+        httsleep.run()
+        assert mock_adapter_send.called
+        args, kwargs = mock_adapter_send.call_args
+    assert args[0].headers == {'Content-Type': 'test/type', 'Cookie': 'tasty-cookie=chocolate'}
+    assert 'verify' in kwargs
+    assert kwargs['verify'] == '/session/verify'
+
+
+@httpretty.activate
+def test_session_headers_and_request_headers_combine():
+    """Should merge headers directly specified over any headers in a specified Session"""
+    session = requests.Session()
+    session.headers = {'session-header': 'mySession', 'conflict-header': 'session-loses'}
+    resp = Response()
+    resp.status_code = 200
+    httsleep = HttSleeper(URL, {'status_code': 200}, session=session,
+                          headers={'req-header': 'myRequest', 'conflict-header': 'req-wins'})
+    with mock.patch('requests.adapters.HTTPAdapter.send') as mock_adapter_send:
+        mock_adapter_send.return_value = resp
+        httsleep.run()
+        assert mock_adapter_send.called
+        args, kwargs = mock_adapter_send.call_args
+    assert args[0].headers == {'conflict-header': 'req-wins', 'req-header': 'myRequest', 'session-header': 'mySession'}
+
+
+@httpretty.activate
+def test_request_verify_overrules_session_verify():
+    """Should give precedence to the 'verify' setting in the request, over that in a specified Session"""
+    session = requests.Session()
+    session.verify = '/path/to/ca/bundle'
+    resp = Response()
+    resp.status_code = 200
+    httsleep = HttSleeper(URL, {'status_code': 200}, session=session, verify='/override/path')
+    with mock.patch('requests.adapters.HTTPAdapter.send') as mock_adapter_send:
+        mock_adapter_send.return_value = resp
+        httsleep.run()
+        assert mock_adapter_send.called
+        args, kwargs = mock_adapter_send.call_args
+    assert 'verify' in kwargs
+    assert kwargs['verify'] == '/override/path'
+
+
 @httpretty.activate
 def test_run_alarm():
     """Should raise an Alarm when a failure criteria has been reached"""