spatialreal 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: spatialreal
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: SpatialReal Python SDK — realtime avatar sessions (audio in, animation/egress out)
5
5
  Project-URL: Documentation, https://docs.spatialreal.com
6
6
  Project-URL: Website, https://www.spatialreal.ai/
@@ -45,8 +45,6 @@ session = new_avatar_session(
45
45
  api_key="...",
46
46
  app_id="...",
47
47
  avatar_id="...",
48
- console_endpoint_url="https://api.spatialreal.cloud", # OpenAPI root: session tokens
49
- ingress_endpoint_url="wss://driven.us-west.spatialreal.cloud/v2/driveningress",
50
48
  expire_at=datetime.now(timezone.utc) + timedelta(hours=1),
51
49
  sample_rate=16000,
52
50
  livekit_egress=LiveKitEgressConfig(url=..., api_token=..., room_name=..., publisher_id=...),
@@ -82,6 +80,19 @@ as `pause_timeout`). Older servers never send it and ignore pause/resume.
82
80
 
83
81
  Session objects are single-use: on a dropped connection, create a fresh one
84
82
  (request state is deliberately not reusable across connections).
83
+ ## Endpoints
84
+
85
+ Endpoints come from the environment you name, so callers write no URLs:
86
+
87
+ ```python
88
+ session = new_avatar_session(..., environment="us-west") # the default
89
+ ```
90
+
91
+ They are resolved from SpatialReal's config service (built-in presets cover it being
92
+ unreachable), which is what lets a deployment move, or a region be added, without an
93
+ SDK release. Pass `console_endpoint_url` / `ingress_endpoint_url` to override either one
94
+ — useful against a local or private deployment.
95
+
85
96
  `AvatarSDKError.retryable` tells you whether reconnecting can succeed — it
86
97
  implements the server's WebSocket close-code contract (40xx: don't retry,
87
98
  45xx: retry with backoff).
@@ -91,6 +102,8 @@ implements the server's WebSocket close-code contract (40xx: don't retry,
91
102
  - `on_playback(PlaybackSignal)` — structured playback lifecycle; no protobuf
92
103
  parsing in caller code (`transport_frames(raw, is_last)` still exists).
93
104
  - `CloseCode` + `AvatarSDKError.retryable` — the reconnect contract as API.
105
+ - `environment="us-west"` — endpoints resolved from the config service instead of
106
+ hard-coded URLs; new regions need no SDK release.
94
107
  - `session.capabilities` — server-declared capabilities from the handshake
95
108
  (feature-detect, don't version-detect).
96
109
  - `pause()` / `resume()` + `on_playback_state(PlaybackStateEvent)` — egress-mode
@@ -15,8 +15,6 @@ session = new_avatar_session(
15
15
  api_key="...",
16
16
  app_id="...",
17
17
  avatar_id="...",
18
- console_endpoint_url="https://api.spatialreal.cloud", # OpenAPI root: session tokens
19
- ingress_endpoint_url="wss://driven.us-west.spatialreal.cloud/v2/driveningress",
20
18
  expire_at=datetime.now(timezone.utc) + timedelta(hours=1),
21
19
  sample_rate=16000,
22
20
  livekit_egress=LiveKitEgressConfig(url=..., api_token=..., room_name=..., publisher_id=...),
@@ -52,6 +50,19 @@ as `pause_timeout`). Older servers never send it and ignore pause/resume.
52
50
 
53
51
  Session objects are single-use: on a dropped connection, create a fresh one
54
52
  (request state is deliberately not reusable across connections).
53
+ ## Endpoints
54
+
55
+ Endpoints come from the environment you name, so callers write no URLs:
56
+
57
+ ```python
58
+ session = new_avatar_session(..., environment="us-west") # the default
59
+ ```
60
+
61
+ They are resolved from SpatialReal's config service (built-in presets cover it being
62
+ unreachable), which is what lets a deployment move, or a region be added, without an
63
+ SDK release. Pass `console_endpoint_url` / `ingress_endpoint_url` to override either one
64
+ — useful against a local or private deployment.
65
+
55
66
  `AvatarSDKError.retryable` tells you whether reconnecting can succeed — it
56
67
  implements the server's WebSocket close-code contract (40xx: don't retry,
57
68
  45xx: retry with backoff).
@@ -61,6 +72,8 @@ implements the server's WebSocket close-code contract (40xx: don't retry,
61
72
  - `on_playback(PlaybackSignal)` — structured playback lifecycle; no protobuf
62
73
  parsing in caller code (`transport_frames(raw, is_last)` still exists).
63
74
  - `CloseCode` + `AvatarSDKError.retryable` — the reconnect contract as API.
75
+ - `environment="us-west"` — endpoints resolved from the config service instead of
76
+ hard-coded URLs; new regions need no SDK release.
64
77
  - `session.capabilities` — server-declared capabilities from the handshake
65
78
  (feature-detect, don't version-detect).
66
79
  - `pause()` / `resume()` + `on_playback_state(PlaybackStateEvent)` — egress-mode
@@ -0,0 +1 @@
1
+ 845c7f2b2b5c39994bffea3e589bbde51d80f379
@@ -7,6 +7,7 @@ from dataclasses import dataclass, field
7
7
  from datetime import datetime
8
8
  from enum import Enum
9
9
 
10
+ from .environments import DEFAULT_ENVIRONMENT
10
11
  from .events import PlaybackSignal, PlaybackStateEvent
11
12
 
12
13
 
@@ -83,6 +84,10 @@ class SessionConfig:
83
84
  sample_rate: int = 16000
84
85
  bitrate: int = 0
85
86
  audio_format: AudioFormat = AudioFormat.PCM_S16LE
87
+ # Which deployment to talk to. Endpoints are resolved from it (config service,
88
+ # then built-in presets), so callers normally set neither URL below.
89
+ environment: str = DEFAULT_ENVIRONMENT
90
+ # Explicit overrides; each one given wins over the environment's.
86
91
  console_endpoint_url: str = ""
87
92
  ingress_endpoint_url: str = ""
88
93
  livekit_egress: LiveKitEgressConfig | None = None
@@ -0,0 +1,95 @@
1
+ """Where a deployment lives, and how the SDK finds it.
2
+
3
+ Callers name an environment (``us-west``) instead of writing URLs, so adding a
4
+ region or moving a deployment is a config-service change rather than an SDK
5
+ release. The built-in presets below are only a fallback for when that service
6
+ cannot be reached — they are a copy, not the source of truth.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ from dataclasses import dataclass
14
+
15
+ import aiohttp
16
+
17
+ CONFIG_SERVICE_URL = "https://config.spatialreal.cloud/sdk"
18
+ # The session must not hang on a config lookup; the presets cover the timeout.
19
+ CONFIG_SERVICE_TIMEOUT = 3.0
20
+ DEFAULT_ENVIRONMENT = "us-west"
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class EnvironmentEndpoints:
27
+ """Origins for one environment; the SDK appends the routes itself."""
28
+
29
+ # OpenAPI root — where session tokens come from.
30
+ api: str
31
+ # driven-ingress origin, without the /v2/driveningress path.
32
+ driveningress: str
33
+
34
+
35
+ # Public deployments only. Internal ones are reached by passing console_endpoint_url /
36
+ # ingress_endpoint_url explicitly, so naming them here cannot expose them to callers.
37
+ BUILTIN_ENVIRONMENTS: dict[str, EnvironmentEndpoints] = {
38
+ "us-west": EnvironmentEndpoints(
39
+ api="https://api.spatialreal.cloud",
40
+ driveningress="https://driven.us-west.spatialreal.cloud",
41
+ ),
42
+ }
43
+
44
+ # Fetched once per process: every session in it resolves against the same answer.
45
+ _cache: dict[str, dict[str, str]] | None = None
46
+ _lock = asyncio.Lock()
47
+
48
+
49
+ def reset_cache() -> None:
50
+ """Forget the fetched config (tests, or a long-lived process that must re-read)."""
51
+ global _cache
52
+ _cache = None
53
+
54
+
55
+ async def _fetch() -> dict[str, dict[str, str]]:
56
+ """The config service's environments, or {} if it cannot be read."""
57
+ timeout = aiohttp.ClientTimeout(total=CONFIG_SERVICE_TIMEOUT)
58
+ async with aiohttp.ClientSession(timeout=timeout) as session:
59
+ async with session.get(CONFIG_SERVICE_URL) as response:
60
+ if response.status != 200:
61
+ raise RuntimeError(f"config service returned {response.status}")
62
+ body = await response.json(content_type=None)
63
+ environments = body.get("environments") if isinstance(body, dict) else None
64
+ return environments if isinstance(environments, dict) else {}
65
+
66
+
67
+ async def resolve(environment: str) -> EnvironmentEndpoints:
68
+ """Endpoints for ``environment``: the config service's, else the built-in preset.
69
+
70
+ Raises ValueError when neither knows the name, since a wrong environment is a
71
+ caller mistake worth surfacing rather than silently defaulting elsewhere.
72
+ """
73
+ global _cache
74
+
75
+ if _cache is None:
76
+ async with _lock:
77
+ if _cache is None:
78
+ try:
79
+ _cache = await _fetch()
80
+ except Exception as e:
81
+ # Not fatal: the presets are exactly what this service would serve
82
+ # today; a session should not fail because config lookup did.
83
+ logger.debug("SpatialReal config service unavailable (%s); using presets", e)
84
+ _cache = {}
85
+
86
+ preset = BUILTIN_ENVIRONMENTS.get(environment)
87
+ remote = _cache.get(environment) or {}
88
+ if not preset and not remote:
89
+ known = sorted(set(BUILTIN_ENVIRONMENTS) | set(_cache))
90
+ raise ValueError(f"Unknown environment {environment!r}; known environments: {known}")
91
+
92
+ return EnvironmentEndpoints(
93
+ api=remote.get("api") or (preset.api if preset else ""),
94
+ driveningress=remote.get("driveningress") or (preset.driveningress if preset else ""),
95
+ )
@@ -21,6 +21,7 @@ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
21
21
  import aiohttp
22
22
  import websockets
23
23
 
24
+ from . import environments
24
25
  from .config import AudioFormat, SessionConfig
25
26
  from .errors import (
26
27
  AvatarSDKError,
@@ -37,6 +38,7 @@ from .proto.generated import message_pb2
37
38
  SESSION_TOKEN_PATH = "/v1/auth/session-token"
38
39
  # Accepted for back-compat: callers used to pass the cp root with this suffix already on it.
39
40
  LEGACY_CONSOLE_SUFFIX = "/v1/console"
41
+ DRIVENINGRESS_PATH = "/v2/driveningress"
40
42
  INGRESS_WEBSOCKET_PATH = "/websocket"
41
43
 
42
44
  logger = logging.getLogger(__name__)
@@ -62,6 +64,7 @@ class AvatarSession:
62
64
  self._last_req_id: str | None = None
63
65
  self._read_task: asyncio.Task | None = None
64
66
  self._close_notified = False
67
+ self._resolved_endpoints: environments.EnvironmentEndpoints | None = None
65
68
 
66
69
  # ------------------------------------------------------------------ props
67
70
 
@@ -83,18 +86,36 @@ class AvatarSession:
83
86
  """
84
87
  return self._capabilities
85
88
 
89
+ # ------------------------------------------------------------- endpoints
90
+
91
+ async def _endpoints(self) -> environments.EnvironmentEndpoints:
92
+ if self._resolved_endpoints is None:
93
+ self._resolved_endpoints = await environments.resolve(self._config.environment)
94
+ return self._resolved_endpoints
95
+
96
+ async def _console_endpoint(self) -> str:
97
+ """The OpenAPI root: the caller's if given, else the environment's."""
98
+ if self._config.console_endpoint_url:
99
+ return self._config.console_endpoint_url
100
+ return (await self._endpoints()).api
101
+
102
+ async def _ingress_endpoint(self) -> str:
103
+ """The driven-ingress WebSocket URL, built from the environment's origin."""
104
+ if self._config.ingress_endpoint_url:
105
+ return self._config.ingress_endpoint_url
106
+ origin = (await self._endpoints()).driveningress.rstrip("/")
107
+ return origin + DRIVENINGRESS_PATH
108
+
86
109
  # ------------------------------------------------------------------ init
87
110
 
88
111
  async def init(self) -> None:
89
112
  """Exchange the API key for a session token via the console API."""
90
113
  if not self._config.api_key:
91
114
  raise ValueError("Missing API key")
92
- if not self._config.console_endpoint_url:
93
- raise ValueError("Missing console endpoint URL")
94
115
  if not self._config.expire_at:
95
116
  raise ValueError("Missing expire_at")
96
117
 
97
- endpoint = _session_token_endpoint(self._config.console_endpoint_url)
118
+ endpoint = _session_token_endpoint(await self._console_endpoint())
98
119
  payload = {"expireAt": int(self._config.expire_at.timestamp())}
99
120
  headers = {"X-Api-Key": self._config.api_key, "Content-Type": "application/json"}
100
121
  timeout = aiohttp.ClientTimeout(total=self._config.token_request_timeout)
@@ -132,8 +153,6 @@ class AvatarSession:
132
153
  raise ValueError("Session already started")
133
154
  if not self._session_token:
134
155
  raise ValueError("Session not initialized (call init() first)")
135
- if not self._config.ingress_endpoint_url:
136
- raise ValueError("Missing ingress endpoint URL")
137
156
  if not self._config.avatar_id:
138
157
  raise ValueError("Missing avatar ID")
139
158
  if not self._config.app_id:
@@ -141,7 +160,7 @@ class AvatarSession:
141
160
  if self._config.livekit_egress is not None and self._config.agora_egress is not None:
142
161
  raise ValueError("Cannot configure both livekit_egress and agora_egress")
143
162
 
144
- ws_url, headers = self._build_ws_target()
163
+ ws_url, headers = self._build_ws_target(await self._ingress_endpoint())
145
164
 
146
165
  try:
147
166
  self._connection = await _ws_connect(ws_url, headers)
@@ -163,8 +182,8 @@ class AvatarSession:
163
182
  self._read_task = asyncio.create_task(self._read_loop())
164
183
  return self._connection_id
165
184
 
166
- def _build_ws_target(self) -> tuple[str, dict[str, str]]:
167
- endpoint = self._config.ingress_endpoint_url.rstrip("/") + INGRESS_WEBSOCKET_PATH
185
+ def _build_ws_target(self, ingress_endpoint_url: str) -> tuple[str, dict[str, str]]:
186
+ endpoint = ingress_endpoint_url.rstrip("/") + INGRESS_WEBSOCKET_PATH
168
187
  parsed = urlparse(endpoint)
169
188
  scheme = parsed.scheme.lower()
170
189
  ws_scheme = {"http": "ws", "https": "wss", "ws": "ws", "wss": "wss"}.get(scheme)
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -1 +0,0 @@
1
- 40f98c99c79c6a52fda9cac4b578a5bccc3f8542
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
File without changes
File without changes
File without changes