Repository: ithaka/apiron

Issue:
How to do HTTPBasicAuth using apiron?
**Describe the bug**
How to do HTTPBasicAuth using apiron?

**To Reproduce**
I use below in requests:

`requests.get('https://api.github.com/user', auth=HTTPBasicAuth('user', 'pass'))`


Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ea12e81..2fad243 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ## Unreleased
 ### Added
 - Ability to specify `proxies` for a `Service` definition so all calls to the service use the defined proxies
+- Ability to specify `auth` for a `Service` definition so all calls to the service use the defined authentication
 - Ability to specify `return_raw_response_object` at the endpoint level, overridden by any value specified at call time
 
 ## [5.0.0] - 2019-12-02
diff --git a/setup.cfg b/setup.cfg
index b456e3a..366d9a3 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -44,6 +44,7 @@ lint =
 test =
     pytest==5.2.2
     pytest-cov==2.8.1
+    pytest-randomly==3.4.0
 docs =
     importlib-metadata==0.23
     sphinx==2.2.1
@@ -67,7 +68,7 @@ skip_covered = True
 
 [tool:pytest]
 testpaths = tests
-addopts = -ra -q --strict --cov
+addopts = -ra --strict --cov
 xfail_strict = True
 
 [tox:tox]
@@ -86,5 +87,5 @@ commands =
 [testenv:lint]
 extras = lint
 commands =
-    pyflakes src/apiron tests
-    black --check src/apiron tests
+    pyflakes {posargs:src tests}
+    black {posargs:--check src tests}
diff --git a/src/apiron/client.py b/src/apiron/client.py
index 80f3d60..b72e78f 100644
--- a/src/apiron/client.py
+++ b/src/apiron/client.py
@@ -224,6 +224,8 @@ def call(
 
     method = method or endpoint.default_method
 
+    auth = auth or session.auth or service.auth
+
     request = build_request_object(
         session,
         service,
diff --git a/src/apiron/service/base.py b/src/apiron/service/base.py
index 7847d85..a2fe5a0 100644
--- a/src/apiron/service/base.py
+++ b/src/apiron/service/base.py
@@ -19,6 +19,7 @@ class ServiceMeta(type):
 
 class ServiceBase(metaclass=ServiceMeta):
     required_headers = {}
+    auth = ()
     proxies = {}
 
     @classmethod


Test patch:
diff --git a/tests/service/test_base.py b/tests/service/test_base.py
index 8ee3a48..8ce47c5 100644
--- a/tests/service/test_base.py
+++ b/tests/service/test_base.py
@@ -3,7 +3,7 @@ import pytest
 from apiron import Endpoint, Service, ServiceBase
 
 
-@pytest.fixture(scope="class")
+@pytest.fixture
 def service():
     class SomeService(Service):
         domain = "http://foo.com"
diff --git a/tests/test_client.py b/tests/test_client.py
index 41e775c..6ab00f0 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -22,249 +22,287 @@ def mock_endpoint():
     return endpoint
 
 
-class TestClient:
-    @mock.patch("requests.sessions.Session", autospec=True)
-    def test_get_adapted_session(self, mock_session):
-        adapter = mock.Mock()
-        adapted_session = client.get_adapted_session(adapter)
-        assert adapter == adapted_session.get_adapter("http://foo.com")
-        assert adapter == adapted_session.get_adapter("https://foo.com")
-
-    def test_get_required_headers(self, mock_endpoint):
-        service = mock.Mock()
-        service.required_headers = {"one": "two"}
-        mock_endpoint.required_headers = {"foo": "bar"}
-        expected_headers = {}
-        expected_headers.update(service.required_headers)
-        expected_headers.update(mock_endpoint.required_headers)
-        assert expected_headers == client.get_required_headers(service, mock_endpoint)
-
-    @mock.patch("apiron.client.requests.Request")
-    @mock.patch("apiron.client.get_required_headers")
-    def test_build_request_object_passes_arguments_to_request_constructor(
-        self, mock_get_required_headers, mock_request_constructor, mock_endpoint
-    ):
-        session = mock.Mock()
-
-        service = mock.Mock()
-        service.get_hosts.return_value = ["http://host1.biz"]
-
-        mock_endpoint.default_method = "POST"
-        mock_endpoint.required_headers = {"header": "value"}
-        mock_endpoint.required_params = set()
-
-        params = {"baz": "qux"}
-        mock_endpoint.get_merged_params.return_value = params
-        data = "I am a data"
-        files = {"file_name": io.BytesIO(b"this is a test")}
-        json = {"raw": "data"}
-        headers = {"Accept": "stuff"}
-        cookies = {"chocolate-chip": "yes"}
-        auth = mock.Mock()
-
-        mock_get_required_headers.return_value = {"header": "value"}
-        expected_headers = {}
-        expected_headers.update(headers)
-        expected_headers.update(mock_endpoint.required_headers)
-
-        with mock.patch.object(session, "prepare_request") as mock_prepare_request:
-            client.build_request_object(
-                session,
-                service,
-                mock_endpoint,
-                params=params,
-                data=data,
-                files=files,
-                json=json,
-                headers=headers,
-                cookies=cookies,
-                auth=auth,
-                foo="bar",
-            )
-
-            mock_request_constructor.assert_called_once_with(
-                url="http://host1.biz/foo/",
-                method=mock_endpoint.default_method,
-                headers=expected_headers,
-                cookies=cookies,
-                params=params,
-                data=data,
-                files=files,
-                json=json,
-                auth=auth,
-            )
-
-            assert 1 == mock_prepare_request.call_count
-
-    @mock.patch("apiron.client.Timeout")
-    @mock.patch("apiron.client.get_adapted_session")
-    @mock.patch("apiron.client.build_request_object")
-    @mock.patch("requests.adapters.HTTPAdapter", autospec=True)
-    @mock.patch("requests.Session", autospec=True)
-    def test_call(
-        self,
-        MockSession,
-        MockAdapter,
-        mock_build_request_object,
-        mock_get_adapted_session,
-        mock_timeout,
-        mock_response,
-        mock_endpoint,
-    ):
-        service = mock.Mock()
-        service.get_hosts.return_value = ["http://host1.biz"]
-
-        mock_endpoint.default_method = "GET"
-        mock_endpoint.streaming = True
-
-        request = mock.Mock()
-        request.url = "http://host1.biz/foo/"
-        mock_build_request_object.return_value = request
-
-        mock_logger = mock.Mock()
-
-        mock_response.status_code = 200
-        mock_response.url = "http://host1.biz/foo/"
-
-        mock_session = MockSession()
-        mock_session.send.return_value = mock_response
-        mock_session.proxies = {}
-        mock_get_adapted_session.return_value = mock_session
-
-        client.call(service, mock_endpoint, timeout_spec=mock_timeout, logger=mock_logger)
-
-        mock_get_adapted_session.assert_called_once_with(MockAdapter())
-        mock_session.send.assert_called_once_with(
-            request,
-            timeout=(mock_timeout.connection_timeout, mock_timeout.read_timeout),
-            stream=mock_endpoint.streaming,
-            allow_redirects=True,
-            proxies=service.proxies,
+@pytest.fixture
+def mock_logger():
+    return mock.Mock()
+
+
+@mock.patch("requests.sessions.Session", autospec=True)
+def test_get_adapted_session(mock_session):
+    adapter = mock.Mock()
+    adapted_session = client.get_adapted_session(adapter)
+    assert adapter == adapted_session.get_adapter("http://foo.com")
+    assert adapter == adapted_session.get_adapter("https://foo.com")
+
+
+def test_get_required_headers(mock_endpoint):
+    service = mock.Mock()
+    service.required_headers = {"one": "two"}
+    mock_endpoint.required_headers = {"foo": "bar"}
+    expected_headers = {}
+    expected_headers.update(service.required_headers)
+    expected_headers.update(mock_endpoint.required_headers)
+    assert expected_headers == client.get_required_headers(service, mock_endpoint)
+
+
+@mock.patch("apiron.client.requests.Request")
+@mock.patch("apiron.client.get_required_headers")
+def test_build_request_object_passes_arguments_to_request_constructor(
+    mock_get_required_headers, mock_request_constructor, mock_endpoint
+):
+    session = mock.Mock()
+
+    service = mock.Mock()
+    service.get_hosts.return_value = ["http://host1.biz"]
+
+    mock_endpoint.default_method = "POST"
+    mock_endpoint.required_headers = {"header": "value"}
+    mock_endpoint.required_params = set()
+
+    params = {"baz": "qux"}
+    mock_endpoint.get_merged_params.return_value = params
+    data = "I am a data"
+    files = {"file_name": io.BytesIO(b"this is a test")}
+    json = {"raw": "data"}
+    headers = {"Accept": "stuff"}
+    cookies = {"chocolate-chip": "yes"}
+    auth = mock.Mock()
+
+    mock_get_required_headers.return_value = {"header": "value"}
+    expected_headers = {}
+    expected_headers.update(headers)
+    expected_headers.update(mock_endpoint.required_headers)
+
+    with mock.patch.object(session, "prepare_request") as mock_prepare_request:
+        client.build_request_object(
+            session,
+            service,
+            mock_endpoint,
+            params=params,
+            data=data,
+            files=files,
+            json=json,
+            headers=headers,
+            cookies=cookies,
+            auth=auth,
+            foo="bar",
         )
 
-        mock_logger.info.assert_any_call("GET http://host1.biz/foo/")
-        mock_logger.info.assert_any_call("200 http://host1.biz/foo/")
+        mock_request_constructor.assert_called_once_with(
+            url="http://host1.biz/foo/",
+            method=mock_endpoint.default_method,
+            headers=expected_headers,
+            cookies=cookies,
+            params=params,
+            data=data,
+            files=files,
+            json=json,
+            auth=auth,
+        )
 
-        mock_endpoint.default_method = "POST"
-        request.method = "POST"
+        assert 1 == mock_prepare_request.call_count
+
+
+@mock.patch("apiron.client.Timeout")
+@mock.patch("apiron.client.get_adapted_session")
+@mock.patch("apiron.client.build_request_object")
+@mock.patch("requests.adapters.HTTPAdapter", autospec=True)
+@mock.patch("requests.Session", autospec=True)
+def test_call(
+    MockSession,
+    MockAdapter,
+    mock_build_request_object,
+    mock_get_adapted_session,
+    mock_timeout,
+    mock_response,
+    mock_endpoint,
+    mock_logger,
+):
+    service = mock.Mock()
+    service.get_hosts.return_value = ["http://host1.biz"]
+
+    mock_endpoint.default_method = "GET"
+    mock_endpoint.streaming = True
+
+    request = mock.Mock()
+    request.url = "http://host1.biz/foo/"
+    mock_build_request_object.return_value = request
+
+    mock_response.status_code = 200
+    mock_response.url = "http://host1.biz/foo/"
+
+    mock_session = MockSession()
+    mock_session.send.return_value = mock_response
+    mock_session.proxies = {}
+    mock_session.auth = ()
+    mock_get_adapted_session.return_value = mock_session
 
-        client.call(service, mock_endpoint, session=mock_session, timeout_spec=mock_timeout, logger=mock_logger)
+    client.call(service, mock_endpoint, timeout_spec=mock_timeout, logger=mock_logger)
 
-        mock_session.send.assert_any_call(
-            request,
-            timeout=(mock_timeout.connection_timeout, mock_timeout.read_timeout),
-            stream=mock_endpoint.streaming,
-            allow_redirects=True,
-            proxies=service.proxies,
-        )
+    mock_get_adapted_session.assert_called_once_with(MockAdapter())
+    mock_session.send.assert_called_once_with(
+        request,
+        timeout=(mock_timeout.connection_timeout, mock_timeout.read_timeout),
+        stream=mock_endpoint.streaming,
+        allow_redirects=True,
+        proxies=service.proxies,
+    )
 
-        mock_logger.info.assert_any_call("GET http://host1.biz/foo/")
-        mock_logger.info.assert_any_call("200 http://host1.biz/foo/")
+    mock_logger.info.assert_any_call("GET http://host1.biz/foo/")
+    mock_logger.info.assert_any_call("200 http://host1.biz/foo/")
 
-        request.method = "PUT"
+    mock_endpoint.default_method = "POST"
+    request.method = "POST"
 
-        client.call(
-            service, mock_endpoint, method="PUT", session=mock_session, timeout_spec=mock_timeout, logger=mock_logger
-        )
+    client.call(service, mock_endpoint, session=mock_session, timeout_spec=mock_timeout, logger=mock_logger)
 
-        mock_session.send.assert_any_call(
-            request,
-            timeout=(mock_timeout.connection_timeout, mock_timeout.read_timeout),
-            stream=mock_endpoint.streaming,
-            allow_redirects=True,
-            proxies=service.proxies,
-        )
+    mock_session.send.assert_any_call(
+        request,
+        timeout=(mock_timeout.connection_timeout, mock_timeout.read_timeout),
+        stream=mock_endpoint.streaming,
+        allow_redirects=True,
+        proxies=service.proxies,
+    )
 
-    @mock.patch("apiron.client.get_adapted_session")
-    def test_call_with_existing_session(self, mock_get_adapted_session, mock_response, mock_endpoint):
-        service = mock.Mock()
-        service.get_hosts.return_value = ["http://host1.biz"]
-        service.required_headers = {}
+    mock_logger.info.assert_any_call("GET http://host1.biz/foo/")
+    mock_logger.info.assert_any_call("200 http://host1.biz/foo/")
 
-        mock_logger = mock.Mock()
+    request.method = "PUT"
 
-        session = mock.Mock()
-        session.send.return_value = mock_response
+    client.call(
+        service, mock_endpoint, method="PUT", session=mock_session, timeout_spec=mock_timeout, logger=mock_logger
+    )
 
-        client.call(service, mock_endpoint, session=session, logger=mock_logger)
+    mock_session.send.assert_any_call(
+        request,
+        timeout=(mock_timeout.connection_timeout, mock_timeout.read_timeout),
+        stream=mock_endpoint.streaming,
+        allow_redirects=True,
+        proxies=service.proxies,
+    )
 
-        assert not mock_get_adapted_session.called
-        assert not session.close.called
 
-    def test_call_with_explicit_encoding(self, mock_response, mock_endpoint):
-        service = mock.Mock()
-        service.get_hosts.return_value = ["http://host1.biz"]
-        service.required_headers = {}
+@mock.patch("apiron.client.build_request_object")
+@mock.patch("apiron.client.get_adapted_session")
+@mock.patch("requests.Session", autospec=True)
+def test_call_auth_priority(
+    MockSession, mock_get_adapted_session, mock_build_request_object, mock_endpoint, mock_logger
+):
+    service = mock.Mock()
+    service.get_hosts.return_value = ["http://host1.biz"]
+    service.required_headers = {}
+    service.auth = ("service-user", "p455w0rd!")
 
-        session = mock.Mock()
-        session.send.return_value = mock_response
+    mock_session = MockSession()
+    mock_session.proxies = {}
+    mock_session.auth = ("session-user", "p455w0rd!")
 
-        client.call(service, mock_endpoint, session=session, logger=mock.Mock(), encoding="FAKE-CODEC")
+    mock_get_adapted_session.return_value = mock_session
 
-        assert "FAKE-CODEC" == mock_response.encoding
+    client.call(service, mock_endpoint, auth=("direct-user", "p455w0rd!"), session=mock_session, logger=mock_logger)
+    assert mock_build_request_object.call_args[1]["auth"] == ("direct-user", "p455w0rd!")
 
-    def test_build_request_object_raises_no_host_exception(self):
-        service = mock.Mock()
-        service.get_hosts.return_value = []
+    client.call(service, mock_endpoint, session=mock_session, logger=mock_logger)
+    assert mock_build_request_object.call_args[1]["auth"] == ("session-user", "p455w0rd!")
 
-        with pytest.raises(NoHostsAvailableException):
-            client.build_request_object(None, service, None)
+    mock_session.auth = ()
+    client.call(service, mock_endpoint, logger=mock_logger)
+    assert mock_build_request_object.call_args[1]["auth"] == ("service-user", "p455w0rd!")
 
-    def test_choose_host_returns_one_of_the_available_hosts(self):
-        hosts = ["foo", "bar", "baz"]
-        service = mock.Mock()
-        service.get_hosts.return_value = hosts
-        assert client.choose_host(service) in hosts
 
-    def test_choose_host_raises_exception_when_no_hosts_available(self):
-        service = mock.Mock()
-        service.get_hosts.return_value = []
-        with pytest.raises(NoHostsAvailableException):
-            client.choose_host(service)
+@mock.patch("apiron.client.get_adapted_session")
+def test_call_with_existing_session(mock_get_adapted_session, mock_response, mock_endpoint, mock_logger):
+    service = mock.Mock()
+    service.get_hosts.return_value = ["http://host1.biz"]
+    service.required_headers = {}
 
-    def test_call_when_raw_response_object_requested(self, mock_response, mock_endpoint):
-        service = mock.Mock()
-        service.get_hosts.return_value = ["http://host1.biz"]
-        service.required_headers = {}
+    session = mock.Mock()
+    session.send.return_value = mock_response
 
-        session = mock.Mock()
-        session.send.return_value = mock_response
+    client.call(service, mock_endpoint, session=session, logger=mock_logger)
 
-        response = client.call(
-            service, mock_endpoint, session=session, logger=mock.Mock(), return_raw_response_object=True,
-        )
+    assert not mock_get_adapted_session.called
+    assert not session.close.called
 
-        assert response is mock_response
 
-    def test_call_when_raw_response_object_requested_on_endpoint(self, mock_response, mock_endpoint):
-        service = mock.Mock()
-        service.get_hosts.return_value = ["http://host1.biz"]
-        service.required_headers = {}
+def test_call_with_explicit_encoding(mock_response, mock_endpoint, mock_logger):
+    service = mock.Mock()
+    service.get_hosts.return_value = ["http://host1.biz"]
+    service.required_headers = {}
 
-        session = mock.Mock()
-        session.send.return_value = mock_response
+    session = mock.Mock()
+    session.send.return_value = mock_response
 
-        mock_endpoint.return_raw_response_object = True
+    client.call(service, mock_endpoint, session=session, logger=mock_logger, encoding="FAKE-CODEC")
 
-        response = client.call(service, mock_endpoint, session=session, logger=mock.Mock())
+    assert "FAKE-CODEC" == mock_response.encoding
 
-        assert response is mock_response
 
-    def test_return_raw_response_object_in_call_overrides_endpoint(self, mock_response, mock_endpoint):
-        service = mock.Mock()
-        service.get_hosts.return_value = ["http://host1.biz"]
-        service.required_headers = {}
+def test_build_request_object_raises_no_host_exception():
+    service = mock.Mock()
+    service.get_hosts.return_value = []
 
-        session = mock.Mock()
-        session.send.return_value = mock_response
+    with pytest.raises(NoHostsAvailableException):
+        client.build_request_object(None, service, None)
 
-        mock_endpoint.return_raw_response_object = False
 
-        response = client.call(
-            service, mock_endpoint, session=session, logger=mock.Mock(), return_raw_response_object=True,
-        )
+def test_choose_host_returns_one_of_the_available_hosts():
+    hosts = ["foo", "bar", "baz"]
+    service = mock.Mock()
+    service.get_hosts.return_value = hosts
+    assert client.choose_host(service) in hosts
+
+
+def test_choose_host_raises_exception_when_no_hosts_available():
+    service = mock.Mock()
+    service.get_hosts.return_value = []
+    with pytest.raises(NoHostsAvailableException):
+        client.choose_host(service)
+
+
+def test_call_when_raw_response_object_requested(mock_response, mock_endpoint, mock_logger):
+    service = mock.Mock()
+    service.get_hosts.return_value = ["http://host1.biz"]
+    service.required_headers = {}
+
+    session = mock.Mock()
+    session.send.return_value = mock_response
+
+    response = client.call(service, mock_endpoint, session=session, logger=mock_logger, return_raw_response_object=True)
+
+    assert response is mock_response
+
+
+def test_call_when_raw_response_object_requested_on_endpoint(mock_response, mock_endpoint):
+    service = mock.Mock()
+    service.get_hosts.return_value = ["http://host1.biz"]
+    service.required_headers = {}
+
+    session = mock.Mock()
+    session.send.return_value = mock_response
+
+    mock_endpoint.return_raw_response_object = True
+
+    response = client.call(service, mock_endpoint, session=session, logger=mock.Mock())
+
+    assert response is mock_response
+
+
+def test_return_raw_response_object_in_call_overrides_endpoint(mock_response, mock_endpoint):
+    service = mock.Mock()
+    service.get_hosts.return_value = ["http://host1.biz"]
+    service.required_headers = {}
+
+    session = mock.Mock()
+    session.send.return_value = mock_response
+
+    mock_endpoint.return_raw_response_object = False
+
+    response = client.call(
+        service, mock_endpoint, session=session, logger=mock.Mock(), return_raw_response_object=True,
+    )
 
-        assert response is mock_response
+    assert response is mock_response
 
 
 @pytest.mark.parametrize(