diff --git a/pocketstation/__init__.py b/pocketstation/__init__.py index 3dc1f76..a43259a 100644 --- a/pocketstation/__init__.py +++ b/pocketstation/__init__.py @@ -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", +] diff --git a/pocketstation/client.py b/pocketstation/client.py new file mode 100644 index 0000000..3fc3238 --- /dev/null +++ b/pocketstation/client.py @@ -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) diff --git a/pocketstation/types.py b/pocketstation/types.py new file mode 100644 index 0000000..17d5549 --- /dev/null +++ b/pocketstation/types.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 20f68e4..70500f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..3bf2de9 --- /dev/null +++ b/tests/test_client.py @@ -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" diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..5613418 --- /dev/null +++ b/tests/test_types.py @@ -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