From e0178bd579e83f802c80a5dac849dd0af874335d Mon Sep 17 00:00:00 2001 From: Sherif Nada Date: Fri, 2 Jul 2021 00:20:41 -0700 Subject: [PATCH 1/7] allow sending arbitrary kwargs on a request --- airbyte-cdk/python/CHANGELOG.md | 3 + .../airbyte_cdk/sources/streams/http/http.py | 73 +++++++++++-------- airbyte-cdk/python/setup.py | 3 +- .../sources/streams/http/test_http.py | 26 +++++-- 4 files changed, 69 insertions(+), 36 deletions(-) diff --git a/airbyte-cdk/python/CHANGELOG.md b/airbyte-cdk/python/CHANGELOG.md index a8f04ed36fcb0..861e34b2604c8 100644 --- a/airbyte-cdk/python/CHANGELOG.md +++ b/airbyte-cdk/python/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 0.1.5 +Allow specifying keyword arguments to be sent on a request made by an HTTP stream: + ## 0.1.4 Allow to use Python 3.7.0: https://github.com/airbytehq/airbyte/pull/3566 diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py index e248a9fe262d0..ca0f304e2e808 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py @@ -76,20 +76,20 @@ def next_page_token(self, response: requests.Response) -> Optional[Mapping[str, @abstractmethod def path( - self, - stream_state: Mapping[str, Any] = None, - stream_slice: Mapping[str, Any] = None, - next_page_token: Mapping[str, Any] = None, + self, + stream_state: Mapping[str, Any] = None, + stream_slice: Mapping[str, Any] = None, + next_page_token: Mapping[str, Any] = None, ) -> str: """ Returns the URL path for the API endpoint e.g: if you wanted to hit https://myapi.com/v1/some_entity then this should return "some_entity" """ def request_params( - self, - stream_state: Mapping[str, Any], - stream_slice: Mapping[str, Any] = None, - next_page_token: Mapping[str, Any] = None, + self, + stream_state: Mapping[str, Any], + stream_slice: Mapping[str, Any] = None, + next_page_token: Mapping[str, Any] = None, ) -> MutableMapping[str, Any]: """ Override this method to define the query parameters that should be set on an outgoing HTTP request given the inputs. @@ -99,7 +99,7 @@ def request_params( return {} def request_headers( - self, stream_state: Mapping[str, Any], stream_slice: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None + self, stream_state: Mapping[str, Any], stream_slice: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None ) -> Mapping[str, Any]: """ Override to return any non-auth headers. Authentication headers will overwrite any overlapping headers returned from this method. @@ -107,10 +107,10 @@ def request_headers( return {} def request_body_json( - self, - stream_state: Mapping[str, Any], - stream_slice: Mapping[str, Any] = None, - next_page_token: Mapping[str, Any] = None, + self, + stream_state: Mapping[str, Any], + stream_slice: Mapping[str, Any] = None, + next_page_token: Mapping[str, Any] = None, ) -> Optional[Mapping]: """ TODO make this possible to do for non-JSON APIs @@ -118,13 +118,29 @@ def request_body_json( """ return None + def request_kwargs( + self, + stream_state: Mapping[str, Any], + stream_slice: Mapping[str, Any] = None, + next_page_token: Mapping[str, Any] = None, + ) -> Mapping[str, Any]: + """ + Override to return a mapping of keyword arguments to be used when creating the HTTP request. + See https://docs.python-requests.org/en/master/user/advanced/ for various options which can be returned from + this method. + + any args returned from this method will be flattened into the outgoing request via something + like `requests.get(url, **self.request_kwargs())`. + """ + return {} + @abstractmethod def parse_response( - self, - response: requests.Response, - stream_state: Mapping[str, Any], - stream_slice: Mapping[str, Any] = None, - next_page_token: Mapping[str, Any] = None, + self, + response: requests.Response, + stream_state: Mapping[str, Any], + stream_slice: Mapping[str, Any] = None, + next_page_token: Mapping[str, Any] = None, ) -> Iterable[Mapping]: """ Parses the raw response object into a list of records. @@ -158,7 +174,7 @@ def backoff_time(self, response: requests.Response) -> Optional[float]: return None def _create_prepared_request( - self, path: str, headers: Mapping = None, params: Mapping = None, json: Any = None + self, path: str, headers: Mapping = None, params: Mapping = None, json: Any = None ) -> requests.PreparedRequest: args = {"method": self.http_method, "url": self.url_base + path, "headers": headers, "params": params} @@ -172,7 +188,7 @@ def _create_prepared_request( # see https://github.com/litl/backoff/pull/122 @default_backoff_handler(max_tries=5, factor=5) @user_defined_backoff_handler(max_tries=5) - def _send_request(self, request: requests.PreparedRequest) -> requests.Response: + def _send_request(self, request: requests.PreparedRequest, request_kwargs: Mapping[str, Any]) -> requests.Response: """ Wraps sending the request in rate limit and error handlers. @@ -190,9 +206,8 @@ def _send_request(self, request: requests.PreparedRequest) -> requests.Response: Unexpected transient exceptions use the default backoff parameters. Unexpected persistent exceptions are not handled and will cause the sync to fail. """ - response: requests.Response = self._session.send(request) + response: requests.Response = self._session.send(request, **request_kwargs) if self.should_retry(response): - custom_backoff_time = self.backoff_time(response) if custom_backoff_time: raise UserDefinedBackoffException(backoff=custom_backoff_time, request=request, response=response) @@ -206,11 +221,11 @@ def _send_request(self, request: requests.PreparedRequest) -> requests.Response: return response def read_records( - self, - sync_mode: SyncMode, - cursor_field: List[str] = None, - stream_slice: Mapping[str, Any] = None, - stream_state: Mapping[str, Any] = None, + self, + sync_mode: SyncMode, + cursor_field: List[str] = None, + stream_slice: Mapping[str, Any] = None, + stream_state: Mapping[str, Any] = None, ) -> Iterable[Mapping[str, Any]]: stream_state = stream_state or {} pagination_complete = False @@ -224,8 +239,8 @@ def read_records( params=self.request_params(stream_state=stream_state, stream_slice=stream_slice, next_page_token=next_page_token), json=self.request_body_json(stream_state=stream_state, stream_slice=stream_slice, next_page_token=next_page_token), ) - - response = self._send_request(request) + request_kwargs = self.request_kwargs(stream_state=stream_state, stream_slice=stream_slice, next_page_token=next_page_token) + response = self._send_request(request, request_kwargs) yield from self.parse_response(response, stream_state=stream_state, stream_slice=stream_slice) next_page_token = self.next_page_token(response) diff --git a/airbyte-cdk/python/setup.py b/airbyte-cdk/python/setup.py index b45170db90959..5c18be1385372 100644 --- a/airbyte-cdk/python/setup.py +++ b/airbyte-cdk/python/setup.py @@ -35,7 +35,7 @@ setup( name="airbyte-cdk", - version="0.1.4", + version="0.1.5", description="A framework for writing Airbyte Connectors.", long_description=README, long_description_content_type="text/markdown", @@ -79,6 +79,7 @@ "pytest", "pytest-cov", "pytest-mock", + "requests-mock" ] }, entry_points={ diff --git a/airbyte-cdk/python/unit_tests/sources/streams/http/test_http.py b/airbyte-cdk/python/unit_tests/sources/streams/http/test_http.py index d70fa2b0d5606..14cade627e9da 100644 --- a/airbyte-cdk/python/unit_tests/sources/streams/http/test_http.py +++ b/airbyte-cdk/python/unit_tests/sources/streams/http/test_http.py @@ -24,9 +24,11 @@ from typing import Any, Iterable, Mapping, Optional +from unittest.mock import ANY import pytest import requests +import requests_mock from airbyte_cdk.models import SyncMode from airbyte_cdk.sources.streams.http import HttpStream from airbyte_cdk.sources.streams.http.exceptions import UserDefinedBackoffException @@ -44,22 +46,34 @@ def next_page_token(self, response: requests.Response) -> Optional[Mapping[str, return None def path( - self, stream_state: Mapping[str, Any] = None, stream_slice: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None + self, stream_state: Mapping[str, Any] = None, stream_slice: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None ) -> str: return "" def parse_response( - self, - response: requests.Response, - stream_state: Mapping[str, Any], - stream_slice: Mapping[str, Any] = None, - next_page_token: Mapping[str, Any] = None, + self, + response: requests.Response, + stream_state: Mapping[str, Any], + stream_slice: Mapping[str, Any] = None, + next_page_token: Mapping[str, Any] = None, ) -> Iterable[Mapping]: stubResp = {"data": self.resp_counter} self.resp_counter += 1 yield stubResp +def test_request_kwargs_used(mocker, requests_mock): + stream = StubBasicReadHttpStream() + request_kwargs = {'cert': None, 'proxies': 'google.com'} + mocker.patch.object(stream, 'request_kwargs', return_value=request_kwargs) + mocker.patch.object(stream._session, 'send', wraps=stream._session.send) + requests_mock.register_uri('GET', stream.url_base) + + list(stream.read_records(sync_mode=SyncMode.full_refresh)) + + stream._session.send.assert_any_call(ANY, **request_kwargs) + + def test_stub_basic_read_http_stream_read_records(mocker): stream = StubBasicReadHttpStream() blank_response = {} # Send a blank response is fine as we ignore the response in `parse_response anyway. From dd8f924a73e66e1d7a3b53fba9c235b0e985bf58 Mon Sep 17 00:00:00 2001 From: Sherif Nada Date: Fri, 2 Jul 2021 00:27:04 -0700 Subject: [PATCH 2/7] fmt --- .../airbyte_cdk/sources/streams/http/http.py | 56 +++++++++---------- airbyte-cdk/python/setup.py | 10 +--- .../sources/streams/http/test_http.py | 21 ++++--- 3 files changed, 39 insertions(+), 48 deletions(-) diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py index ca0f304e2e808..5ffefde5f6daa 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py @@ -76,20 +76,20 @@ def next_page_token(self, response: requests.Response) -> Optional[Mapping[str, @abstractmethod def path( - self, - stream_state: Mapping[str, Any] = None, - stream_slice: Mapping[str, Any] = None, - next_page_token: Mapping[str, Any] = None, + self, + stream_state: Mapping[str, Any] = None, + stream_slice: Mapping[str, Any] = None, + next_page_token: Mapping[str, Any] = None, ) -> str: """ Returns the URL path for the API endpoint e.g: if you wanted to hit https://myapi.com/v1/some_entity then this should return "some_entity" """ def request_params( - self, - stream_state: Mapping[str, Any], - stream_slice: Mapping[str, Any] = None, - next_page_token: Mapping[str, Any] = None, + self, + stream_state: Mapping[str, Any], + stream_slice: Mapping[str, Any] = None, + next_page_token: Mapping[str, Any] = None, ) -> MutableMapping[str, Any]: """ Override this method to define the query parameters that should be set on an outgoing HTTP request given the inputs. @@ -99,7 +99,7 @@ def request_params( return {} def request_headers( - self, stream_state: Mapping[str, Any], stream_slice: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None + self, stream_state: Mapping[str, Any], stream_slice: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None ) -> Mapping[str, Any]: """ Override to return any non-auth headers. Authentication headers will overwrite any overlapping headers returned from this method. @@ -107,10 +107,10 @@ def request_headers( return {} def request_body_json( - self, - stream_state: Mapping[str, Any], - stream_slice: Mapping[str, Any] = None, - next_page_token: Mapping[str, Any] = None, + self, + stream_state: Mapping[str, Any], + stream_slice: Mapping[str, Any] = None, + next_page_token: Mapping[str, Any] = None, ) -> Optional[Mapping]: """ TODO make this possible to do for non-JSON APIs @@ -119,10 +119,10 @@ def request_body_json( return None def request_kwargs( - self, - stream_state: Mapping[str, Any], - stream_slice: Mapping[str, Any] = None, - next_page_token: Mapping[str, Any] = None, + self, + stream_state: Mapping[str, Any], + stream_slice: Mapping[str, Any] = None, + next_page_token: Mapping[str, Any] = None, ) -> Mapping[str, Any]: """ Override to return a mapping of keyword arguments to be used when creating the HTTP request. @@ -136,11 +136,11 @@ def request_kwargs( @abstractmethod def parse_response( - self, - response: requests.Response, - stream_state: Mapping[str, Any], - stream_slice: Mapping[str, Any] = None, - next_page_token: Mapping[str, Any] = None, + self, + response: requests.Response, + stream_state: Mapping[str, Any], + stream_slice: Mapping[str, Any] = None, + next_page_token: Mapping[str, Any] = None, ) -> Iterable[Mapping]: """ Parses the raw response object into a list of records. @@ -174,7 +174,7 @@ def backoff_time(self, response: requests.Response) -> Optional[float]: return None def _create_prepared_request( - self, path: str, headers: Mapping = None, params: Mapping = None, json: Any = None + self, path: str, headers: Mapping = None, params: Mapping = None, json: Any = None ) -> requests.PreparedRequest: args = {"method": self.http_method, "url": self.url_base + path, "headers": headers, "params": params} @@ -221,11 +221,11 @@ def _send_request(self, request: requests.PreparedRequest, request_kwargs: Mappi return response def read_records( - self, - sync_mode: SyncMode, - cursor_field: List[str] = None, - stream_slice: Mapping[str, Any] = None, - stream_state: Mapping[str, Any] = None, + self, + sync_mode: SyncMode, + cursor_field: List[str] = None, + stream_slice: Mapping[str, Any] = None, + stream_state: Mapping[str, Any] = None, ) -> Iterable[Mapping[str, Any]]: stream_state = stream_state or {} pagination_complete = False diff --git a/airbyte-cdk/python/setup.py b/airbyte-cdk/python/setup.py index 5c18be1385372..3f193b944ad71 100644 --- a/airbyte-cdk/python/setup.py +++ b/airbyte-cdk/python/setup.py @@ -73,15 +73,7 @@ "requests", ], python_requires=">=3.7.0", - extras_require={ - "dev": [ - "MyPy==0.812", - "pytest", - "pytest-cov", - "pytest-mock", - "requests-mock" - ] - }, + extras_require={"dev": ["MyPy==0.812", "pytest", "pytest-cov", "pytest-mock", "requests-mock"]}, entry_points={ "console_scripts": ["base-python=base_python.entrypoint:main"], }, diff --git a/airbyte-cdk/python/unit_tests/sources/streams/http/test_http.py b/airbyte-cdk/python/unit_tests/sources/streams/http/test_http.py index 14cade627e9da..997157c2da089 100644 --- a/airbyte-cdk/python/unit_tests/sources/streams/http/test_http.py +++ b/airbyte-cdk/python/unit_tests/sources/streams/http/test_http.py @@ -28,7 +28,6 @@ import pytest import requests -import requests_mock from airbyte_cdk.models import SyncMode from airbyte_cdk.sources.streams.http import HttpStream from airbyte_cdk.sources.streams.http.exceptions import UserDefinedBackoffException @@ -46,16 +45,16 @@ def next_page_token(self, response: requests.Response) -> Optional[Mapping[str, return None def path( - self, stream_state: Mapping[str, Any] = None, stream_slice: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None + self, stream_state: Mapping[str, Any] = None, stream_slice: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None ) -> str: return "" def parse_response( - self, - response: requests.Response, - stream_state: Mapping[str, Any], - stream_slice: Mapping[str, Any] = None, - next_page_token: Mapping[str, Any] = None, + self, + response: requests.Response, + stream_state: Mapping[str, Any], + stream_slice: Mapping[str, Any] = None, + next_page_token: Mapping[str, Any] = None, ) -> Iterable[Mapping]: stubResp = {"data": self.resp_counter} self.resp_counter += 1 @@ -64,10 +63,10 @@ def parse_response( def test_request_kwargs_used(mocker, requests_mock): stream = StubBasicReadHttpStream() - request_kwargs = {'cert': None, 'proxies': 'google.com'} - mocker.patch.object(stream, 'request_kwargs', return_value=request_kwargs) - mocker.patch.object(stream._session, 'send', wraps=stream._session.send) - requests_mock.register_uri('GET', stream.url_base) + request_kwargs = {"cert": None, "proxies": "google.com"} + mocker.patch.object(stream, "request_kwargs", return_value=request_kwargs) + mocker.patch.object(stream._session, "send", wraps=stream._session.send) + requests_mock.register_uri("GET", stream.url_base) list(stream.read_records(sync_mode=SyncMode.full_refresh)) From e0e5817cc9dfac9ec62dfada53e1317c7f4838a4 Mon Sep 17 00:00:00 2001 From: "Sherif A. Nada" Date: Fri, 2 Jul 2021 13:28:08 -0700 Subject: [PATCH 3/7] Update http.py --- airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py index 5ffefde5f6daa..615159d2719e3 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py @@ -126,11 +126,11 @@ def request_kwargs( ) -> Mapping[str, Any]: """ Override to return a mapping of keyword arguments to be used when creating the HTTP request. - See https://docs.python-requests.org/en/master/user/advanced/ for various options which can be returned from + See https://docs.python-requests.org/en/latest/api/#requests.Session.send for various options which can be returned from this method. any args returned from this method will be flattened into the outgoing request via something - like `requests.get(url, **self.request_kwargs())`. + like `requests.Session().send(request, **self.request_kwargs())`. """ return {} From 39b1c1254105910209041437491f14ae60f417da Mon Sep 17 00:00:00 2001 From: "Sherif A. Nada" Date: Fri, 2 Jul 2021 13:31:37 -0700 Subject: [PATCH 4/7] Update http.py --- .../python/airbyte_cdk/sources/streams/http/http.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py index 615159d2719e3..c744a6c855544 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py @@ -126,11 +126,8 @@ def request_kwargs( ) -> Mapping[str, Any]: """ Override to return a mapping of keyword arguments to be used when creating the HTTP request. - See https://docs.python-requests.org/en/latest/api/#requests.Session.send for various options which can be returned from - this method. - - any args returned from this method will be flattened into the outgoing request via something - like `requests.Session().send(request, **self.request_kwargs())`. + Any option listed in https://docs.python-requests.org/en/latest/api/#requests.adapters.BaseAdapter.send for can be returned from + this method. Note that these options do not conflict with request-level options such as headers, request params, etc.. """ return {} From bf39c43dc298fc1c71e3db9c7b41c55f9e53f9f5 Mon Sep 17 00:00:00 2001 From: "Sherif A. Nada" Date: Fri, 2 Jul 2021 13:32:17 -0700 Subject: [PATCH 5/7] Update CHANGELOG.md --- airbyte-cdk/python/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airbyte-cdk/python/CHANGELOG.md b/airbyte-cdk/python/CHANGELOG.md index 861e34b2604c8..fda59b2cd649e 100644 --- a/airbyte-cdk/python/CHANGELOG.md +++ b/airbyte-cdk/python/CHANGELOG.md @@ -1,7 +1,7 @@ # Changelog ## 0.1.5 -Allow specifying keyword arguments to be sent on a request made by an HTTP stream: +Allow specifying keyword arguments to be sent on a request made by an HTTP stream: https://github.com/airbytehq/airbyte/pull/4493 ## 0.1.4 Allow to use Python 3.7.0: https://github.com/airbytehq/airbyte/pull/3566 From d021dd440eac980d1f585459ba9817480c937ea8 Mon Sep 17 00:00:00 2001 From: Sherif Nada Date: Fri, 2 Jul 2021 14:34:11 -0700 Subject: [PATCH 6/7] kill build cache --- .github/workflows/publish-cdk-command.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-cdk-command.yml b/.github/workflows/publish-cdk-command.yml index ee12e965cb3c5..7e9b33f3318bb 100644 --- a/.github/workflows/publish-cdk-command.yml +++ b/.github/workflows/publish-cdk-command.yml @@ -27,7 +27,7 @@ jobs: - name: Checkout Airbyte uses: actions/checkout@v2 - name: Build CDK Package - run: ./gradlew --no-daemon :airbyte-cdk:python:build + run: ./gradlew --no-daemon --no-build-cache :airbyte-cdk:python:build - name: Add Failure Comment if: github.event.inputs.comment-id && !success() uses: peter-evans/create-or-update-comment@v1 From e5710c58328c80abb3dfe90a45637addad61a041 Mon Sep 17 00:00:00 2001 From: Sherif Nada Date: Wed, 7 Jul 2021 10:48:26 -0700 Subject: [PATCH 7/7] docs --- airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py | 2 +- airbyte-cdk/python/docs/concepts/http-streams.md | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py index c744a6c855544..60aaa95e153fb 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py @@ -179,7 +179,7 @@ def _create_prepared_request( # TODO support non-json bodies args["json"] = json - return requests.Request(**args).prepare() + return self._session.prepare_request(requests.Request(**args)) # TODO allow configuring these parameters. If we can get this into the requests library, then we can do it without the ugly exception hacks # see https://github.com/litl/backoff/pull/122 diff --git a/airbyte-cdk/python/docs/concepts/http-streams.md b/airbyte-cdk/python/docs/concepts/http-streams.md index 9d15c2f72e1fb..12fda0eca2cba 100644 --- a/airbyte-cdk/python/docs/concepts/http-streams.md +++ b/airbyte-cdk/python/docs/concepts/http-streams.md @@ -71,3 +71,8 @@ errors. It is not currently possible to specify a rate limit Airbyte should adhe ### Stream Slicing When implementing [stream slicing](incremental-stream.md#streamstream_slices) in an `HTTPStream` each Slice is equivalent to a HTTP request; the stream will make one request per element returned by the `stream_slices` function. The current slice being read is passed into every other method in `HttpStream` e.g: `request_params`, `request_headers`, `path`, etc.. to be injected into a request. This allows you to dynamically determine the output of the `request_params`, `path`, and other functions to read the input slice and return the appropriate value. + +### Network Adapter Keyword arguments +If you need to set any network-adapter keyword args on the outgoing HTTP requests such as `allow_redirects`, `stream`, `verify`, `cert`, etc.. +override the `request_kwargs` method. Any option listed in [BaseAdapter.send](https://docs.python-requests.org/en/latest/api/#requests.adapters.BaseAdapter.send) can +be returned as a keyword argument.