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
11 changes: 11 additions & 0 deletions pocketstation/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,12 @@
"""PocketStation Python SDK. Phase 5."""
from .client import PocketStation, PocketStationSession
from .types import IceServer, PocketStationError, RoomCredentials

__version__ = "0.1.0"
__all__ = [
"PocketStation",
"PocketStationSession",
"RoomCredentials",
"IceServer",
"PocketStationError",
]
137 changes: 137 additions & 0 deletions pocketstation/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""
PocketStation Python async client SDK.

Phase scope: Phase 5.

Usage::

import asyncio
from pocketstation import PocketStation

async def main():
async with PocketStation.connect(
api_url="https://api.pocketstation.io",
relay_url="wss://relay.pocketstation.io",
) as session:
print(f"Connected to room {session.room_id}")
creds = session.credentials
# Use creds.source_token / creds.listener_token for signaling

asyncio.run(main())
"""
from __future__ import annotations

import json
from contextlib import asynccontextmanager
from typing import AsyncGenerator, Optional

import httpx

from .types import IceServer, PocketStationError, RoomCredentials


class PocketStationSession:
"""
Active PocketStation session.

Invariant: valid only within the ``async with PocketStation.connect()`` block.
Ownership: created and owned by PocketStation.connect(); do not instantiate directly.
Failure behavior: network errors raise PocketStationError.

Phase 5: HTTP room creation implemented. WebSocket signaling and WebRTC
publish/subscribe wiring are Phase 5 follow-up (requires native WebRTC
Python binding or aiortc).
"""

def __init__(self, credentials: RoomCredentials) -> None:
self._credentials = credentials

@property
def credentials(self) -> RoomCredentials:
"""Room credentials including TURN servers (ADR-023)."""
return self._credentials

@property
def room_id(self) -> str:
return self._credentials.room_id

@property
def source_token(self) -> str:
return self._credentials.source_token

@property
def listener_token(self) -> str:
return self._credentials.listener_token

@property
def ice_servers(self) -> list[IceServer]:
"""TURN/STUN servers for WebRTC PeerConnection config."""
return self._credentials.ice_servers


class PocketStation:
"""
PocketStation async context manager.

Creates a room on enter, cleans up on exit.
"""

@staticmethod
@asynccontextmanager
async def connect(
*,
api_url: str,
relay_url: str,
credentials: Optional[RoomCredentials] = None,
) -> AsyncGenerator[PocketStationSession, None]:
"""
Async context manager that creates a PocketStation session.

On entry: creates a new room via POST /v1/rooms (or uses provided credentials).
On exit: releases the session (future: sends LEAVE, closes WebSocket).

:param api_url: Base URL of the api-server.
:param relay_url: Base URL of the relay (wss://...).
:param credentials: Pre-obtained credentials. If None, a new room is created.
:raises PocketStationError: on network or protocol failure.
"""
if credentials is None:
credentials = await PocketStation._create_room(api_url)

session = PocketStationSession(credentials)
try:
yield session
finally:
# Phase 5 TODO: send LEAVE via signaling WebSocket.
pass

@staticmethod
async def create_room(api_url: str) -> RoomCredentials:
"""Create a new room and return credentials. Does not start a session."""
return await PocketStation._create_room(api_url)

@staticmethod
async def _create_room(api_url: str) -> RoomCredentials:
url = api_url.rstrip("/") + "/v1/rooms"
try:
async with httpx.AsyncClient() as http:
response = await http.post(url, json={})
except httpx.RequestError as exc:
raise PocketStationError(
f"network error creating room: {exc}", "network_error"
) from exc

if not response.is_success:
raise PocketStationError(
f"relay returned HTTP {response.status_code}: {response.text}",
"http_error",
)

try:
data = response.json()
except json.JSONDecodeError as exc:
raise PocketStationError(
f"failed to parse room creation response: {exc}", "parse_error"
) from exc

return RoomCredentials.from_dict(data)
45 changes: 45 additions & 0 deletions pocketstation/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""PocketStation SDK type definitions. Phase 5."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional


@dataclass
class IceServer:
"""ICE server configuration (ADR-023 embedded TURN)."""
urls: list[str]
username: Optional[str] = None
credential: Optional[str] = None


@dataclass
class RoomCredentials:
"""Credentials returned by POST /v1/rooms."""
room_id: str
source_token: str
listener_token: str
ice_servers: list[IceServer] = field(default_factory=list)

@classmethod
def from_dict(cls, data: dict) -> "RoomCredentials":
ice_servers = [
IceServer(
urls=srv.get("urls", []),
username=srv.get("username"),
credential=srv.get("credential"),
)
for srv in data.get("ice_servers", [])
]
return cls(
room_id=data["room_id"],
source_token=data["source_token"],
listener_token=data["listener_token"],
ice_servers=ice_servers,
)


class PocketStationError(Exception):
"""Base error for all PocketStation SDK failures."""
def __init__(self, message: str, code: str = "error") -> None:
super().__init__(message)
self.code = code
21 changes: 19 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
[project]
name="pocketstation"
version="0.1.0"
name = "pocketstation"
version = "0.1.0"
description = "PocketStation Python client SDK"
requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = [
"httpx>=0.27",
"websockets>=12.0",
]

[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"respx>=0.21",
]

[tool.pytest.ini_options]
asyncio_mode = "auto"
98 changes: 98 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Unit tests for pocketstation.client. Phase 5."""
import pytest
import httpx
import respx

from pocketstation import PocketStation, PocketStationError, RoomCredentials


VALID_ROOM_RESPONSE = {
"room_id": "room-py-001",
"source_token": "src-py",
"listener_token": "lst-py",
}


@respx.mock
@pytest.mark.asyncio
async def test_given_valid_api_server_when_connect_then_session_has_credentials():
# Given
respx.post("http://api.example.com/v1/rooms").mock(
return_value=httpx.Response(201, json=VALID_ROOM_RESPONSE)
)
# When
async with PocketStation.connect(
api_url="http://api.example.com",
relay_url="ws://relay.example.com",
) as session:
# Then
assert session.room_id == "room-py-001"
assert session.source_token == "src-py"
assert session.listener_token == "lst-py"
assert session.ice_servers == []


@respx.mock
@pytest.mark.asyncio
async def test_given_api_server_with_turn_when_connect_then_ice_servers_forwarded():
# Given
respx.post("http://api.example.com/v1/rooms").mock(
return_value=httpx.Response(201, json={
**VALID_ROOM_RESPONSE,
"ice_servers": [
{"urls": ["stun:relay.example.com:3478"]},
{"urls": ["turn:relay.example.com:3478"], "username": "u", "credential": "p"},
],
})
)
# When
async with PocketStation.connect(
api_url="http://api.example.com",
relay_url="ws://relay.example.com",
) as session:
# Then
assert len(session.ice_servers) == 2
assert session.ice_servers[1].username == "u"


@respx.mock
@pytest.mark.asyncio
async def test_given_api_server_500_when_connect_then_raises():
# Given
respx.post("http://api.example.com/v1/rooms").mock(
return_value=httpx.Response(500, text="internal error")
)
# When / Then
with pytest.raises(PocketStationError) as exc_info:
async with PocketStation.connect(
api_url="http://api.example.com",
relay_url="ws://relay.example.com",
):
pass
assert exc_info.value.code == "http_error"


@pytest.mark.asyncio
async def test_given_preexisting_credentials_when_connect_then_no_http_call():
# Given — no network mock; any http call would error
creds = RoomCredentials.from_dict(VALID_ROOM_RESPONSE)
# When
async with PocketStation.connect(
api_url="http://should-not-be-called.example.com",
relay_url="ws://relay.example.com",
credentials=creds,
) as session:
# Then — session uses provided credentials without making any HTTP call
assert session.room_id == "room-py-001"


@pytest.mark.asyncio
async def test_given_session_when_credentials_property_then_returns_room_credentials():
creds = RoomCredentials.from_dict(VALID_ROOM_RESPONSE)
async with PocketStation.connect(
api_url="http://unused.example.com",
relay_url="ws://relay.example.com",
credentials=creds,
) as session:
assert isinstance(session.credentials, RoomCredentials)
assert session.credentials.room_id == "room-py-001"
54 changes: 54 additions & 0 deletions tests/test_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Unit tests for pocketstation.types. Phase 5."""
import pytest
from pocketstation.types import IceServer, PocketStationError, RoomCredentials


def test_given_valid_dict_when_from_dict_then_credentials_parsed():
# Given
data = {
"room_id": "room-abc",
"source_token": "src-tok",
"listener_token": "lst-tok",
}
# When
creds = RoomCredentials.from_dict(data)
# Then
assert creds.room_id == "room-abc"
assert creds.source_token == "src-tok"
assert creds.listener_token == "lst-tok"
assert creds.ice_servers == []


def test_given_ice_servers_when_from_dict_then_parsed():
# Given
data = {
"room_id": "room-turn",
"source_token": "s",
"listener_token": "l",
"ice_servers": [
{"urls": ["stun:relay.example.com:3478"]},
{"urls": ["turn:relay.example.com:3478"], "username": "u", "credential": "p"},
],
}
# When
creds = RoomCredentials.from_dict(data)
# Then
assert len(creds.ice_servers) == 2
assert creds.ice_servers[0].urls == ["stun:relay.example.com:3478"]
assert creds.ice_servers[1].username == "u"
assert creds.ice_servers[1].credential == "p"


def test_given_pocketstation_error_when_raised_then_code_set():
# Given / When / Then
with pytest.raises(PocketStationError) as exc_info:
raise PocketStationError("connection failed", "network_error")
assert exc_info.value.code == "network_error"
assert "connection failed" in str(exc_info.value)


def test_given_ice_server_when_constructed_then_fields_accessible():
srv = IceServer(urls=["stun:example.com:3478"])
assert srv.urls == ["stun:example.com:3478"]
assert srv.username is None
assert srv.credential is None
Loading