Skip to content
Merged
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
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
lint:
name: Lint & Format
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
python-version: "3.14"
enable-cache: true

- name: Install dependencies
run: |
uv sync --group dev
uv pip install -e .

- name: Run ruff check
run: uv run ruff check src/ tests/

- name: Run ruff format check
run: uv run ruff format --check src/ tests/

test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
python-version: "3.14"
enable-cache: true

- name: Install dependencies
run: |
uv sync --group dev
uv pip install -e .

- name: Run pytest with coverage
run: uv run pytest --cov=src/pubsub_sdk_python --cov-branch --cov-report=term-missing --cov-fail-under=85
34 changes: 34 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Integration Tests

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
integration:
name: Integration Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
python-version: "3.14"
enable-cache: true

- name: Install dependencies
run: |
uv sync --group dev
uv pip install -e .

- name: Run integration tests
env:
SA_KEY_B64: ${{ secrets.SA_KEY_B64 }}
PROJECT_ID: ${{ secrets.PROJECT_ID }}
TOKEN_URL: ${{ secrets.TOKEN_URL }}
REGION: ${{ secrets.REGION }}
run: uv run pytest tests/integration/ -v --timeout=700 --no-cov
75 changes: 75 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: Release

on:
release:
types: [published]

jobs:
test:
name: Lint & Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
python-version: "3.14"
enable-cache: true

- name: Install dependencies
run: |
uv sync --group dev
uv pip install -e .

- name: Run ruff check
run: uv run ruff check src/ tests/

- name: Run ruff format check
run: uv run ruff format --check src/ tests/

- name: Run pytest with coverage
run: uv run pytest --cov=src/pubsub_sdk_python --cov-branch --cov-report=term-missing --cov-fail-under=85

build:
name: Build distribution
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
python-version: "3.14"
enable-cache: true

- name: Build sdist and wheel
run: uv build

- name: Upload distribution artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/

publish:
name: Publish to PyPI
needs: [test, build]
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/pubsub-sdk-python/
permissions:
id-token: write
steps:
- name: Download distribution artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
20 changes: 20 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info

# Virtual environments
.venv

# Idea
.idea

# Misc
*.rst
.coverage

# Service account keys
*.json
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.14
18 changes: 18 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
install:
uv sync --group dev
uv pip install -e .

lint:
uvx ruff@0.16.4 check

format:
uvx ruff@0.16.4 format

check: lint format
uvx ruff@0.16.4 format --check

test: check install
uv run pytest

test-cov: check install
uv run pytest --cov=src/pubsub_sdk_python --cov-branch --cov-report=term-missing --cov-fail-under=85
96 changes: 96 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# pubsub-sdk-python

Python SDK for the **STACKIT PubSub Data Plane API**.

This SDK covers the **data plane only** (publish, pull, ack, nack). Creating/deleting topics and subscriptions, and granting publisher/subscriber access, is done via the separate STACKIT PubSub **control plane** REST API (see `tests/integration/conftest.py` for example calls) or the STACKIT Portal.

## Install

Not published to PyPI yet — every GitHub Release publishes `pubsub-sdk-python` to PyPI automatically (see `.github/workflows/release.yml`), so once a release is cut:

```bash
pip install pubsub-sdk-python
# or: uv add pubsub-sdk-python
```

Until then, install from source with [uv](https://docs.astral.sh/uv/):

```bash
uv sync --group dev
uv pip install -e .
```

Requires Python >= 3.11. Runtime dependencies: `httpx`, `stackit-authorization`.

## Auth

Authentication goes through `AuthConfig` + `StackitAuthProvider`, backed by a STACKIT service account key.

```python
from pubsub_sdk_python import AuthConfig, PubSubClient

auth_config = AuthConfig(
service_account_key=open("sa-key.json").read(), # or the raw JSON string
token_endpoint="https://authorization.stackit.cloud/auth/token", # default shown
)
```

`AuthConfig` fields: `service_account_key`, `token_endpoint` (default `https://authorization.stackit.cloud/auth/token`), `client_id`, `client_secret`, `scope` (default `"dataplane"`). Tokens are fetched via `stackit-authorization` and cached until ~60s before expiry.

## Quickstart

```python
import asyncio
from pubsub_sdk_python import PubSubClient, PublishMessage


async def main():
client = PubSubClient(topic_id="<topic-id>", auth_config=auth_config)

async with client.get_publisher() as publisher:
response = await publisher.publish([PublishMessage.from_plain("hello")])
print(response.message_ids)

async with client.get_subscriber(subscription_id="<subscription-id>") as subscriber:
messages = await subscriber.pull(max_messages=10)
for msg in messages:
print(msg.text) # base64-decoded payload
await subscriber.ack([msg.ack_id for msg in messages])
# or: await subscriber.ack_messages(messages)


asyncio.run(main())
```

`client.base_url` defaults to `https://{topic_id}.pubsub.eu01.onstackit.cloud`; pass `base_url=` to `PubSubClient(...)` to override (e.g. for other regions/environments).

`PublishMessage.from_plain(str)` / `.from_bytes(bytes)` base64-encode the payload. `Message.payload` (bytes) / `Message.text` (str) base64-decode it back.

Both `PubSubPublisher` and `PubSubSubscriber` support `async with`, and expose call counters: `publisher.messages_sent` / `.publish_calls` / `.purge_calls` / `.messages_purged`, `subscriber.messages_received` / `.messages_acked` / `.messages_nacked` / `.pull_calls` / `.ack_calls` / `.nack_calls`.

Other methods: `publisher.publish_batch(messages, batch_size=100)`, `publisher.purge_topic()` / `.purge_topic_with_confirm("purge")`, `subscriber.pull_stream(on_message=callback)`, `subscriber.nack(...)` / `.nack_messages(...)`.

## Errors

All exceptions subclass `PubSubError`:

| Exception | Raised when |
|-----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `PubSubValidationError` | Bad request (HTTP 400/413), or local validation (empty message list, >100 messages, `max_messages`/`long_pull_duration_ms` out of range, empty ack/nack list) |
| `PubSubNotFoundError` | HTTP 404 |
| `PubSubAuthenticationError` | HTTP 401/403, or token acquisition failure |
| `PubSubServerError` | HTTP 5xx (transient - triggers a retry, see below) |
| `PubSubRetryError` | All retry attempts exhausted; `.causes` holds the underlying exceptions |
| `PubSubTimeoutError` | Request timed out (also a `TimeoutError`) |

## Retries

`PubSubPublisher`/`PubSubSubscriber` retry automatically on network/timeout errors (`httpx.TimeoutException`, `httpx.NetworkError`, `OSError`, `ConnectionError`, `TimeoutError`) and HTTP 500/502/503/504, with exponential backoff + jitter. Configure via `retry_config=RetryConfig(...)` passed to the publisher/subscriber constructor (or `get_publisher()`/`get_subscriber()` kwargs):

```python
from pubsub_sdk_python import RetryConfig

retry_config = RetryConfig(max_retries=3, base_delay=0.5, max_delay=30.0, multiplier=2.0)
```

400/401/404/413 are never retried. The standalone decorators `with_retry`/`with_async_retry` (in `pubsub_sdk_python.retry`) apply the same policy to arbitrary functions.
Loading
Loading