From 4db4935eafbd9ce735bf2c97fbfca3340d22e788 Mon Sep 17 00:00:00 2001 From: Vadym Hevlich Date: Wed, 25 Aug 2021 14:34:47 +0300 Subject: [PATCH 1/9] Add requests native auth class --- .../airbyte_cdk/sources/streams/http/http.py | 11 ++- .../http/requests_native_auth/__init__.py | 23 +++++ .../http/requests_native_auth/oauth.py | 92 +++++++++++++++++++ .../http/requests_native_auth/token.py | 56 +++++++++++ 4 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/__init__.py create mode 100644 airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py create mode 100644 airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/token.py 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 80625becb602a..535dfbc5e748a 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py @@ -29,6 +29,7 @@ import requests from airbyte_cdk.models import SyncMode from airbyte_cdk.sources.streams.core import Stream +from requests.auth import AuthBase from .auth.core import HttpAuthenticator, NoAuth from .exceptions import DefaultBackoffException, RequestBodyException, UserDefinedBackoffException @@ -45,10 +46,16 @@ class HttpStream(Stream, ABC): source_defined_cursor = True # Most HTTP streams use a source defined cursor (i.e: the user can't configure it like on a SQL table) - def __init__(self, authenticator: HttpAuthenticator = NoAuth()): - self._authenticator = authenticator + # TODO: remove legacy HttpAuthenticator authenticator references + def __init__(self, authenticator: Any[AuthBase, HttpAuthenticator] = None): self._session = requests.Session() + if isinstance(authenticator, AuthBase): + self._session.auth = authenticator + self._authenticator = NoAuth() + else: + self._authenticator = authenticator + @property @abstractmethod def url_base(self) -> str: diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/__init__.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/__init__.py new file mode 100644 index 0000000000000..9db886e0930f0 --- /dev/null +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/__init__.py @@ -0,0 +1,23 @@ +# +# MIT License +# +# Copyright (c) 2020 Airbyte +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py new file mode 100644 index 0000000000000..4726eca00fea4 --- /dev/null +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py @@ -0,0 +1,92 @@ +# +# MIT License +# +# Copyright (c) 2020 Airbyte +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + + +from typing import Any, List, Mapping, MutableMapping, Tuple + +import pendulum +import requests +from requests.auth import AuthBase + + +class Oauth2Authenticator(AuthBase): + """ + Generates OAuth2.0 access tokens from an OAuth2.0 refresh token and client credentials. + The generated access token is attached to each request via the Authorization header. + """ + + def __init__(self, token_refresh_endpoint: str, client_id: str, client_secret: str, refresh_token: str, scopes: List[str] = None): + self.token_refresh_endpoint = token_refresh_endpoint + self.client_secret = client_secret + self.client_id = client_id + self.refresh_token = refresh_token + self.scopes = scopes + + self._token_expiry_date = pendulum.now().subtract(days=1) + self._access_token = None + + def __call__(self, request): + request.headers.update(self.get_auth_header()) + return request + + def get_auth_header(self) -> Mapping[str, Any]: + return {"Authorization": f"Bearer {self.get_access_token()}"} + + def get_access_token(self): + if self.token_has_expired(): + t0 = pendulum.now() + token, expires_in = self.refresh_access_token() + self._access_token = token + self._token_expiry_date = t0.add(seconds=expires_in) + + return self._access_token + + def token_has_expired(self) -> bool: + return pendulum.now() > self._token_expiry_date + + def get_refresh_request_body(self) -> Mapping[str, Any]: + """Override to define additional parameters""" + payload: MutableMapping[str, Any] = { + "grant_type": "refresh_token", + "client_id": self.client_id, + "client_secret": self.client_secret, + "refresh_token": self.refresh_token, + } + + if self.scopes: + payload["scopes"] = self.scopes + + return payload + + def refresh_access_token(self) -> Tuple[str, int]: + """ + returns a tuple of (access_token, token_lifespan_in_seconds) + """ + try: + response = requests.request(method="POST", url=self.token_refresh_endpoint, data=self.get_refresh_request_body()) + response.raise_for_status() + response_json = response.json() + return response_json["access_token"], response_json["expires_in"] + except Exception as e: + raise Exception(f"Error while refreshing access token: {e}") from e diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/token.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/token.py new file mode 100644 index 0000000000000..b2a5607fccaca --- /dev/null +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/token.py @@ -0,0 +1,56 @@ +# +# MIT License +# +# Copyright (c) 2020 Airbyte +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +from itertools import cycle +from typing import Any, List, Mapping + +from requests.auth import AuthBase + + +class TokenAuthenticator(AuthBase): + def __init__(self, token: str, auth_method: str = "Bearer", auth_header: str = "Authorization"): + self.auth_method = auth_method + self.auth_header = auth_header + self._token = token + + def __call__(self, request): + request.headers.update(self.get_auth_header()) + return request + + def get_auth_header(self) -> Mapping[str, Any]: + return {self.auth_header: f"{self.auth_method} {self._token}"} + + +class MultipleTokenAuthenticator(AuthBase): + def __init__(self, tokens: List[str], auth_method: str = "Bearer", auth_header: str = "Authorization"): + self.auth_method = auth_method + self.auth_header = auth_header + self._tokens = tokens + self._tokens_iter = cycle(self._tokens) + + def __call__(self, request): + request.headers.update(self.get_auth_header()) + return request + + def get_auth_header(self) -> Mapping[str, Any]: + return {self.auth_header: f"{self.auth_method} {next(self._tokens_iter)}"} From 3378eb188b7f627860da1590adea0c9f4bfa6989 Mon Sep 17 00:00:00 2001 From: Vadym Hevlich Date: Mon, 30 Aug 2021 14:24:39 +0300 Subject: [PATCH 2/9] Update init file. Update type annotations. Bump version. --- .../python/airbyte_cdk/sources/streams/http/http.py | 2 +- .../streams/http/requests_native_auth/__init__.py | 9 +++++++++ .../sources/streams/http/requests_native_auth/token.py | 1 + airbyte-cdk/python/setup.py | 2 +- 4 files changed, 12 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 bf8f52b4914da..8d50da7fc2ee0 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py @@ -47,7 +47,7 @@ class HttpStream(Stream, ABC): source_defined_cursor = True # Most HTTP streams use a source defined cursor (i.e: the user can't configure it like on a SQL table) # TODO: remove legacy HttpAuthenticator authenticator references - def __init__(self, authenticator: Any[AuthBase, HttpAuthenticator] = None): + def __init__(self, authenticator: Union[AuthBase, HttpAuthenticator] = None): self._session = requests.Session() if isinstance(authenticator, AuthBase): diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/__init__.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/__init__.py index 9db886e0930f0..8b62c71c24da3 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/__init__.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/__init__.py @@ -21,3 +21,12 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # + +from .oauth import Oauth2Authenticator +from .token import MultipleTokenAuthenticator, TokenAuthenticator + +__all__ = [ + "Oauth2Authenticator", + "TokenAuthenticator", + "MultipleTokenAuthenticator", +] diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/token.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/token.py index b2a5607fccaca..24e9bf445b6a1 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/token.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/token.py @@ -21,6 +21,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # + from itertools import cycle from typing import Any, List, Mapping diff --git a/airbyte-cdk/python/setup.py b/airbyte-cdk/python/setup.py index 28cb7070ea7bb..38a119df0e502 100644 --- a/airbyte-cdk/python/setup.py +++ b/airbyte-cdk/python/setup.py @@ -35,7 +35,7 @@ setup( name="airbyte-cdk", - version="0.1.12", + version="0.1.13", description="A framework for writing Airbyte Connectors.", long_description=README, long_description_content_type="text/markdown", From e841e7756d355d8a5c47c5732f6dfad308df8872 Mon Sep 17 00:00:00 2001 From: Vadym Hevlich Date: Tue, 31 Aug 2021 14:45:09 +0300 Subject: [PATCH 3/9] Update TokenAuthenticator implementation. Update Oauth2Authenticator implemetation. Add CHANGELOG.md record. --- airbyte-cdk/python/CHANGELOG.md | 5 +++++ .../http/requests_native_auth/oauth.py | 18 +++++++++++++++--- .../http/requests_native_auth/token.py | 19 +++++-------------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/airbyte-cdk/python/CHANGELOG.md b/airbyte-cdk/python/CHANGELOG.md index d296390c5fb38..2d8678fdfd721 100644 --- a/airbyte-cdk/python/CHANGELOG.md +++ b/airbyte-cdk/python/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.1.13 +Add requests native auth module. +Implement Oauth2Authenticator, MultipleTokenAuthenticator and TokenAuthenticator authenticators. +Add support of both legacy and requests native authenticator support to HttpStream class. + ## 0.1.12 Add raise_on_http_errors, max_retries, retry_factor properties to be able to ignore http status errors and modify retry time in HTTP stream diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py index 4726eca00fea4..ad119605bc0c8 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py @@ -36,14 +36,26 @@ class Oauth2Authenticator(AuthBase): The generated access token is attached to each request via the Authorization header. """ - def __init__(self, token_refresh_endpoint: str, client_id: str, client_secret: str, refresh_token: str, scopes: List[str] = None): + def __init__( + self, + token_refresh_endpoint: str, + client_id: str, + client_secret: str, + refresh_token: str, + token_expiry_date: pendulum.datetime = pendulum.now().subtract(days=1), + scopes: List[str] = None, + access_token_name: str = "access_token", + expires_in_name: str = "expires_in", + ): self.token_refresh_endpoint = token_refresh_endpoint self.client_secret = client_secret self.client_id = client_id self.refresh_token = refresh_token self.scopes = scopes + self.access_token_name = access_token_name + self.expires_in_name = expires_in_name - self._token_expiry_date = pendulum.now().subtract(days=1) + self._token_expiry_date = token_expiry_date self._access_token = None def __call__(self, request): @@ -87,6 +99,6 @@ def refresh_access_token(self) -> Tuple[str, int]: response = requests.request(method="POST", url=self.token_refresh_endpoint, data=self.get_refresh_request_body()) response.raise_for_status() response_json = response.json() - return response_json["access_token"], response_json["expires_in"] + return response_json[self.access_token_name], response_json[self.expires_in_name] except Exception as e: raise Exception(f"Error while refreshing access token: {e}") from e diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/token.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/token.py index 24e9bf445b6a1..c9a6bd7b40dea 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/token.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/token.py @@ -28,20 +28,6 @@ from requests.auth import AuthBase -class TokenAuthenticator(AuthBase): - def __init__(self, token: str, auth_method: str = "Bearer", auth_header: str = "Authorization"): - self.auth_method = auth_method - self.auth_header = auth_header - self._token = token - - def __call__(self, request): - request.headers.update(self.get_auth_header()) - return request - - def get_auth_header(self) -> Mapping[str, Any]: - return {self.auth_header: f"{self.auth_method} {self._token}"} - - class MultipleTokenAuthenticator(AuthBase): def __init__(self, tokens: List[str], auth_method: str = "Bearer", auth_header: str = "Authorization"): self.auth_method = auth_method @@ -55,3 +41,8 @@ def __call__(self, request): def get_auth_header(self) -> Mapping[str, Any]: return {self.auth_header: f"{self.auth_method} {next(self._tokens_iter)}"} + + +class TokenAuthenticator(MultipleTokenAuthenticator): + def __init__(self, token: str, auth_method: str = "Bearer", auth_header: str = "Authorization"): + super().__init__([token], auth_method, auth_header) From 259191600507c8c14c417e1e50aa6bf3e6e758d8 Mon Sep 17 00:00:00 2001 From: Vadym Hevlich Date: Tue, 31 Aug 2021 18:29:52 +0300 Subject: [PATCH 4/9] Update Oauth2Authenticator default value setting. Update CHANGELOG.md --- airbyte-cdk/python/CHANGELOG.md | 2 +- .../sources/streams/http/requests_native_auth/oauth.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/airbyte-cdk/python/CHANGELOG.md b/airbyte-cdk/python/CHANGELOG.md index 2d8678fdfd721..729f14927bb64 100644 --- a/airbyte-cdk/python/CHANGELOG.md +++ b/airbyte-cdk/python/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.1.13 Add requests native auth module. Implement Oauth2Authenticator, MultipleTokenAuthenticator and TokenAuthenticator authenticators. -Add support of both legacy and requests native authenticator support to HttpStream class. +Add support for both legacy and requests native authenticator to HttpStream class. ## 0.1.12 Add raise_on_http_errors, max_retries, retry_factor properties to be able to ignore http status errors and modify retry time in HTTP stream diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py index ad119605bc0c8..40e7610473093 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py @@ -42,7 +42,7 @@ def __init__( client_id: str, client_secret: str, refresh_token: str, - token_expiry_date: pendulum.datetime = pendulum.now().subtract(days=1), + token_expiry_date: None, scopes: List[str] = None, access_token_name: str = "access_token", expires_in_name: str = "expires_in", @@ -55,7 +55,7 @@ def __init__( self.access_token_name = access_token_name self.expires_in_name = expires_in_name - self._token_expiry_date = token_expiry_date + self._token_expiry_date = token_expiry_date or pendulum.now().subtract(days=1) self._access_token = None def __call__(self, request): From fd43229d285b4d6aa0009a9cdeb14bccb09fcdf6 Mon Sep 17 00:00:00 2001 From: Vadym Hevlich Date: Mon, 6 Sep 2021 16:58:35 +0300 Subject: [PATCH 5/9] Add requests native authenticator tests --- .../airbyte_cdk/sources/streams/http/http.py | 4 +- .../http/requests_native_auth/oauth.py | 2 +- .../test_requests_native_auth.py | 137 ++++++++++++++++++ .../sources/streams/http/test_http.py | 25 +++- 4 files changed, 163 insertions(+), 5 deletions(-) create mode 100644 airbyte-cdk/python/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py 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 093108f15b5f0..9d7575bd81540 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/http.py @@ -51,10 +51,10 @@ class HttpStream(Stream, ABC): def __init__(self, authenticator: Union[AuthBase, HttpAuthenticator] = None): self._session = requests.Session() + self._authenticator = NoAuth() if isinstance(authenticator, AuthBase): self._session.auth = authenticator - self._authenticator = NoAuth() - else: + elif authenticator: self._authenticator = authenticator @property diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py index 40e7610473093..ee90164a70e9e 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py @@ -42,8 +42,8 @@ def __init__( client_id: str, client_secret: str, refresh_token: str, - token_expiry_date: None, scopes: List[str] = None, + token_expiry_date: pendulum.datetime = None, access_token_name: str = "access_token", expires_in_name: str = "expires_in", ): diff --git a/airbyte-cdk/python/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py b/airbyte-cdk/python/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py new file mode 100644 index 0000000000000..975635a4617e9 --- /dev/null +++ b/airbyte-cdk/python/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py @@ -0,0 +1,137 @@ +# +# MIT License +# +# Copyright (c) 2020 Airbyte +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + + +import logging + +import requests +from airbyte_cdk.sources.streams.http.requests_native_auth import MultipleTokenAuthenticator, Oauth2Authenticator, TokenAuthenticator +from requests import Response + +LOGGER = logging.getLogger(__name__) + + +def test_token_authenticator(): + """ + Should match passed in token, no matter how many times token is retrieved. + """ + token = TokenAuthenticator(token="test-token") + header = token.get_auth_header() + assert {"Authorization": "Bearer test-token"} == header + header = token.get_auth_header() + assert {"Authorization": "Bearer test-token"} == header + + +def test_multiple_token_authenticator(): + token = MultipleTokenAuthenticator(tokens=["token1", "token2"]) + header1 = token.get_auth_header() + assert {"Authorization": "Bearer token1"} == header1 + header2 = token.get_auth_header() + assert {"Authorization": "Bearer token2"} == header2 + header3 = token.get_auth_header() + assert {"Authorization": "Bearer token1"} == header3 + + +class TestOauth2Authenticator: + """ + Test class for OAuth2Authenticator. + """ + + refresh_endpoint = "refresh_end" + client_id = "client_id" + client_secret = "client_secret" + refresh_token = "refresh_token" + + def test_get_auth_header_fresh(self, mocker): + """ + Should not retrieve new token if current token is valid. + """ + oauth = Oauth2Authenticator( + token_refresh_endpoint=TestOauth2Authenticator.refresh_endpoint, + client_id=TestOauth2Authenticator.client_id, + client_secret=TestOauth2Authenticator.client_secret, + refresh_token=TestOauth2Authenticator.refresh_token, + ) + + mocker.patch.object(Oauth2Authenticator, "refresh_access_token", return_value=("access_token", 1000)) + header = oauth.get_auth_header() + assert {"Authorization": "Bearer access_token"} == header + + def test_get_auth_header_expired(self, mocker): + """ + Should retrieve new token if current token is expired. + """ + oauth = Oauth2Authenticator( + token_refresh_endpoint=TestOauth2Authenticator.refresh_endpoint, + client_id=TestOauth2Authenticator.client_id, + client_secret=TestOauth2Authenticator.client_secret, + refresh_token=TestOauth2Authenticator.refresh_token, + ) + + expire_immediately = 0 + mocker.patch.object(Oauth2Authenticator, "refresh_access_token", return_value=("access_token_1", expire_immediately)) + oauth.get_auth_header() # Set the first expired token. + + valid_100_secs = 100 + mocker.patch.object(Oauth2Authenticator, "refresh_access_token", return_value=("access_token_2", valid_100_secs)) + header = oauth.get_auth_header() + assert {"Authorization": "Bearer access_token_2"} == header + + def test_refresh_request_body(self): + """ + Request body should match given configuration. + """ + scopes = ["scope1", "scope2"] + oauth = Oauth2Authenticator( + token_refresh_endpoint=TestOauth2Authenticator.refresh_endpoint, + client_id=TestOauth2Authenticator.client_id, + client_secret=TestOauth2Authenticator.client_secret, + refresh_token=TestOauth2Authenticator.refresh_token, + scopes=scopes, + ) + body = oauth.get_refresh_request_body() + expected = { + "grant_type": "refresh_token", + "client_id": "client_id", + "client_secret": "client_secret", + "refresh_token": "refresh_token", + "scopes": scopes, + } + assert body == expected + + def test_refresh_access_token(self, mocker): + oauth = Oauth2Authenticator( + token_refresh_endpoint=TestOauth2Authenticator.refresh_endpoint, + client_id=TestOauth2Authenticator.client_id, + client_secret=TestOauth2Authenticator.client_secret, + refresh_token=TestOauth2Authenticator.refresh_token, + ) + resp = Response() + resp.status_code = 200 + + mocker.patch.object(requests, "request", return_value=resp) + mocker.patch.object(resp, "json", return_value={"access_token": "access_token", "expires_in": 1000}) + token = oauth.refresh_access_token() + + assert ("access_token", 1000) == token 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 84a53835243d3..591e2cc8003ee 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 @@ -32,15 +32,18 @@ import requests from airbyte_cdk.models import SyncMode from airbyte_cdk.sources.streams.http import HttpStream +from airbyte_cdk.sources.streams.http.auth import NoAuth +from airbyte_cdk.sources.streams.http.auth import TokenAuthenticator as HttpTokenAuthenticator from airbyte_cdk.sources.streams.http.exceptions import DefaultBackoffException, RequestBodyException, UserDefinedBackoffException +from airbyte_cdk.sources.streams.http.requests_native_auth import TokenAuthenticator class StubBasicReadHttpStream(HttpStream): url_base = "https://test_base_url.com" primary_key = "" - def __init__(self): - super().__init__() + def __init__(self, **kwargs): + super().__init__(**kwargs) self.resp_counter = 1 def next_page_token(self, response: requests.Response) -> Optional[Mapping[str, Any]]: @@ -63,6 +66,24 @@ def parse_response( yield stubResp +def test_default_authenticator(): + stream = StubBasicReadHttpStream() + assert isinstance(stream.authenticator, NoAuth) + assert stream._session.auth is None + + +def test_requests_native_token_authenticator(): + stream = StubBasicReadHttpStream(authenticator=TokenAuthenticator("test-token")) + assert isinstance(stream.authenticator, NoAuth) + assert isinstance(stream._session.auth, TokenAuthenticator) + + +def test_http_token_authenticator(): + stream = StubBasicReadHttpStream(authenticator=HttpTokenAuthenticator("test-token")) + assert isinstance(stream.authenticator, HttpTokenAuthenticator) + assert stream._session.auth is None + + def test_request_kwargs_used(mocker, requests_mock): stream = StubBasicReadHttpStream() request_kwargs = {"cert": None, "proxies": "google.com"} From 25f0b620f216ea7472b5f202ce2ad8623862af01 Mon Sep 17 00:00:00 2001 From: Vadym Hevlich Date: Thu, 9 Sep 2021 18:56:31 +0300 Subject: [PATCH 6/9] Add CDK requests native __call__ method tests. Update CHANGELOG.md --- airbyte-cdk/python/CHANGELOG.md | 6 +-- .../http/requests_native_auth/token.py | 11 ++++++ .../test_requests_native_auth.py | 39 +++++++++++++++---- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/airbyte-cdk/python/CHANGELOG.md b/airbyte-cdk/python/CHANGELOG.md index cc99281249934..7e474e728612a 100644 --- a/airbyte-cdk/python/CHANGELOG.md +++ b/airbyte-cdk/python/CHANGELOG.md @@ -1,9 +1,9 @@ # Changelog ## 0.1.18 -Add requests native auth module. -Implement Oauth2Authenticator, MultipleTokenAuthenticator and TokenAuthenticator authenticators. -Add support for both legacy and requests native authenticator to HttpStream class. +- Allow using `requests.auth.AuthBase` as authenticators instead of custom CDK authenticators. +- Implement Oauth2Authenticator, MultipleTokenAuthenticator and TokenAuthenticator authenticators. +- Add support for both legacy and requests native authenticator to HttpStream class. ## 0.1.17 Fix mismatching between number of records actually read and number of records in logs by 1: https://github.com/airbytehq/airbyte/pull/5767 diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/token.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/token.py index c9a6bd7b40dea..925962993fba9 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/token.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/requests_native_auth/token.py @@ -29,6 +29,12 @@ class MultipleTokenAuthenticator(AuthBase): + """ + Builds auth header, based on the list of tokens provided. + Auth header is changed per each `get_auth_header` call, using each token in cycle. + The token is attached to each request via the `auth_header` header. + """ + def __init__(self, tokens: List[str], auth_method: str = "Bearer", auth_header: str = "Authorization"): self.auth_method = auth_method self.auth_header = auth_header @@ -44,5 +50,10 @@ def get_auth_header(self) -> Mapping[str, Any]: class TokenAuthenticator(MultipleTokenAuthenticator): + """ + Builds auth header, based on the token provided. + The token is attached to each request via the `auth_header` header. + """ + def __init__(self, token: str, auth_method: str = "Bearer", auth_header: str = "Authorization"): super().__init__([token], auth_method, auth_header) diff --git a/airbyte-cdk/python/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py b/airbyte-cdk/python/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py index 975635a4617e9..a477b63fa5f34 100644 --- a/airbyte-cdk/python/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py +++ b/airbyte-cdk/python/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py @@ -32,24 +32,34 @@ LOGGER = logging.getLogger(__name__) -def test_token_authenticator(): +def test_token_authenticator(mocker): """ Should match passed in token, no matter how many times token is retrieved. """ token = TokenAuthenticator(token="test-token") - header = token.get_auth_header() - assert {"Authorization": "Bearer test-token"} == header - header = token.get_auth_header() - assert {"Authorization": "Bearer test-token"} == header + header1 = token.get_auth_header() + header2 = token.get_auth_header() + + mocker.patch.object(requests.sessions.Session, "send", new=lambda self, request, **kwargs: request.headers) + request_headers = requests.request(url="https://fake_url", method="FAKE", auth=token) + assert all(item in request_headers.items() for item in header1.items()) + assert {"Authorization": "Bearer test-token"} == header1 + assert {"Authorization": "Bearer test-token"} == header2 -def test_multiple_token_authenticator(): + +def test_multiple_token_authenticator(mocker): token = MultipleTokenAuthenticator(tokens=["token1", "token2"]) header1 = token.get_auth_header() - assert {"Authorization": "Bearer token1"} == header1 header2 = token.get_auth_header() - assert {"Authorization": "Bearer token2"} == header2 header3 = token.get_auth_header() + + mocker.patch.object(requests.sessions.Session, "send", new=lambda self, request, **kwargs: request.headers) + request_headers = requests.request(url="https://fake_url", method="FAKE", auth=token) + + assert all(item in request_headers.items() for item in header2.items()) + assert {"Authorization": "Bearer token1"} == header1 + assert {"Authorization": "Bearer token2"} == header2 assert {"Authorization": "Bearer token1"} == header3 @@ -135,3 +145,16 @@ def test_refresh_access_token(self, mocker): token = oauth.refresh_access_token() assert ("access_token", 1000) == token + + def test_auth_call_method(self, mocker): + oauth = Oauth2Authenticator( + token_refresh_endpoint=TestOauth2Authenticator.refresh_endpoint, + client_id=TestOauth2Authenticator.client_id, + client_secret=TestOauth2Authenticator.client_secret, + refresh_token=TestOauth2Authenticator.refresh_token, + ) + mocker.patch.object(oauth, "refresh_access_token", return_value=("access_token", 100)) + mocker.patch.object(requests.sessions.Session, "send", new=lambda s, request, **kwargs: request.headers) + request_headers = requests.request(url="https://fake_url", method="FAKE", auth=oauth) + + assert all(item in request_headers.items() for item in oauth.get_auth_header().items()) From 6df105b4c387c42a373e444dc27cb8bcf4fbec74 Mon Sep 17 00:00:00 2001 From: Vadym Hevlich Date: Thu, 9 Sep 2021 19:21:26 +0300 Subject: [PATCH 7/9] Add outdated auth deprication messages --- .../python/airbyte_cdk/sources/streams/http/auth/core.py | 4 ++++ .../python/airbyte_cdk/sources/streams/http/auth/oauth.py | 2 ++ .../python/airbyte_cdk/sources/streams/http/auth/token.py | 4 ++++ airbyte-cdk/python/setup.py | 1 + 4 files changed, 11 insertions(+) diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/core.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/core.py index 5db5cfea1f750..3bc05b614293a 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/core.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/core.py @@ -26,7 +26,10 @@ from abc import ABC, abstractmethod from typing import Any, Mapping +from deprecated import deprecated + +@deprecated(version="0.1.18", reason="Use requests.auth.AuthBase instead") class HttpAuthenticator(ABC): """ Base abstract class for various HTTP Authentication strategies. Authentication strategies are generally @@ -40,6 +43,7 @@ def get_auth_header(self) -> Mapping[str, Any]: """ +@deprecated(version="0.1.18", reason="Set `authenticator=None` instead") class NoAuth(HttpAuthenticator): def get_auth_header(self) -> Mapping[str, Any]: return {} diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/oauth.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/oauth.py index d7799e25ab736..712d9ea33aa22 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/oauth.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/oauth.py @@ -27,10 +27,12 @@ import pendulum import requests +from deprecated import deprecated from .core import HttpAuthenticator +@deprecated(version="0.1.18", reason="Use airbyte_cdk.sources.streams.http.requests_native_auth.Oauth2Authenticator instead") class Oauth2Authenticator(HttpAuthenticator): """ Generates OAuth2.0 access tokens from an OAuth2.0 refresh token and client credentials. diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/token.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/token.py index 294e19175d3e4..eaae352c4f93d 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/token.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/token.py @@ -26,9 +26,12 @@ from itertools import cycle from typing import Any, List, Mapping +from deprecated import deprecated + from .core import HttpAuthenticator +@deprecated(version="0.1.18", reason="Use airbyte_cdk.sources.streams.http.requests_native_auth.TokenAuthenticator instead") class TokenAuthenticator(HttpAuthenticator): def __init__(self, token: str, auth_method: str = "Bearer", auth_header: str = "Authorization"): self.auth_method = auth_method @@ -39,6 +42,7 @@ def get_auth_header(self) -> Mapping[str, Any]: return {self.auth_header: f"{self.auth_method} {self._token}"} +@deprecated(version="0.1.18", reason="Use airbyte_cdk.sources.streams.http.requests_native_auth.MultipleTokenAuthenticator instead") class MultipleTokenAuthenticator(HttpAuthenticator): def __init__(self, tokens: List[str], auth_method: str = "Bearer", auth_header: str = "Authorization"): self.auth_method = auth_method diff --git a/airbyte-cdk/python/setup.py b/airbyte-cdk/python/setup.py index 7e5223f33a268..9a3193ce4f66d 100644 --- a/airbyte-cdk/python/setup.py +++ b/airbyte-cdk/python/setup.py @@ -71,6 +71,7 @@ "pydantic~=1.6", "PyYAML~=5.4", "requests", + "Deprecated~=1.2", ], python_requires=">=3.7.0", extras_require={"dev": ["MyPy~=0.812", "pytest", "pytest-cov", "pytest-mock", "requests-mock"]}, From 1cffc364e02b9dfdb25747dc2594aaa0a3052ff1 Mon Sep 17 00:00:00 2001 From: Vadym Hevlich Date: Wed, 15 Sep 2021 16:10:14 +0300 Subject: [PATCH 8/9] Update requests native auth __call__ method tests --- .../test_requests_native_auth.py | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/airbyte-cdk/python/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py b/airbyte-cdk/python/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py index a477b63fa5f34..f1a88dadc585a 100644 --- a/airbyte-cdk/python/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py +++ b/airbyte-cdk/python/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py @@ -32,32 +32,34 @@ LOGGER = logging.getLogger(__name__) -def test_token_authenticator(mocker): +def test_token_authenticator(): """ Should match passed in token, no matter how many times token is retrieved. """ - token = TokenAuthenticator(token="test-token") - header1 = token.get_auth_header() - header2 = token.get_auth_header() + token_auth = TokenAuthenticator(token="test-token") + header1 = token_auth.get_auth_header() + header2 = token_auth.get_auth_header() - mocker.patch.object(requests.sessions.Session, "send", new=lambda self, request, **kwargs: request.headers) - request_headers = requests.request(url="https://fake_url", method="FAKE", auth=token) + prepared_request = requests.PreparedRequest() + prepared_request.headers = {} + token_auth(prepared_request) - assert all(item in request_headers.items() for item in header1.items()) + assert {"Authorization": "Bearer test-token"} == prepared_request.headers assert {"Authorization": "Bearer test-token"} == header1 assert {"Authorization": "Bearer test-token"} == header2 -def test_multiple_token_authenticator(mocker): - token = MultipleTokenAuthenticator(tokens=["token1", "token2"]) - header1 = token.get_auth_header() - header2 = token.get_auth_header() - header3 = token.get_auth_header() +def test_multiple_token_authenticator(): + multiple_token_auth = MultipleTokenAuthenticator(tokens=["token1", "token2"]) + header1 = multiple_token_auth.get_auth_header() + header2 = multiple_token_auth.get_auth_header() + header3 = multiple_token_auth.get_auth_header() - mocker.patch.object(requests.sessions.Session, "send", new=lambda self, request, **kwargs: request.headers) - request_headers = requests.request(url="https://fake_url", method="FAKE", auth=token) + prepared_request = requests.PreparedRequest() + prepared_request.headers = {} + multiple_token_auth(prepared_request) - assert all(item in request_headers.items() for item in header2.items()) + assert {"Authorization": "Bearer token2"} == prepared_request.headers assert {"Authorization": "Bearer token1"} == header1 assert {"Authorization": "Bearer token2"} == header2 assert {"Authorization": "Bearer token1"} == header3 @@ -153,8 +155,10 @@ def test_auth_call_method(self, mocker): client_secret=TestOauth2Authenticator.client_secret, refresh_token=TestOauth2Authenticator.refresh_token, ) - mocker.patch.object(oauth, "refresh_access_token", return_value=("access_token", 100)) - mocker.patch.object(requests.sessions.Session, "send", new=lambda s, request, **kwargs: request.headers) - request_headers = requests.request(url="https://fake_url", method="FAKE", auth=oauth) - assert all(item in request_headers.items() for item in oauth.get_auth_header().items()) + mocker.patch.object(Oauth2Authenticator, "refresh_access_token", return_value=("access_token", 1000)) + prepared_request = requests.PreparedRequest() + prepared_request.headers = {} + oauth(prepared_request) + + assert {"Authorization": "Bearer access_token"} == prepared_request.headers From a2f87e52b7960c1fe6935e105133bd2d94a5c61b Mon Sep 17 00:00:00 2001 From: Vadym Hevlich Date: Wed, 15 Sep 2021 18:59:49 +0300 Subject: [PATCH 9/9] Bump CDK version to 0.1.20 --- .../python/airbyte_cdk/sources/streams/http/auth/core.py | 4 ++-- .../python/airbyte_cdk/sources/streams/http/auth/oauth.py | 2 +- .../python/airbyte_cdk/sources/streams/http/auth/token.py | 4 ++-- airbyte-cdk/python/setup.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/core.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/core.py index 3bc05b614293a..97bcc3b58645b 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/core.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/core.py @@ -29,7 +29,7 @@ from deprecated import deprecated -@deprecated(version="0.1.18", reason="Use requests.auth.AuthBase instead") +@deprecated(version="0.1.20", reason="Use requests.auth.AuthBase instead") class HttpAuthenticator(ABC): """ Base abstract class for various HTTP Authentication strategies. Authentication strategies are generally @@ -43,7 +43,7 @@ def get_auth_header(self) -> Mapping[str, Any]: """ -@deprecated(version="0.1.18", reason="Set `authenticator=None` instead") +@deprecated(version="0.1.20", reason="Set `authenticator=None` instead") class NoAuth(HttpAuthenticator): def get_auth_header(self) -> Mapping[str, Any]: return {} diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/oauth.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/oauth.py index 712d9ea33aa22..b76cf962ffb94 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/oauth.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/oauth.py @@ -32,7 +32,7 @@ from .core import HttpAuthenticator -@deprecated(version="0.1.18", reason="Use airbyte_cdk.sources.streams.http.requests_native_auth.Oauth2Authenticator instead") +@deprecated(version="0.1.20", reason="Use airbyte_cdk.sources.streams.http.requests_native_auth.Oauth2Authenticator instead") class Oauth2Authenticator(HttpAuthenticator): """ Generates OAuth2.0 access tokens from an OAuth2.0 refresh token and client credentials. diff --git a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/token.py b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/token.py index eaae352c4f93d..64da6c61f8e12 100644 --- a/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/token.py +++ b/airbyte-cdk/python/airbyte_cdk/sources/streams/http/auth/token.py @@ -31,7 +31,7 @@ from .core import HttpAuthenticator -@deprecated(version="0.1.18", reason="Use airbyte_cdk.sources.streams.http.requests_native_auth.TokenAuthenticator instead") +@deprecated(version="0.1.20", reason="Use airbyte_cdk.sources.streams.http.requests_native_auth.TokenAuthenticator instead") class TokenAuthenticator(HttpAuthenticator): def __init__(self, token: str, auth_method: str = "Bearer", auth_header: str = "Authorization"): self.auth_method = auth_method @@ -42,7 +42,7 @@ def get_auth_header(self) -> Mapping[str, Any]: return {self.auth_header: f"{self.auth_method} {self._token}"} -@deprecated(version="0.1.18", reason="Use airbyte_cdk.sources.streams.http.requests_native_auth.MultipleTokenAuthenticator instead") +@deprecated(version="0.1.20", reason="Use airbyte_cdk.sources.streams.http.requests_native_auth.MultipleTokenAuthenticator instead") class MultipleTokenAuthenticator(HttpAuthenticator): def __init__(self, tokens: List[str], auth_method: str = "Bearer", auth_header: str = "Authorization"): self.auth_method = auth_method diff --git a/airbyte-cdk/python/setup.py b/airbyte-cdk/python/setup.py index 9a3193ce4f66d..3488ef6928528 100644 --- a/airbyte-cdk/python/setup.py +++ b/airbyte-cdk/python/setup.py @@ -35,7 +35,7 @@ setup( name="airbyte-cdk", - version="0.1.18", + version="0.1.20", description="A framework for writing Airbyte Connectors.", long_description=README, long_description_content_type="text/markdown",