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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
13 changes: 11 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ jobs:
python-version: ${{ matrix.python-version }}
- uses: astral-sh/setup-uv@v6
- run: uv sync --frozen
- run: uv run python scripts/check_openapi.py
- run: uv run ruff check .
- run: uv run mypy .
- run: uv run pytest
- run: uv run ruff format --check .
- run: uv run mypy
- run: uv run pyright
- run: uv run pytest tests/unit -q
- name: Check contract feature bindings
run: |
chmod 600 tests/fixtures/sdk-contract-dry-run.json
VOLCANO_SDK_CONTRACT_FIXTURE="$PWD/tests/fixtures/sdk-contract-dry-run.json" \
uv run behave features/contract --dry-run --no-snippets
- run: uv run python -m build
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ __pycache__/
build/
dist/
*.egg-info/
reports/
78 changes: 77 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,79 @@
# Volcano Python SDK

Official Python SDK for Volcano.
This private proof of concept validates Volcano's Python SDK contract. It is not
published to PyPI and is not ready for production use.

## Try the contract facade

The REST facade is synchronous. Create one client, sign in, and use the stored
session for database and storage requests. Configure a service key for locks.

```python
from volcano_sdk import VolcanoClient

client = VolcanoClient(
api_url="https://api.volcano.dev",
anon_key="your-anon-key",
service_key="your-service-key",
)

session = client.auth.sign_in(email="user@example.com", password="secret")

rows = client.database("main").from_("items").select("*").eq("slug", "a").execute()

bucket = client.storage.from_("assets")
bucket.upload("a.txt", b"hello")
downloaded = bucket.download("a.txt")
assert downloaded == b"hello"

lease = client.locks.acquire("build", ttl=30)
client.locks.release("build", lease)
```

Realtime is async. Channels wrap `centrifuge-python`; the underlying client and
subscription objects are not part of the public API.

```python
channel = client.realtime.channel("updates")
channel.on("message", print)

await channel.subscribe()
await channel.send({"event": "message", "value": "contract"})
await channel.unsubscribe()
await client.realtime.disconnect()
```

## Compatibility

The POC supports Python 3.11 and 3.14. Its public facade is intentionally
independent of generated httpx types. Compatibility is verified against the
bundled Volcano API contract from hosting commit
`a3f4a6e9d0fb48a16621383bd796d8b0d1378630`; `openapi/openapi.yaml` has SHA-256
`c26ab2f32961699b19f710c1174906b7baae077eefcec299a6c19a36d2f559f6`.

The realtime wrapper includes a narrow compatibility adapter for Volcano's
project-prefixed publication channels. It still delegates connection,
subscription, publish, and disconnect behavior to `centrifuge-python` 0.6.

## Develop locally

```shell
uv sync --frozen
uv run python scripts/check_openapi.py
uv run ruff check .
uv run ruff format --check .
uv run mypy
uv run pyright
uv run pytest tests/unit -q
uv run python -m build
```

Live contract scenarios require an isolated fixture produced by
`volcano-hosting/tests/sdk-contract/support/fixture.mjs`:

```shell
VOLCANO_SDK_CONTRACT_FIXTURE=/absolute/path/to/fixture.json \
uv run behave features/contract --junit --junit-directory reports/behave
```

The fixture must be an absolute path to a mode-`0600` JSON file.
9 changes: 9 additions & 0 deletions features/contract/auth.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Feature: SDK authentication contract

@auth @SDK-AUTH-001
Scenario: Successful password sign-in creates a usable session
Given the confirmed contract user
When the client signs in with the contract user's credentials
Then the SDK operation succeeds
And the current session belongs to the contract user
And the current session exposes access and refresh tokens
8 changes: 8 additions & 0 deletions features/contract/database.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Feature: SDK database contract

@database @SDK-DB-001
Scenario: Equality filters select the matching row
Given an authenticated client
When the client selects the contract table where "slug" equals the fixture slug
Then the SDK operation succeeds
And exactly the fixture row is returned
8 changes: 8 additions & 0 deletions features/contract/locks.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Feature: SDK lock contract

@locks @SDK-LOCKS-001
Scenario: An acquired lock can be released
Given a service-role client
When the client acquires and releases the contract lock
Then the SDK operation succeeds
And the released lease is no longer held
8 changes: 8 additions & 0 deletions features/contract/realtime.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Feature: SDK realtime contract

@realtime @SDK-REALTIME-001
Scenario: Broadcast reaches another subscribed client
Given two authenticated realtime clients
When one client subscribes and the other publishes the contract message
Then the SDK operation succeeds
And the subscriber receives the contract message within 10 seconds
9 changes: 9 additions & 0 deletions features/contract/storage.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Feature: SDK storage contract

@storage @SDK-STORAGE-001
Scenario: Uploaded bytes can be downloaded unchanged
Given an authenticated client
When the client uploads and downloads the contract object
Then the SDK operation succeeds
And the downloaded bytes equal the uploaded bytes
And the stored object path equals the contract path
167 changes: 167 additions & 0 deletions features/contract_support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
from __future__ import annotations

import asyncio
import os
import secrets
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

from centrifuge import CentrifugeError

from volcano_sdk import (
AuthenticationError,
ConflictError,
NotFoundError,
RateLimitedError,
ServerError,
TransportError,
ValidationError,
VolcanoClient,
VolcanoError,
)

if TYPE_CHECKING:
from collections.abc import Awaitable, Callable

from volcano_sdk.models import LockLease
from volcano_sdk.realtime import Channel

HTTP_NOT_FOUND = 404
HTTP_CONFLICT = 409
HTTP_RATE_LIMITED = 429
HTTP_SERVER_ERROR_MIN = 500
HTTP_SERVER_ERROR_MAX = 599

CONTRACT_EXCEPTIONS = (
CentrifugeError,
KeyError,
OSError,
RuntimeError,
TypeError,
ValueError,
VolcanoError,
)


@dataclass(frozen=True, slots=True)
class Outcome:
ok: bool
value: Any = None
category: str | None = None
error: Exception | None = None


def classify_error(error: Exception) -> str:
categories = (
(AuthenticationError, "authentication error"),
(ValidationError, "validation error"),
(NotFoundError, "not found"),
(ConflictError, "conflict"),
(RateLimitedError, "rate limited"),
(ServerError, "server error"),
(TransportError, "transport error"),
)
matched_category = next(
(
category
for error_type, category in categories
if isinstance(error, error_type)
),
None,
)
if matched_category is not None:
return matched_category
if isinstance(error, VolcanoError):
status = error.status
if status in (401, 403):
return "authentication error"
if status in (400, 422):
return "validation error"
category_by_status = {
HTTP_NOT_FOUND: "not found",
HTTP_CONFLICT: "conflict",
HTTP_RATE_LIMITED: "rate limited",
}
if status in category_by_status:
return category_by_status[status]
if (
status is not None
and HTTP_SERVER_ERROR_MIN <= status <= HTTP_SERVER_ERROR_MAX
):
return "server error"
return "transport error"


class ContractWorld:
def __init__(self, fixture: dict[str, Any]) -> None:
self.fixture = fixture
self.client = VolcanoClient(
api_url=fixture["api_url"],
anon_key=fixture["anon_key"],
)
self.service_client = VolcanoClient(
api_url=fixture["api_url"],
anon_key=fixture["anon_key"],
service_key=fixture["service_key"],
)
suffix = f"py-{os.getpid()}-{secrets.token_hex(5)}"
self.storage_path = f"{fixture['storage_path']}.{suffix}"
self.realtime_channel = f"{fixture['realtime_channel']}-{suffix}"
self.lock_key = f"{fixture['lock_key']}-{suffix}"
self.storage_bytes = f"volcano-sdk-contract-{suffix}".encode()
self.realtime_message = {
"event": "message",
"value": f"volcano-sdk-contract-{suffix}",
}
self.last_outcome: Outcome | None = None
self.subscriber: Channel | None = None
self.publisher: Channel | None = None
self.realtime_clients: list[VolcanoClient] = []
self.cleanup_callbacks: list[Callable[[], None]] = []
self.loop = asyncio.new_event_loop()

def authenticate(self) -> None:
self.client.auth.sign_in(
email=self.fixture["user_email"],
password=self.fixture["user_password"],
)

def run(self, operation: Awaitable[Any]) -> Any:
return self.loop.run_until_complete(operation)

def record(self, operation: Callable[[], Any]) -> Outcome:
try:
self.last_outcome = Outcome(ok=True, value=operation())
except CONTRACT_EXCEPTIONS as error:
self.last_outcome = Outcome(
ok=False,
category=classify_error(error),
error=error,
)
return self.last_outcome

def register_lock_cleanup(self, key: str, lease: LockLease) -> Callable[[], None]:
def release() -> None:
self.service_client.locks.release(key, lease)

self.cleanup_callbacks.append(release)
return release

def cleanup(self) -> None:
failures: list[Exception] = []
for callback in reversed(self.cleanup_callbacks):
try:
callback()
except CONTRACT_EXCEPTIONS as error:
failures.append(error)
for client in self.realtime_clients:
try:
self.run(client.realtime.disconnect())
except CONTRACT_EXCEPTIONS as error:
failures.append(error)
self.cleanup_callbacks.clear()
self.realtime_clients.clear()
self.loop.close()
if failures:
message = "Python contract cleanup failed"
raise ExceptionGroup(message, failures)
44 changes: 44 additions & 0 deletions features/environment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from __future__ import annotations

import json
import os
import stat
from pathlib import Path
from typing import Any

from contract_support import ContractWorld

FIXTURE_MODE = 0o600
FIXTURE_ABSOLUTE_PATH_ERROR = "VOLCANO_SDK_CONTRACT_FIXTURE must be an absolute path"
FIXTURE_MODE_ERROR = "VOLCANO_SDK_CONTRACT_FIXTURE must have mode 0600"
FIXTURE_OBJECT_ERROR = "VOLCANO_SDK_CONTRACT_FIXTURE must contain a JSON object"
FIXTURE_REQUIRED_ERROR = "VOLCANO_SDK_CONTRACT_FIXTURE is required"


def load_fixture(path: Path) -> dict[str, Any]:
if not path.is_absolute():
raise ValueError(FIXTURE_ABSOLUTE_PATH_ERROR)
mode = stat.S_IMODE(path.stat().st_mode)
if mode != FIXTURE_MODE:
raise PermissionError(FIXTURE_MODE_ERROR)
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise TypeError(FIXTURE_OBJECT_ERROR)
return value


def before_all(context: Any) -> None:
fixture_path = os.environ.get("VOLCANO_SDK_CONTRACT_FIXTURE")
if fixture_path is None:
raise RuntimeError(FIXTURE_REQUIRED_ERROR)
context.contract_fixture = load_fixture(Path(fixture_path))


def before_scenario(context: Any, scenario: Any) -> None:
del scenario
context.contract = ContractWorld(context.contract_fixture)


def after_scenario(context: Any, scenario: Any) -> None:
del scenario
context.contract.cleanup()
Loading
Loading