From f3897090482b6b2f93b36c241249286c9f8f2b39 Mon Sep 17 00:00:00 2001 From: Antoine SEIN <142824551+asein-sinch@users.noreply.github.com> Date: Wed, 18 Jun 2025 18:02:33 +0200 Subject: [PATCH 1/5] Add missing implementations for async authentication (#74) * Add missing implementations * Bump SDK version --- pyproject.toml | 2 +- sinch/__init__.py | 2 +- sinch/core/ports/http_transport.py | 46 +++++++ tests/unit/http_transport_tests.py | 187 +++++++++++++++++++++++++++++ 4 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 tests/unit/http_transport_tests.py diff --git a/pyproject.toml b/pyproject.toml index 665599b0..1e3f92e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "sinch" description = "Sinch SDK for Python programming language" -version = "1.1.1" +version = "1.1.2" license = "Apache 2.0" readme = "README.md" authors = [ diff --git a/sinch/__init__.py b/sinch/__init__.py index 6ee7635c..f0ac93b0 100644 --- a/sinch/__init__.py +++ b/sinch/__init__.py @@ -1,7 +1,7 @@ """ Sinch Python SDK To access Sinch resources, use the Sync or Async version of the Sinch Client. """ -__version__ = "1.1.1" +__version__ = "1.1.2" from sinch.core.clients.sinch_client_sync import SinchClient from sinch.core.clients.sinch_client_async import SinchClientAsync diff --git a/sinch/core/ports/http_transport.py b/sinch/core/ports/http_transport.py index de172311..9385b31d 100644 --- a/sinch/core/ports/http_transport.py +++ b/sinch/core/ports/http_transport.py @@ -127,6 +127,21 @@ def handle_response(self, endpoint: HTTPEndpoint, http_response: HTTPResponse): class AsyncHTTPTransport(HTTPTransport): async def authenticate(self, endpoint, request_data): + if endpoint.HTTP_AUTHENTICATION in (HTTPAuthentication.BASIC.value, HTTPAuthentication.OAUTH.value): + if ( + not self.sinch.configuration.key_id + or not self.sinch.configuration.key_secret + or not self.sinch.configuration.project_id + ): + raise ValidationException( + message=( + "key_id, key_secret and project_id are required by this API. " + "Those credentials can be obtained from Sinch portal." + ), + is_from_server=False, + response=None + ) + if endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.BASIC.value: request_data.auth = httpx.BasicAuth( self.sinch.configuration.key_id, @@ -141,6 +156,37 @@ async def authenticate(self, endpoint, request_data): "Authorization": f"Bearer {token_response.access_token}", "Content-Type": "application/json" } + elif endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.SIGNED.value: + if not self.sinch.configuration.application_key or not self.sinch.configuration.application_secret: + raise ValidationException( + message=( + "application key and application secret are required by this API. " + "Those credentials can be obtained from Sinch portal." + ), + is_from_server=False, + response=None + ) + signature = Signature( + self.sinch, + endpoint.HTTP_METHOD, + request_data.request_body, + endpoint.get_url_without_origin(self.sinch) + ) + request_data.headers = signature.get_http_headers_with_signature() + elif endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.SMS_TOKEN.value: + if not self.sinch.configuration.sms_api_token or not self.sinch.configuration.service_plan_id: + raise ValidationException( + message=( + "sms_api_token and service_plan_id are required by this API. " + "Those credentials can be obtained from Sinch portal." + ), + is_from_server=False, + response=None + ) + request_data.headers.update({ + "Authorization": f"Bearer {self.sinch.configuration.sms_api_token}", + "Content-Type": "application/json" + }) return request_data diff --git a/tests/unit/http_transport_tests.py b/tests/unit/http_transport_tests.py new file mode 100644 index 00000000..5e096c2d --- /dev/null +++ b/tests/unit/http_transport_tests.py @@ -0,0 +1,187 @@ +import httpx +import pytest +from unittest.mock import Mock, AsyncMock +from sinch.core.enums import HTTPAuthentication +from sinch.core.exceptions import ValidationException +from sinch.core.models.http_request import HttpRequest +from sinch.core.endpoint import HTTPEndpoint +from sinch.core.models.http_response import HTTPResponse +from sinch.core.ports.http_transport import HTTPTransport, AsyncHTTPTransport + + +# Mock classes and fixtures +class MockEndpoint(HTTPEndpoint): + def __init__(self, auth_type): + self.HTTP_AUTHENTICATION = auth_type + self.HTTP_METHOD = "GET" + + def build_url(self, sinch): + return "api.sinch.com/test" + + def get_url_without_origin(self, sinch): + return "/test" + + def request_body(self): + return {} + + def build_query_params(self): + return {} + + def handle_response(self, response: HTTPResponse): + return response + + +@pytest.fixture +def mock_sinch(): + sinch = Mock() + sinch.configuration = Mock() + sinch.configuration.key_id = "test_key_id" + sinch.configuration.key_secret = "test_key_secret" + sinch.configuration.project_id = "test_project_id" + sinch.configuration.application_key = "test_app_key" + sinch.configuration.application_secret = "dGVzdF9hcHBfc2VjcmV0X2Jhc2U2NA==" + sinch.configuration.sms_api_token = "test_sms_token" + sinch.configuration.service_plan_id = "test_service_plan" + return sinch + +@pytest.fixture +def mock_sinch_async(): + sinch = Mock() + sinch.configuration = Mock() + sinch.configuration.key_id = "test_key_id" + sinch.configuration.key_secret = "test_key_secret" + sinch.configuration.project_id = "test_project_id" + sinch.configuration.application_key = "test_app_key" + sinch.configuration.application_secret = "dGVzdF9hcHBfc2VjcmV0X2Jhc2U2NA==" + sinch.configuration.sms_api_token = "test_sms_token" + sinch.configuration.service_plan_id = "test_service_plan" + + mock_auth = AsyncMock() + mock_token = Mock() + mock_token.access_token = "test_token" + mock_auth.get_auth_token.return_value = mock_token + sinch.authentication = mock_auth + + return sinch + + +@pytest.fixture +def base_request(): + return HttpRequest( + headers={}, + protocol="https://", + url="https://api.sinch.com/test", + http_method="GET", + request_body={}, + query_params={}, + auth=() + ) + +class MockHTTPTransport(HTTPTransport): + def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: + # Simple mock implementation that just returns a dummy response + return HTTPResponse(status_code=200, body={}, headers={}) + +class MockAsyncHTTPTransport(AsyncHTTPTransport): + async def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: + # Simple mock implementation that just returns a dummy response + return HTTPResponse(status_code=200, body={}, headers={}) + +# Synchronous Transport Tests +class TestHTTPTransport: + @pytest.mark.parametrize("auth_type", [ + HTTPAuthentication.BASIC.value, + HTTPAuthentication.OAUTH.value, + HTTPAuthentication.SIGNED.value, + HTTPAuthentication.SMS_TOKEN.value + ]) + def test_authenticate(self, mock_sinch, base_request, auth_type): + transport = MockHTTPTransport(mock_sinch) + endpoint = MockEndpoint(auth_type) + + if auth_type == HTTPAuthentication.BASIC.value: + result = transport.authenticate(endpoint, base_request) + assert result.auth == ("test_key_id", "test_key_secret") + + elif auth_type == HTTPAuthentication.OAUTH.value: + mock_sinch.authentication.get_auth_token.return_value.access_token = "test_token" + result = transport.authenticate(endpoint, base_request) + assert result.headers["Authorization"] == "Bearer test_token" + assert result.headers["Content-Type"] == "application/json" + + elif auth_type == HTTPAuthentication.SIGNED.value: + result = transport.authenticate(endpoint, base_request) + assert "x-timestamp" in result.headers + assert "Authorization" in result.headers + + elif auth_type == HTTPAuthentication.SMS_TOKEN.value: + result = transport.authenticate(endpoint, base_request) + assert result.headers["Authorization"] == "Bearer test_sms_token" + assert result.headers["Content-Type"] == "application/json" + + @pytest.mark.parametrize("auth_type,missing_creds", [ + (HTTPAuthentication.BASIC.value, {"key_id": None}), + (HTTPAuthentication.OAUTH.value, {"key_secret": None}), + (HTTPAuthentication.SIGNED.value, {"application_key": None}), + (HTTPAuthentication.SMS_TOKEN.value, {"sms_api_token": None}) + ]) + def test_authenticate_missing_credentials(self, mock_sinch, base_request, auth_type, missing_creds): + transport = MockHTTPTransport(mock_sinch) + endpoint = MockEndpoint(auth_type) + + for cred, value in missing_creds.items(): + setattr(mock_sinch.configuration, cred, value) + + with pytest.raises(ValidationException): + transport.authenticate(endpoint, base_request) + +# Async Transport Tests +class TestAsyncHTTPTransport: + @pytest.mark.asyncio + @pytest.mark.parametrize("auth_type", [ + HTTPAuthentication.BASIC.value, + HTTPAuthentication.OAUTH.value, + HTTPAuthentication.SIGNED.value, + HTTPAuthentication.SMS_TOKEN.value + ]) + async def test_authenticate(self, mock_sinch_async, base_request, auth_type): + transport = MockAsyncHTTPTransport(mock_sinch_async) + endpoint = MockEndpoint(auth_type) + + if auth_type == HTTPAuthentication.BASIC.value: + result = await transport.authenticate(endpoint, base_request) + assert isinstance(result.auth, httpx.BasicAuth) + assert result.auth._auth_header == "Basic dGVzdF9rZXlfaWQ6dGVzdF9rZXlfc2VjcmV0" + + elif auth_type == HTTPAuthentication.OAUTH.value: + mock_sinch_async.authentication.get_auth_token.return_value.access_token = "test_token" + result = await transport.authenticate(endpoint, base_request) + assert result.headers["Authorization"] == "Bearer test_token" + assert result.headers["Content-Type"] == "application/json" + + elif auth_type == HTTPAuthentication.SIGNED.value: + result = await transport.authenticate(endpoint, base_request) + assert "x-timestamp" in result.headers + assert "Authorization" in result.headers + + elif auth_type == HTTPAuthentication.SMS_TOKEN.value: + result = await transport.authenticate(endpoint, base_request) + assert result.headers["Authorization"] == "Bearer test_sms_token" + assert result.headers["Content-Type"] == "application/json" + + @pytest.mark.asyncio + @pytest.mark.parametrize("auth_type,missing_creds", [ + (HTTPAuthentication.BASIC.value, {"key_id": None}), + (HTTPAuthentication.OAUTH.value, {"key_secret": None}), + (HTTPAuthentication.SIGNED.value, {"application_key": None}), + (HTTPAuthentication.SMS_TOKEN.value, {"sms_api_token": None}) + ]) + async def test_authenticate_missing_credentials(self, mock_sinch_async, base_request, auth_type, missing_creds): + transport = MockAsyncHTTPTransport(mock_sinch_async) + endpoint = MockEndpoint(auth_type) + + for cred, value in missing_creds.items(): + setattr(mock_sinch_async.configuration, cred, value) + + with pytest.raises(ValidationException): + await transport.authenticate(endpoint, base_request) \ No newline at end of file From 863bece71833f68a0b62540e006c9afe54e1f4be Mon Sep 17 00:00:00 2001 From: Antoine SEIN <142824551+asein-sinch@users.noreply.github.com> Date: Thu, 11 Sep 2025 11:43:44 +0200 Subject: [PATCH 2/5] Fix DTMF options for conference call (#79) --- pyproject.toml | 2 +- sinch/__init__.py | 2 +- .../voice/endpoints/callouts/callout.py | 6 +- .../domains/voice/test_callout_conference.py | 77 +++++++++++++++++++ 4 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 tests/unit/domains/voice/test_callout_conference.py diff --git a/pyproject.toml b/pyproject.toml index 1e3f92e2..6a59c2b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "sinch" description = "Sinch SDK for Python programming language" -version = "1.1.2" +version = "1.1.3" license = "Apache 2.0" readme = "README.md" authors = [ diff --git a/sinch/__init__.py b/sinch/__init__.py index f0ac93b0..a9dd613d 100644 --- a/sinch/__init__.py +++ b/sinch/__init__.py @@ -1,7 +1,7 @@ """ Sinch Python SDK To access Sinch resources, use the Sync or Async version of the Sinch Client. """ -__version__ = "1.1.2" +__version__ = "1.1.3" from sinch.core.clients.sinch_client_sync import SinchClient from sinch.core.clients.sinch_client_async import SinchClientAsync diff --git a/sinch/domains/voice/endpoints/callouts/callout.py b/sinch/domains/voice/endpoints/callouts/callout.py index 324e7698..ef889a05 100644 --- a/sinch/domains/voice/endpoints/callouts/callout.py +++ b/sinch/domains/voice/endpoints/callouts/callout.py @@ -36,13 +36,13 @@ def request_body(self): dtmf_options = {} if self.request_data.conferenceDtmfOptions["mode"]: - dtmf_options["mode"] = self.request_data.get["conferenceDtmfOptions"]["mode"] + dtmf_options["mode"] = self.request_data.conferenceDtmfOptions["mode"] if self.request_data.conferenceDtmfOptions["timeout_mills"]: - dtmf_options["timeoutMills"] = self.request_data.get["conferenceDtmfOptions"]["timeout_mills"] + dtmf_options["timeoutMills"] = self.request_data.conferenceDtmfOptions["timeout_mills"] if self.request_data.conferenceDtmfOptions["max_digits"]: - dtmf_options["maxDigits"] = self.request_data.get["conferenceDtmfOptions"]["max_digits"] + dtmf_options["maxDigits"] = self.request_data.conferenceDtmfOptions["max_digits"] self.request_data.conferenceDtmfOptions = dtmf_options diff --git a/tests/unit/domains/voice/test_callout_conference.py b/tests/unit/domains/voice/test_callout_conference.py new file mode 100644 index 00000000..9ea2169c --- /dev/null +++ b/tests/unit/domains/voice/test_callout_conference.py @@ -0,0 +1,77 @@ +import json +import pytest +from sinch.domains.voice.enums import CalloutMethod + +from sinch.domains.voice.endpoints.callouts.callout import CalloutEndpoint + +from sinch.domains.voice.models.callouts.requests import ConferenceVoiceCalloutRequest + + +@pytest.fixture +def request_data(): + return ConferenceVoiceCalloutRequest( + destination={ + "type": "number", + "endpoint": "+33612345678", + }, + cli="", + greeting='Welcome', + conferenceId="123456", + conferenceDtmfOptions={ + "mode": "forward", + "max_digits": 2, + "timeout_mills": 2500 + }, + dtmf="dtmf", + conference="conference", + maxDuration=10, + enableAce=True, + enableDice=True, + enablePie=True, + locale="locale", + mohClass="moh_class", + custom="custom", + domain="pstn" + ) + +@pytest.fixture +def endpoint(request_data): + return CalloutEndpoint(request_data, CalloutMethod.CONFERENCE.value) + +@pytest.fixture +def mock_response_body(): + expected_body = { + "method" : "conferenceCallout", + "conferenceCallout" : { + "destination" : { + "type" : "number", + "endpoint" : "+33612345678" + }, + "conferenceId" : "123456", + "cli" : "", + "conferenceDtmfOptions" : { + "mode" : "forward", + "timeoutMills" : 2500, + "maxDigits" : 2 + }, + "dtmf" : "dtmf", + "conference" : "conference", + "maxDuration" : 10, + "enableAce" : True, + "enableDice" : True, + "enablePie" : True, + "locale" : "locale", + "greeting" : "Welcome", + "mohClass" : "moh_class", + "custom" : "custom", + "domain" : "pstn" + } + } + return json.dumps(expected_body) + +def test_handle_response(endpoint, mock_response_body): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + request_body = endpoint.request_body() + assert request_body == mock_response_body From b2145bdae6158d0294a7c549c87a6d82871307cf Mon Sep 17 00:00:00 2001 From: Vincent Date: Thu, 23 Oct 2025 02:26:37 -0600 Subject: [PATCH 3/5] Add python 3.13 and 3.14 spec (#86) Co-authored-by: Vincent Davis --- .github/workflows/run-tests.yml | 2 +- README.md | 4 +++- pyproject.toml | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 92ab75e3..828691a7 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v3 diff --git a/README.md b/README.md index 733c0c0a..5b7c40c4 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ [![Python 3.10](https://img.shields.io/badge/python-3.10-blue.svg)](https://www.python.org/downloads/release/python-3100/) [![Python 3.11](https://img.shields.io/badge/python-3.11-blue.svg)](https://www.python.org/downloads/release/python-3110/) [![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/downloads/release/python-3120/) +[![Python 3.13](https://img.shields.io/badge/python-3.13-blue.svg)](https://www.python.org/downloads/release/python-3130/) +[![Python 3.14](https://img.shields.io/badge/python-3.14-blue.svg)](https://www.python.org/downloads/release/python-3140/) @@ -24,7 +26,7 @@ For more information on the Sinch APIs on which this SDK is based, refer to the ## Prerequisites -- Python in one of the supported versions - 3.9, 3.10, 3.11, 3.12 +- Python in one of the supported versions - 3.9, 3.10, 3.11, 3.12, 3.13, 3.14 - pip - Sinch account diff --git a/pyproject.toml b/pyproject.toml index 6a59c2b5..eae40698 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,8 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Communications :: Telephony", From e16baa4894ed3520eb5fc23593d57390c7738257 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 23 Oct 2025 15:19:13 +0200 Subject: [PATCH 4/5] relese(ci): Add Python 3.13 and 3.14 support (#89) Co-authored by: @vincentdavis --- pyproject.toml | 4 ++-- sinch/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eae40698..44d69345 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "sinch" description = "Sinch SDK for Python programming language" -version = "1.1.3" +version = "1.1.4" license = "Apache 2.0" readme = "README.md" authors = [ @@ -17,7 +17,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Software Development :: Libraries :: Python Modules", diff --git a/sinch/__init__.py b/sinch/__init__.py index a9dd613d..769e4e96 100644 --- a/sinch/__init__.py +++ b/sinch/__init__.py @@ -1,7 +1,7 @@ """ Sinch Python SDK To access Sinch resources, use the Sync or Async version of the Sinch Client. """ -__version__ = "1.1.3" +__version__ = "1.1.4" from sinch.core.clients.sinch_client_sync import SinchClient from sinch.core.clients.sinch_client_async import SinchClientAsync From 7210e4f5bdfee84809039e18b312f8f680f5e7f1 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 25 Nov 2025 10:15:39 +0100 Subject: [PATCH 5/5] chore: add CODEOWNERS (#100) --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 585690ea..10b70ef9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners -* @matsk-sinch @Dovchik @krogers0607 @asein-sinch @JPPortier \ No newline at end of file +* @matsk-sinch @asein-sinch @JPPortier @rpredescu-sinch \ No newline at end of file