Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 6 additions & 18 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,6 @@
name: Test Python SDK
on: [ push ]

env:
AUTH_ORIGIN: ${{ secrets.AUTH_ORIGIN }}
CONVERSATION_ORIGIN: ${{ secrets.CONVERSATION_ORIGIN }}
DISABLE_SSL: ${{ secrets.DISABLE_SSL }}
KEY_ID: ${{ secrets.KEY_ID }}
KEY_SECRET: ${{ secrets.KEY_SECRET }}
NUMBERS_ORIGIN: ${{ secrets.NUMBERS_ORIGIN }}
PROJECT_ID: ${{ secrets.PROJECT_ID }}
SERVICE_PLAN_ID: ${{ secrets.SERVICE_PLAN_ID }}
SMS_ORIGIN: ${{ secrets.SMS_ORIGIN }}
TEMPLATES_ORIGIN: ${{ secrets.TEMPLATES_ORIGIN }}

jobs:
build:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -45,6 +33,10 @@ jobs:
run: |
pip install python-dotenv
python scripts/check_snippet_coverage.py

- name: Generate Documentation
run: |
make docs

- name: Lint and format check with Ruff
run: |
Expand All @@ -69,7 +61,7 @@ jobs:
uses: actions/checkout@v4
with:
repository: sinch/sinch-sdk-mockserver
token: ${{ secrets.PAT_CI }}
token: ${{ secrets.MOCKSERVER_REPO_PAT_CI }}
fetch-depth: 0
path: sinch-sdk-mockserver

Expand All @@ -84,11 +76,7 @@ jobs:
cp sinch-sdk-mockserver/features/numbers/callback-configuration.feature ./tests/e2e/numbers/features/
cp sinch-sdk-mockserver/features/numbers/numbers.feature ./tests/e2e/numbers/features/
cp sinch-sdk-mockserver/features/numbers/webhooks.feature ./tests/e2e/numbers/features/
cp sinch-sdk-mockserver/features/sms/delivery-reports.feature ./tests/e2e/sms/features/
cp sinch-sdk-mockserver/features/sms/delivery-reports_servicePlanId.feature ./tests/e2e/sms/features/
cp sinch-sdk-mockserver/features/sms/batches.feature ./tests/e2e/sms/features/
cp sinch-sdk-mockserver/features/sms/batches_servicePlanId.feature ./tests/e2e/sms/features/
cp sinch-sdk-mockserver/features/sms/webhooks.feature ./tests/e2e/sms/features/
cp sinch-sdk-mockserver/features/sms/* ./tests/e2e/sms/features/
cp sinch-sdk-mockserver/features/number-lookup/lookups.feature ./tests/e2e/number-lookup/features/
cp sinch-sdk-mockserver/features/conversation/messages.feature ./tests/e2e/conversation/features/
cp sinch-sdk-mockserver/features/conversation/webhooks-events.feature ./tests/e2e/conversation/features/
Expand Down
82 changes: 82 additions & 0 deletions .github/workflows/documentation-upload.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: Generate and upload documentation

on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: 'Version to use for the documentation package in semver format (e.g. 1.2.3)'
required: true

jobs:
upload-documentation:
runs-on: ubuntu-latest
env:
SDK_NAME: sinch-sdk-python

steps:
- name: Checkout Code
uses: actions/checkout@v4

- name: Resolve Version
id: version
run: |
if [ "${{ github.event_name }}" = "release" ]; then
VERSION="${{ github.event.release.tag_name }}"
else
VERSION="${{ inputs.version }}"
fi
# Strip leading 'v' if present (e.g. v1.2.3 → 1.2.3)
VERSION="${VERSION#v}"
echo "value=${VERSION}" >> "$GITHUB_OUTPUT"

- name: Validate Version Format
run: |
VERSION="${{ steps.version.outputs.value }}"
SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((alpha|beta|preview)(\.[0-9]+)?))?$'
if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then
echo "::error::Invalid version format: '$VERSION'. Expected semver (e.g. 1.2.3, 1.2.3-alpha, 1.2.3-beta.1, 1.2.3-preview)"
exit 1
fi
echo "Version '$VERSION' is valid"

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install dev dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt

- name: Generate Documentation
run: |
make docs

- name: Package Documentation
run: |
cd docs/build/html
zip -r "../../../${{ env.SDK_NAME }}-${{ steps.version.outputs.value }}.zip" .

- name: Upload to GitLab Registry
run: |
echo "Uploading documentation package to GitLab Registry..."
VERSION="${{ steps.version.outputs.value }}"
curl --fail --show-error --location --header "PRIVATE-TOKEN: ${{ secrets.GITLAB_REGISTRY_UPLOAD_DOC_TOKEN }}" \
--upload-file "./${{ env.SDK_NAME }}-${VERSION}.zip" \
"https://gitlab.com/api/v4/projects/63164411/packages/generic/${{ env.SDK_NAME }}/${VERSION}/${{ env.SDK_NAME }}-${VERSION}.zip"
echo "Documentation package for version ${VERSION} uploaded to GitLab Registry"

- name: Trigger Downstream GitLab Pipeline
run: |
echo "Triggering downstream GitLab pipeline to notify about new documentation package version..."
VERSION="${{ steps.version.outputs.value }}"
curl --fail --show-error --location --request POST \
--form "token=${{ secrets.GITLAB_NOTIFY_REGISTRY_UPLOADED_DOC_TOKEN }}" \
--form "ref=main" \
--form "variables[UPSTREAM_PACKAGE_NAME]=${{ env.SDK_NAME }}" \
--form "variables[UPSTREAM_PACKAGE_VERSION]=${VERSION}" \
"https://gitlab.com/api/v4/projects/63164411/trigger/pipeline"
echo "Documentation repo notified about new package version ${VERSION}"
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ coverage.xml
.hypothesis/
.pytest_cache/
cover/
lcov.info

# E2E features
*.feature
Expand All @@ -73,7 +74,9 @@ instance/
.scrapy

# Sphinx documentation
docs/_build/
docs/build/
docs/api/


# PyBuilder
.pybuilder/
Expand Down
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,29 @@ All notable changes to the **Sinch Python SDK** are documented in this file.

---

## v2.0.1 – 2026-06-02
## v2.1.0 – 2026-06-05

### SDK

- **[dependency]** Set up minimum version for `requests` to `>=2.0.0` to prevent pulling in versions with known vulnerabilities (#152).
- **[fix]** Fixed a race condition in OAuth token creation and renewal under concurrent requests: `TokenManagerBase` now uses a lock with double-checked locking so the initial token is fetched exactly once, and a new `refresh_auth_token(used_token)` deduplicates concurrent renewals by only fetching when the stale token still matches the cached one (#156).
- **[refactor]** `HTTPTransport` now prepares and authenticates requests in `request()`, so the new `send_request(request_data)` receives an already-prepared `HttpRequest` and acts as a pure I/O primitive, simplifying subclassing (#156).
- **[deprecation notice]** `HTTPTransport.send(endpoint)` is deprecated in favour of `send_request(request_data)`; the legacy method still works for backward compatibility, but will be removed in 3.0 (#156).
- **[deprecation notice]** `TokenManagerBase.invalidate_expired_token()` and `handle_invalid_token()` (and the `TokenState.EXPIRED` value) are deprecated and will be removed in 3.0, as token renewal now goes through `refresh_auth_token()` (#156).
- **[tech]** Removed unused GitHub environment secrets from CI workflow and simplified test fixtures to use hardcoded test values (#162).
- **[doc]** Improve README structure and content(#155).


### SMS

- **[feature]** SMS Groups API: `create`, `list`, `get`, `update`, `replace`, `delete`, and `list_members` operations, with full model, endpoint, and unit test coverage (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md#groups-api)).
- **[feature]** SMS Inbounds API: `get` and `list` operations, with full model, endpoint, and unit test coverage (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md#inbounds-api)).
- **[design]** SMS Sinch Events inbound payload models unified with the Inbounds API: `MOTextSinchEvent`, `MOBinarySinchEvent`, `MOMediaSinchEvent`, `MediaBody`, and `MediaItem` removed from `sinch_events`; use `InboundMessage` (and its variants) from `sinch.domains.sms.models.v1.types` instead (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md#sms-sinch-events)).

---

## v2.0.1 – 2026-06-02

### SMS

- **[fix]** SMS paginator fix (#145).
Expand Down
72 changes: 68 additions & 4 deletions MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Sinch Python SDK Migration Guide

## 2.0.0
## 2.1.0

This release removes legacy SDK support.

Expand Down Expand Up @@ -205,9 +205,7 @@ The Conversation HTTP API still expects the JSON field **`callback_url`**. In V2

#### Replacement APIs

The SMS domain API access remains the same: `sinch.sms.batches` and `sinch.sms.delivery_reports`. However, the underlying models and method signatures have changed.

Note that `sinch.sms.groups` and `sinch.sms.inbounds` are not supported yet and will be available in future minor versions.
The SMS domain API access remains the same: `sinch.sms.batches`, `sinch.sms.delivery_reports`, `sinch.sms.inbounds` and `sinch.sms.groups`. However, the underlying models and method signatures have changed. See the sections below for the full list of changes: [Batches](#batches-api), [Delivery Reports](#delivery-reports-api), [Groups](#groups-api), [Inbounds](#inbounds-api).

##### Batches API

Expand All @@ -230,6 +228,71 @@ Note that `sinch.sms.groups` and `sinch.sms.inbounds` are not supported yet and
| `get_for_batch()` with `GetSMSDeliveryReportForBatchRequest` | `get()` with `batch_id: str` and optional parameters: `report_type`, `status`, `code`, `client_reference` |
| `get_for_number()` with `GetSMSDeliveryReportForNumberRequest` | `get_for_number()` with `batch_id: str` and `recipient: str` parameters |

##### Groups API

###### Replacement models

| Old class | New class |
|-----------|-----------|
| `sinch.domains.sms.models.groups.requests.CreateSMSGroupRequest` | [`sinch.domains.sms.models.v1.internal.GroupRequest`](sinch/domains/sms/models/v1/internal/group_request.py) |
| `sinch.domains.sms.models.groups.requests.ListSMSGroupRequest` | [`sinch.domains.sms.models.v1.internal.ListGroupsRequest`](sinch/domains/sms/models/v1/internal/list_groups_request.py) |
| `sinch.domains.sms.models.groups.requests.GetSMSGroupRequest` | [`sinch.domains.sms.models.v1.internal.GroupIdRequest`](sinch/domains/sms/models/v1/internal/group_id_request.py) |
| `sinch.domains.sms.models.groups.requests.DeleteSMSGroupRequest` | [`sinch.domains.sms.models.v1.internal.GroupIdRequest`](sinch/domains/sms/models/v1/internal/group_id_request.py) |
| `sinch.domains.sms.models.groups.requests.GetSMSGroupPhoneNumbersRequest` | [`sinch.domains.sms.models.v1.internal.GroupIdRequest`](sinch/domains/sms/models/v1/internal/group_id_request.py) |
| `sinch.domains.sms.models.groups.requests.UpdateSMSGroupRequest` | [`sinch.domains.sms.models.v1.internal.UpdateGroupRequest`](sinch/domains/sms/models/v1/internal/update_group_request.py) |
| `sinch.domains.sms.models.groups.requests.ReplaceSMSGroupPhoneNumbersRequest` | [`sinch.domains.sms.models.v1.internal.ReplaceGroupRequest`](sinch/domains/sms/models/v1/internal/replace_group_request.py) |
| `sinch.domains.sms.models.groups.responses.CreateSMSGroupResponse` | [`sinch.domains.sms.models.v1.response.GroupResponse`](sinch/domains/sms/models/v1/response/group_response.py) |
| `sinch.domains.sms.models.groups.responses.GetSMSGroupResponse` | [`sinch.domains.sms.models.v1.response.GroupResponse`](sinch/domains/sms/models/v1/response/group_response.py) |
| `sinch.domains.sms.models.groups.responses.UpdateSMSGroupResponse` | [`sinch.domains.sms.models.v1.response.GroupResponse`](sinch/domains/sms/models/v1/response/group_response.py) |
| `sinch.domains.sms.models.groups.responses.ReplaceSMSGroupResponse` | [`sinch.domains.sms.models.v1.response.GroupResponse`](sinch/domains/sms/models/v1/response/group_response.py) |
| `sinch.domains.sms.models.groups.responses.SinchListSMSGroupResponse` | [`sinch.domains.sms.models.v1.response.ListGroupsResponse`](sinch/domains/sms/models/v1/response/list_groups_response.py) |
| `sinch.domains.sms.models.groups.responses.SinchGetSMSGroupPhoneNumbersResponse` | [`sinch.domains.sms.models.v1.response.ListGroupMembersResponse`](sinch/domains/sms/models/v1/response/list_group_members_response.py) |
| `sinch.domains.sms.models.groups.responses.SinchDeleteSMSGroupResponse` | `None` (method returns `None`) |

###### Replacement APIs

| Old method | New method in `sms.groups` |
|------------|---------------------------|
| `create()` with `CreateSMSGroupRequest` | `create()` with individual parameters: `name`, `members`, `child_groups`, `auto_update` |
| `list()` with `ListSMSGroupRequest` | `list()` with individual parameters: `page`, `page_size`. Returns **`Paginator[GroupResponse]`** |
| `get()` with `GetSMSGroupRequest` | `get()` with `group_id: str` parameter |
| `update()` with `UpdateSMSGroupRequest` | `update()` with `group_id: str` and optional parameters: `add`, `remove`, `name`, `add_from_group`, `remove_from_group`, `auto_update` |
| `replace()` with `ReplaceSMSGroupPhoneNumbersRequest` | `replace()` with `group_id: str` and optional parameters: `name`, `members`, `child_groups`, `auto_update` |
| `delete()` with `DeleteSMSGroupRequest` | `delete()` with `group_id: str` parameter |
| `get_phone_numbers()` / phone number listing | `list_members()` with `group_id: str`. Returns **`Paginator[str]`** |

##### Inbounds API

###### Replacement models

| Old class | New class |
|-----------|-----------|
| `sinch.domains.sms.models.inbounds.requests.ListSMSInboundMessageRequest` | [`sinch.domains.sms.models.v1.internal.ListInboundsRequest`](sinch/domains/sms/models/v1/internal/list_inbounds_request.py) |
| `sinch.domains.sms.models.inbounds.requests.GetSMSInboundMessageRequest` | [`sinch.domains.sms.models.v1.internal.InboundIdRequest`](sinch/domains/sms/models/v1/internal/inbound_id_request.py) |
| `sinch.domains.sms.models.inbounds.responses.SinchListInboundMessagesResponse` | [`sinch.domains.sms.models.v1.internal.ListInboundsResponse`](sinch/domains/sms/models/v1/internal/list_inbounds_response.py) |
| `sinch.domains.sms.models.inbounds.responses.GetInboundMessagesResponse` | [`sinch.domains.sms.models.v1.types.InboundMessage`](sinch/domains/sms/models/v1/types/inbound_message.py) (Union of `MOTextMessage`, `MOBinaryMessage`, `MOMediaMessage`) |

###### Replacement APIs

| Old method | New method in `sms.inbounds` |
|------------|------------------------------|
| `list()` with `ListSMSInboundMessageRequest` | `list()` with individual parameters: `page`, `page_size`, `to`, `start_date`, `end_date`, `client_reference`. Returns **`Paginator[InboundMessage]`** |
| `get()` with `GetSMSInboundMessageRequest` | `get()` with `inbound_id: str` parameter |

##### SMS Sinch Events

The inbound payload models in `sinch_events` have been unified with the Inbounds API models. The following classes have been removed:

| Removed class | Replacement |
|---------------|-------------|
| `sinch.domains.sms.sinch_events.v1.events.MOTextSinchEvent` | [`sinch.domains.sms.models.v1.shared.MOTextMessage`](sinch/domains/sms/models/v1/shared/mo_text_message.py) |
| `sinch.domains.sms.sinch_events.v1.events.MOBinarySinchEvent` | [`sinch.domains.sms.models.v1.shared.MOBinaryMessage`](sinch/domains/sms/models/v1/shared/mo_binary_message.py) |
| `sinch.domains.sms.sinch_events.v1.events.MOMediaSinchEvent` | [`sinch.domains.sms.models.v1.shared.MOMediaMessage`](sinch/domains/sms/models/v1/shared/mo_media_message.py) |
| `sinch.domains.sms.sinch_events.v1.events.MediaBody` | Embedded in `MOMediaMessage` |
| `sinch.domains.sms.sinch_events.v1.events.MediaItem` | Embedded in `MOMediaMessage` |

`IncomingSMSSinchEvent` is now a type alias for [`InboundMessage`](sinch/domains/sms/models/v1/types/inbound_message.py) (discriminated union of `MOTextMessage`, `MOBinaryMessage`, `MOMediaMessage`). Code that previously type-checked against `MOTextSinchEvent` and siblings should switch to their `MO*Message` equivalents.

---

### [`Numbers` (Virtual Numbers)](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/numbers)
Expand All @@ -244,6 +307,7 @@ Note that `sinch.sms.groups` and `sinch.sms.inbounds` are not supported yet and

##### Replacement models


| Old class | New class |
|-----------|-----------|
| `UpdateNumbersCallbackConfigurationRequest` | `UpdateEventDestinationRequest` |
Expand Down
21 changes: 21 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.PHONY: docs

docs:
rm -rf docs/build
rm -rf docs/api
sphinx-apidoc --force --separate --no-toc --maxdepth 2 \
--templatedir docs/_templates/apidoc -o docs/api sinch \
"sinch/core/models/base_model.py" \
"sinch/core/models/utils.py" \
"sinch/core/deserializers.py" \
"sinch/core/endpoint.py" \
"sinch/core/enums.py" \
"sinch/core/types.py" \
"sinch/domains/sms/enums.py" \
"sinch/*/internal" "sinch/*/internal/*" \
"sinch/*/api/v1/base" "sinch/*/api/v1/base/*" \
"sinch/*/api/v1/utils" "sinch/*/api/v1/utils/*" \
"sinch/domains/authentication/endpoints" "sinch/domains/authentication/endpoints/*" \
"sinch/domains/authentication/sinch_events" "sinch/domains/authentication/sinch_events/*" \
"sinch/domains/numbers/models/v1/utils" "sinch/domains/numbers/models/v1/utils/*"
sphinx-build -b html docs docs/build/html
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ The following example replaces the default `requests` backend with `httpx` and r
import httpx
from sinch import SinchClient
from sinch.core.ports.http_transport import HTTPTransport
from sinch.core.endpoint import HTTPEndpoint
from sinch.core.models.http_request import HttpRequest
from sinch.core.models.http_response import HTTPResponse


Expand All @@ -266,9 +266,7 @@ class MyHTTPImplementation(HTTPTransport):
proxy=f"http://{proxy_user}:{proxy_password}@{proxy_url}"
)

def send(self, endpoint: HTTPEndpoint) -> HTTPResponse:
request_data = self.prepare_request(endpoint)
request_data = self.authenticate(endpoint, request_data)
def send_request(self, request_data: HttpRequest) -> HTTPResponse:

body = request_data.request_body
response = self.http_client.request(
Expand Down
21 changes: 21 additions & 0 deletions docs/_static/custom.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/* ---------------------------------------------------------------------------
Multi-line signatures (one parameter per line).

When a signature is wrapped, Sphinx renders each parameter as a <dd> inside
a nested <dl>. The Read the Docs theme styles every <dl>/<dd> with borders,
background tint and vertical margins, which leak into the signature and draw
ugly "lines" between parameters. Flatten that nested list so the parameters
read as a clean indented column.
--------------------------------------------------------------------------- */
.rst-content .sig dl,
.rst-content .sig dd {
margin: 0;
padding: 0;
border: none;
background: none;
}

/* Keep each parameter indented under the opening parenthesis. */
.rst-content .sig dd {
margin-left: 2em;
}
8 changes: 8 additions & 0 deletions docs/_templates/apidoc/module.rst.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{%- if show_headings %}
{{- [basename, "module"] | join(" ") | e | heading }}

{% endif -%}
.. automodule:: {{ qualname }}
{%- for option in automodule_options %}
:{{ option }}:
{%- endfor %}
39 changes: 39 additions & 0 deletions docs/_templates/apidoc/package.rst.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{%- macro automodule(modname, options) -%}
.. automodule:: {{ modname }}
{%- for option in options %}
:{{ option }}:
{%- endfor %}
{%- endmacro %}

{%- macro toctree(docnames) -%}
.. toctree::
:maxdepth: {{ maxdepth }}
{% for docname in docnames %}
{{ docname }}
{%- endfor %}
{%- endmacro %}

{{- [pkgname, "package"] | join(" ") | e | heading }}

{%- if is_namespace %}
.. py:module:: {{ pkgname }}
{% endif %}

{%- if subpackages %}
{{ toctree(subpackages) }}
{% endif %}

{%- if submodules %}
{% if separatemodules %}
{{ toctree(submodules) }}
{% else %}
{% for submodule in submodules %}
{{ [submodule.split(".")[-1], "module"] | join(" ") | e | heading(2) }}
{{ automodule(submodule, automodule_options) }}
{% endfor %}
{%- endif %}
{%- endif %}

{%- if not is_namespace %}
{{ automodule(pkgname, automodule_options) }}
{% endif %}
Loading
Loading