Repository: prkumar/uplink
Pull request #262 (2022-03-07 06:53:01)

Release v0.9.7
Fixed
-----
- Fix behavior of async ``@response_handler`` with ``AiohttpClient``. (#256)


Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index ddc3a03..0f4992f 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -6,6 +6,12 @@ All notable changes to this project will be documented in this file.
 The format is based on `Keep a Changelog`_, and this project adheres to the
 `Semantic Versioning`_ scheme.
 
+0.9.7_ - 2022-03-10
+===================
+Fixed
+-----
+- Fix behavior of async ``@response_handler`` with ``AiohttpClient``. (`#256`_)
+
 0.9.6_ - 2022-01-24
 ===================
 Added
@@ -378,6 +384,7 @@ Added
 .. _pydantic: https://pydantic-docs.helpmanual.io/
 
 .. Releases
+.. _0.9.7: https://github.com/prkumar/uplink/compare/v0.9.6...v0.9.7
 .. _0.9.6: https://github.com/prkumar/uplink/compare/v0.9.5...v0.9.6
 .. _0.9.5: https://github.com/prkumar/uplink/compare/v0.9.4...v0.9.5
 .. _0.9.4: https://github.com/prkumar/uplink/compare/v0.9.3...v0.9.4
@@ -435,6 +442,7 @@ Added
 .. _#237: https://github.com/prkumar/uplink/discussions/237
 .. _#238: https://github.com/prkumar/uplink/issues/238
 .. _#246: https://github.com/prkumar/uplink/issues/246
+.. _#256: https://github.com/prkumar/uplink/issues/256
 
 .. Commits
 .. _3653a672ee: https://github.com/prkumar/uplink/commit/3653a672ee0703119720d0077bb450649af5459c
diff --git a/setup.py b/setup.py
index 1b1f701..df023b3 100644
--- a/setup.py
+++ b/setup.py
@@ -36,6 +36,9 @@ metadata = {
     "author": "P. Raj Kumar",
     "author_email": "raj.pritvi.kumar@gmail.com",
     "url": "https://uplink.readthedocs.io/",
+    "project_urls": {
+        "Source": "https://github.com/prkumar/uplink",
+    },
     "license": "MIT",
     "description": "A Declarative HTTP Client for Python.",
     "long_description": read("README.rst"),
diff --git a/uplink/__about__.py b/uplink/__about__.py
index 8012c76..0c8df64 100644
--- a/uplink/__about__.py
+++ b/uplink/__about__.py
@@ -3,4 +3,4 @@ This module is the single source of truth for any package metadata
 that is used both in distribution (i.e., setup.py) and within the
 codebase.
 """
-__version__ = "0.9.6"
+__version__ = "0.9.7"
diff --git a/uplink/builder.py b/uplink/builder.py
index dc11996..e051586 100644
--- a/uplink/builder.py
+++ b/uplink/builder.py
@@ -7,6 +7,7 @@ from uplink import (
     arguments,
     auth as auth_,
     clients,
+    compat,
     converters as converters_,
     exceptions,
     helpers,
@@ -41,7 +42,11 @@ class RequestPreparer(object):
         return chain
 
     def _wrap_hook(self, func):
-        return functools.partial(func, self._consumer)
+        @compat.wraps(func)
+        def wrapper(*args, **kwargs):
+            return func(self._consumer, *args, **kwargs)
+
+        return wrapper
 
     def apply_hooks(self, execution_builder, chain):
         # TODO:
diff --git a/uplink/clients/aiohttp_.py b/uplink/clients/aiohttp_.py
index cc64976..4d3a3f3 100644
--- a/uplink/clients/aiohttp_.py
+++ b/uplink/clients/aiohttp_.py
@@ -5,6 +5,7 @@ that returns awaitable responses.
 # Standard library imports
 import asyncio
 import collections
+import inspect
 import threading
 from concurrent import futures
 
@@ -86,7 +87,8 @@ class AiohttpClient(interfaces.HttpClientAdapter):
         return self._session
 
     def wrap_callback(self, callback):
-        if not asyncio.iscoroutinefunction(callback):
+        func = inspect.unwrap(callback)
+        if not asyncio.iscoroutinefunction(func):
             callback = self._sync_callback_adapter(callback)
         return callback
 
diff --git a/uplink/compat.py b/uplink/compat.py
index 6b6ac98..6656fb7 100644
--- a/uplink/compat.py
+++ b/uplink/compat.py
@@ -5,3 +5,4 @@ __all__ = ["abc", "reraise"]
 
 abc = six.moves.collections_abc
 reraise = six.reraise
+wraps = six.wraps
diff --git a/uplink/hooks.py b/uplink/hooks.py
index 20d128e..5f588fa 100644
--- a/uplink/hooks.py
+++ b/uplink/hooks.py
@@ -15,6 +15,7 @@ def _wrap_if_necessary(hook, requires_consumer):
 
 
 def _wrap_to_ignore_consumer(hook):
+    @compat.wraps(hook)
     def wrapper(_, *args, **kwargs):
         # Expects that consumer is the first argument
         return hook(*args, **kwargs)


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index a809aa9..ac6c289 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -5,6 +5,7 @@ if sys.version_info.major < 3:
     collect_ignore.extend(
         [
             "unit/test_aiohttp_client.py",
+            "integration/test_handlers_aiohttp.py",
             "integration/test_retry_aiohttp.py",
         ]
     )
diff --git a/tests/integration/test_basic.py b/tests/integration/test_basic.py
index 0f28dc1..1bf6d54 100644
--- a/tests/integration/test_basic.py
+++ b/tests/integration/test_basic.py
@@ -17,8 +17,11 @@ class GitHubService(uplink.Consumer):
         """List all public repositories for a specific user."""
 
     @uplink.returns.json
-    @uplink.get("/users/{user}/repos/{repo}")
-    def get_repo(self, user, repo):
+    @uplink.get(
+        "/users/{user}/repos/{repo}",
+        args={"token": uplink.Header("Authorization")},
+    )
+    def get_repo(self, user, repo, token):
         pass
 
     @uplink.get(args={"url": uplink.Url})
@@ -64,10 +67,11 @@ def test_get_repo(mock_client, mock_response):
     github = GitHubService(base_url=BASE_URL, client=mock_client)
 
     # Run
-    actual_json = github.get_repo("prkumar", "uplink")
+    actual_json = github.get_repo("prkumar", "uplink", "Bearer token")
 
     # Verify
     assert expected_json == actual_json
+    assert mock_client.history[0].headers["Authorization"] == "Bearer token"
 
 
 def test_forward(mock_client):
diff --git a/tests/integration/test_handlers.py b/tests/integration/test_handlers.py
index d0bdfe7..752c4d2 100644
--- a/tests/integration/test_handlers.py
+++ b/tests/integration/test_handlers.py
@@ -61,6 +61,24 @@ class Calendar(uplink.Consumer):
         pass
 
 
+@handle_response
+class CalendarV2(uplink.Consumer):
+    @uplink.get("todos/{todo_id}")
+    def get_todo(self, todo_id):
+        pass
+
+
+def test_class_response_handler(mock_client):
+    calendar = CalendarV2(base_url=BASE_URL, client=mock_client)
+    calendar.flagged = False
+
+    # Run
+    response = calendar.get_todo(todo_id=1)
+
+    # Verify
+    assert response.flagged is True
+
+
 def test_response_handler_with_consumer(mock_client):
     calendar = Calendar(base_url=BASE_URL, client=mock_client)
     calendar.flagged = False
diff --git a/tests/integration/test_handlers_aiohttp.py b/tests/integration/test_handlers_aiohttp.py
new file mode 100644
index 0000000..f048b6c
--- /dev/null
+++ b/tests/integration/test_handlers_aiohttp.py
@@ -0,0 +1,48 @@
+# Local imports.
+import uplink
+
+# Third-party imports
+import aiohttp
+import pytest
+from uplink.clients.aiohttp_ import AiohttpClient
+
+# Constants
+BASE_URL = "https://example.com/"
+SIMPLE_RESPONSE = "simple response"
+
+
+@pytest.fixture
+def mock_aiohttp_session(mocker):
+    return mocker.Mock(spec=aiohttp.ClientSession)
+
+
+@uplink.response_handler
+async def simple_async_handler(response):
+    return SIMPLE_RESPONSE
+
+
+class Calendar(uplink.Consumer):
+    @simple_async_handler
+    @uplink.get("todos/{todo_id}")
+    def get_todo(self, todo_id):
+        pass
+
+
+@pytest.mark.asyncio
+async def test_simple_async_handler(mock_aiohttp_session, mock_response):
+    mock_response.status = 200
+
+    async def request(*args, **kwargs):
+        return mock_response
+
+    mock_aiohttp_session.request = request
+
+    calendar = Calendar(
+        base_url=BASE_URL, client=AiohttpClient(mock_aiohttp_session)
+    )
+
+    # Run
+    response = await calendar.get_todo(todo_id=1)
+
+    # Verify
+    assert response == SIMPLE_RESPONSE
diff --git a/tests/integration/test_returns.py b/tests/integration/test_returns.py
index d640b45..cd4329a 100644
--- a/tests/integration/test_returns.py
+++ b/tests/integration/test_returns.py
@@ -70,7 +70,27 @@ class GitHub(uplink.Consumer):
         pass
 
 
+@uplink.returns.json
+class GitHubV2(uplink.Consumer):
+    @uplink.get("/users/{user}/repos/{repo}")
+    def get_repo(self, user, repo):
+        pass
+
+
 # Tests
+def test_returns_json_on_class(mock_client, mock_response):
+    # Setup
+    mock_response.with_json({"owner": "prkumar", "name": "uplink"})
+    mock_client.with_response(mock_response)
+    github = GitHubV2(
+        base_url=BASE_URL, client=mock_client, converters=repo_json_reader
+    )
+
+    # Run
+    repo = github.get_repo("prkumar", "uplink")
+
+    # Verify
+    assert repo == {"owner": "prkumar", "name": "uplink"}
 
 
 def test_returns_response_when_type_has_no_converter(
diff --git a/tests/unit/test_hooks.py b/tests/unit/test_hooks.py
index fecada0..0fbd793 100644
--- a/tests/unit/test_hooks.py
+++ b/tests/unit/test_hooks.py
@@ -16,7 +16,7 @@ class TestResponseHandler(object):
 
 class TestRequestAuditor(object):
     def test_audit_request(self, mocker):
-        auditor = mocker.stub()
+        auditor = mocker.Mock()
         ra = hooks.RequestAuditor(auditor)
         ra.audit_request("consumer", "request")
         auditor.assert_called_with("request")
@@ -24,7 +24,7 @@ class TestRequestAuditor(object):
 
 class TestExceptionHandler(object):
     def test_handle_exception_masked(self, mocker):
-        handler = mocker.stub()
+        handler = mocker.Mock()
         eh = hooks.ExceptionHandler(handler)
         eh.handle_exception("consumer", "exc_type", "exc_val", "exc_tb")
         handler.assert_called_with("exc_type", "exc_val", "exc_tb")
@@ -45,8 +45,8 @@ class TestTransactionHookChain(object):
 
     def test_delegate_handle_response_multiple(self, mocker):
         # Include one hook that can't handle responses
-        mock_response_handler = mocker.stub()
-        mock_request_auditor = mocker.stub()
+        mock_response_handler = mocker.Mock()
+        mock_request_auditor = mocker.Mock()
 
         chain = hooks.TransactionHookChain(
             hooks.RequestAuditor(mock_request_auditor),