interhumanai 0.4.1__py3-none-any.whl
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.
- interhumanai/__init__.py +181 -0
- interhumanai/_http.py +73 -0
- interhumanai/_session_socket.py +314 -0
- interhumanai/_version.py +3 -0
- interhumanai/auth.py +242 -0
- interhumanai/client.py +120 -0
- interhumanai/config.py +52 -0
- interhumanai/errors.py +56 -0
- interhumanai/py.typed +0 -0
- interhumanai/realtime.py +239 -0
- interhumanai/stream.py +338 -0
- interhumanai/types.py +162 -0
- interhumanai/upload.py +110 -0
- interhumanai-0.4.1.dist-info/METADATA +222 -0
- interhumanai-0.4.1.dist-info/RECORD +17 -0
- interhumanai-0.4.1.dist-info/WHEEL +4 -0
- interhumanai-0.4.1.dist-info/licenses/LICENSE +202 -0
interhumanai/__init__.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""First-party Python SDK for the Interhuman API.
|
|
2
|
+
|
|
3
|
+
Quickstart::
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from interhumanai import InterhumanClient
|
|
7
|
+
|
|
8
|
+
async def main() -> None:
|
|
9
|
+
client = InterhumanClient(key_id="...", key_secret="...")
|
|
10
|
+
result = await client.upload.analyze("meeting.mp4")
|
|
11
|
+
for signal in result.signals:
|
|
12
|
+
print(signal.type.value, signal.start, signal.end)
|
|
13
|
+
|
|
14
|
+
asyncio.run(main())
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from ._session_socket import CloseInfo, SessionSocketClient, UnknownEvent
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
from .auth import (
|
|
20
|
+
DEFAULT_REFRESH_SKEW_SECONDS,
|
|
21
|
+
DEFAULT_SCOPES,
|
|
22
|
+
AuthClient,
|
|
23
|
+
ClientTokenResponse,
|
|
24
|
+
StaticTokenProvider,
|
|
25
|
+
TokenManager,
|
|
26
|
+
TokenProvider,
|
|
27
|
+
TokenResponse,
|
|
28
|
+
)
|
|
29
|
+
from .client import InterhumanClient
|
|
30
|
+
from .config import HTTP_BASE_URLS, Environment, http_to_ws_base_url, resolve_http_base_url
|
|
31
|
+
from .errors import InterhumanAPIError, InterhumanConfigError, InterhumanError
|
|
32
|
+
from .realtime import (
|
|
33
|
+
NO_GUIDANCE,
|
|
34
|
+
RealtimeClient,
|
|
35
|
+
RealtimeEvent,
|
|
36
|
+
RealtimeSessionConfigOptions,
|
|
37
|
+
RealtimeSessionReadyData,
|
|
38
|
+
RealtimeSessionReadyEvent,
|
|
39
|
+
RealtimeSessionUpdatedData,
|
|
40
|
+
RealtimeSessionUpdatedEvent,
|
|
41
|
+
SynthesisGeneratedData,
|
|
42
|
+
SynthesisGeneratedEvent,
|
|
43
|
+
TranscriptGeneratedEvent,
|
|
44
|
+
TranscriptSegment,
|
|
45
|
+
parse_realtime_event,
|
|
46
|
+
)
|
|
47
|
+
from .stream import (
|
|
48
|
+
ConversationQualityUpdatedData,
|
|
49
|
+
ConversationQualityUpdatedEvent,
|
|
50
|
+
CoverageDroppedData,
|
|
51
|
+
CoverageDroppedEvent,
|
|
52
|
+
CoverageRange,
|
|
53
|
+
EngagementUpdatedData,
|
|
54
|
+
EngagementUpdatedEvent,
|
|
55
|
+
ErrorData,
|
|
56
|
+
ErrorEvent,
|
|
57
|
+
FeedbackGeneratedData,
|
|
58
|
+
FeedbackGeneratedEvent,
|
|
59
|
+
SessionClosingData,
|
|
60
|
+
SessionClosingEvent,
|
|
61
|
+
SessionConfigOptions,
|
|
62
|
+
SessionEndedData,
|
|
63
|
+
SessionEndedEvent,
|
|
64
|
+
SessionReadyData,
|
|
65
|
+
SessionReadyEvent,
|
|
66
|
+
SessionUpdatedData,
|
|
67
|
+
SessionUpdatedEvent,
|
|
68
|
+
SignalDetectedData,
|
|
69
|
+
SignalDetectedEvent,
|
|
70
|
+
SignalEndedData,
|
|
71
|
+
SignalEndedEvent,
|
|
72
|
+
SignalUpdatedData,
|
|
73
|
+
SignalUpdatedEvent,
|
|
74
|
+
StreamClient,
|
|
75
|
+
StreamEvent,
|
|
76
|
+
parse_stream_event,
|
|
77
|
+
)
|
|
78
|
+
from .types import (
|
|
79
|
+
AnalysisGroup,
|
|
80
|
+
AnalysisResult,
|
|
81
|
+
ConversationQuality,
|
|
82
|
+
ConversationQualityTimelineEntry,
|
|
83
|
+
ConversationQualityValues,
|
|
84
|
+
EngagementLevel,
|
|
85
|
+
EngagementStateEntry,
|
|
86
|
+
Feedback,
|
|
87
|
+
GoalDimension,
|
|
88
|
+
IncludeFlag,
|
|
89
|
+
InteractionFeedback,
|
|
90
|
+
NoFeedback,
|
|
91
|
+
Probability,
|
|
92
|
+
Scope,
|
|
93
|
+
Signal,
|
|
94
|
+
SignalType,
|
|
95
|
+
SynthesisFrequency,
|
|
96
|
+
)
|
|
97
|
+
from .upload import UploadClient, VideoInput
|
|
98
|
+
|
|
99
|
+
__all__ = [
|
|
100
|
+
"DEFAULT_REFRESH_SKEW_SECONDS",
|
|
101
|
+
"DEFAULT_SCOPES",
|
|
102
|
+
"HTTP_BASE_URLS",
|
|
103
|
+
"NO_GUIDANCE",
|
|
104
|
+
"AnalysisGroup",
|
|
105
|
+
"AnalysisResult",
|
|
106
|
+
"AuthClient",
|
|
107
|
+
"ClientTokenResponse",
|
|
108
|
+
"CloseInfo",
|
|
109
|
+
"ConversationQuality",
|
|
110
|
+
"ConversationQualityTimelineEntry",
|
|
111
|
+
"ConversationQualityUpdatedData",
|
|
112
|
+
"ConversationQualityUpdatedEvent",
|
|
113
|
+
"ConversationQualityValues",
|
|
114
|
+
"CoverageDroppedData",
|
|
115
|
+
"CoverageDroppedEvent",
|
|
116
|
+
"CoverageRange",
|
|
117
|
+
"EngagementLevel",
|
|
118
|
+
"EngagementStateEntry",
|
|
119
|
+
"EngagementUpdatedData",
|
|
120
|
+
"EngagementUpdatedEvent",
|
|
121
|
+
"Environment",
|
|
122
|
+
"ErrorData",
|
|
123
|
+
"ErrorEvent",
|
|
124
|
+
"Feedback",
|
|
125
|
+
"FeedbackGeneratedData",
|
|
126
|
+
"FeedbackGeneratedEvent",
|
|
127
|
+
"GoalDimension",
|
|
128
|
+
"IncludeFlag",
|
|
129
|
+
"InteractionFeedback",
|
|
130
|
+
"InterhumanAPIError",
|
|
131
|
+
"InterhumanClient",
|
|
132
|
+
"InterhumanConfigError",
|
|
133
|
+
"InterhumanError",
|
|
134
|
+
"NoFeedback",
|
|
135
|
+
"Probability",
|
|
136
|
+
"RealtimeClient",
|
|
137
|
+
"RealtimeEvent",
|
|
138
|
+
"RealtimeSessionConfigOptions",
|
|
139
|
+
"RealtimeSessionReadyData",
|
|
140
|
+
"RealtimeSessionReadyEvent",
|
|
141
|
+
"RealtimeSessionUpdatedData",
|
|
142
|
+
"RealtimeSessionUpdatedEvent",
|
|
143
|
+
"Scope",
|
|
144
|
+
"SessionClosingData",
|
|
145
|
+
"SessionClosingEvent",
|
|
146
|
+
"SessionConfigOptions",
|
|
147
|
+
"SessionEndedData",
|
|
148
|
+
"SessionEndedEvent",
|
|
149
|
+
"SessionReadyData",
|
|
150
|
+
"SessionReadyEvent",
|
|
151
|
+
"SessionSocketClient",
|
|
152
|
+
"SessionUpdatedData",
|
|
153
|
+
"SessionUpdatedEvent",
|
|
154
|
+
"Signal",
|
|
155
|
+
"SignalDetectedData",
|
|
156
|
+
"SignalDetectedEvent",
|
|
157
|
+
"SignalEndedData",
|
|
158
|
+
"SignalEndedEvent",
|
|
159
|
+
"SignalType",
|
|
160
|
+
"SignalUpdatedData",
|
|
161
|
+
"SignalUpdatedEvent",
|
|
162
|
+
"StaticTokenProvider",
|
|
163
|
+
"StreamClient",
|
|
164
|
+
"StreamEvent",
|
|
165
|
+
"SynthesisFrequency",
|
|
166
|
+
"SynthesisGeneratedData",
|
|
167
|
+
"SynthesisGeneratedEvent",
|
|
168
|
+
"TokenManager",
|
|
169
|
+
"TokenProvider",
|
|
170
|
+
"TokenResponse",
|
|
171
|
+
"TranscriptGeneratedEvent",
|
|
172
|
+
"TranscriptSegment",
|
|
173
|
+
"UnknownEvent",
|
|
174
|
+
"UploadClient",
|
|
175
|
+
"VideoInput",
|
|
176
|
+
"__version__",
|
|
177
|
+
"http_to_ws_base_url",
|
|
178
|
+
"parse_realtime_event",
|
|
179
|
+
"parse_stream_event",
|
|
180
|
+
"resolve_http_base_url",
|
|
181
|
+
]
|
interhumanai/_http.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Internal HTTP helpers shared by the SDK's HTTP clients."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .errors import InterhumanAPIError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _error_from_response(response: httpx.Response) -> InterhumanAPIError:
|
|
11
|
+
"""Build an :class:`InterhumanAPIError` from a non-2xx HTTP response."""
|
|
12
|
+
body: dict[str, Any] | None = None
|
|
13
|
+
try:
|
|
14
|
+
parsed = response.json()
|
|
15
|
+
except ValueError:
|
|
16
|
+
parsed = None
|
|
17
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("error_id"), str):
|
|
18
|
+
body = parsed
|
|
19
|
+
detail = None
|
|
20
|
+
if body is not None and isinstance(body.get("message"), str):
|
|
21
|
+
detail = body["message"]
|
|
22
|
+
error_id = body.get("error_id") if body else None
|
|
23
|
+
message = f"Interhuman API error ({error_id}): " if error_id else "Interhuman API error: "
|
|
24
|
+
message += detail or f"HTTP {response.status_code}"
|
|
25
|
+
return InterhumanAPIError(
|
|
26
|
+
message,
|
|
27
|
+
status=response.status_code,
|
|
28
|
+
error_id=error_id,
|
|
29
|
+
correlation_id=body.get("correlation_id") if body else None,
|
|
30
|
+
link=body.get("link") if body else None,
|
|
31
|
+
body=body,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def request_json(
|
|
36
|
+
client: httpx.AsyncClient,
|
|
37
|
+
method: str,
|
|
38
|
+
url: str,
|
|
39
|
+
*,
|
|
40
|
+
json: Any | None = None,
|
|
41
|
+
files: Any | None = None,
|
|
42
|
+
data: Any | None = None,
|
|
43
|
+
headers: dict[str, str] | None = None,
|
|
44
|
+
) -> Any:
|
|
45
|
+
"""Send a request and return the parsed JSON body.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
InterhumanAPIError: For non-2xx responses (with the API's canonical
|
|
49
|
+
error fields when present) and for transport failures (``status=0``).
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The parsed JSON response body, or ``None`` for empty bodies (e.g. 204).
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
response = await client.request(
|
|
56
|
+
method, url, json=json, files=files, data=data, headers=headers
|
|
57
|
+
)
|
|
58
|
+
except httpx.HTTPError as exc:
|
|
59
|
+
raise InterhumanAPIError(
|
|
60
|
+
f"Interhuman API request failed before a response was received: {exc}",
|
|
61
|
+
status=0,
|
|
62
|
+
) from exc
|
|
63
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
64
|
+
raise _error_from_response(response)
|
|
65
|
+
if not response.content:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
return response.json()
|
|
69
|
+
except ValueError as exc:
|
|
70
|
+
raise InterhumanAPIError(
|
|
71
|
+
"Interhuman API returned a malformed JSON response.",
|
|
72
|
+
status=response.status_code,
|
|
73
|
+
) from exc
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Shared asyncio WebSocket engine for the stream and real-time clients."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, ClassVar, Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ValidationError
|
|
8
|
+
from websockets.asyncio.client import connect as ws_connect
|
|
9
|
+
from websockets.exceptions import ConnectionClosed, InvalidHandshake
|
|
10
|
+
|
|
11
|
+
from .auth import TokenProvider
|
|
12
|
+
from .config import Environment, http_to_ws_base_url, resolve_http_base_url
|
|
13
|
+
from .errors import InterhumanConfigError, InterhumanError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CloseInfo(BaseModel):
|
|
17
|
+
"""Close code and reason of a finished WebSocket session."""
|
|
18
|
+
|
|
19
|
+
code: int
|
|
20
|
+
reason: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UnknownEvent(BaseModel):
|
|
24
|
+
"""A server envelope whose ``type`` this SDK version does not know.
|
|
25
|
+
|
|
26
|
+
Newer API versions may add event types; they are surfaced as-is instead of
|
|
27
|
+
failing the session.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
type: The envelope's ``type`` discriminator.
|
|
31
|
+
raw: The full envelope payload as received.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
type: str
|
|
35
|
+
raw: dict[str, Any]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
EventT = TypeVar("EventT")
|
|
39
|
+
|
|
40
|
+
_QUEUE_END = object()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SessionSocketClient(Generic[EventT]):
|
|
44
|
+
"""One live analysis session over a WebSocket.
|
|
45
|
+
|
|
46
|
+
Connect with :meth:`connect` (or ``async with``), send binary video chunks
|
|
47
|
+
with :meth:`send_video`, and consume typed server events by iterating the
|
|
48
|
+
client with ``async for``. Iteration ends when the connection closes;
|
|
49
|
+
:attr:`close_info` then holds the close code and reason.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
token_provider: Source of bearer tokens for authentication.
|
|
53
|
+
environment: Named environment to connect to. Defaults to
|
|
54
|
+
``production``.
|
|
55
|
+
base_url: Explicit HTTP base URL override; converted to ``ws(s)://``.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
_endpoint_path: ClassVar[str]
|
|
59
|
+
_display_name: ClassVar[str]
|
|
60
|
+
_scope_hint: ClassVar[str]
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
token_provider: TokenProvider,
|
|
66
|
+
environment: Environment | None = None,
|
|
67
|
+
base_url: str | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Initialize the client (see class docstring for arguments)."""
|
|
70
|
+
self._token_provider = token_provider
|
|
71
|
+
self._ws_base_url = http_to_ws_base_url(
|
|
72
|
+
resolve_http_base_url(base_url=base_url, environment=environment)
|
|
73
|
+
)
|
|
74
|
+
self._ws: Any = None
|
|
75
|
+
self._open = False
|
|
76
|
+
self._close_info: CloseInfo | None = None
|
|
77
|
+
self._events: asyncio.Queue[Any] = asyncio.Queue()
|
|
78
|
+
self._session_ready: asyncio.Future[EventT] | None = None
|
|
79
|
+
self._receiver: asyncio.Task[None] | None = None
|
|
80
|
+
|
|
81
|
+
def _parse_event(self, payload: dict[str, Any]) -> EventT:
|
|
82
|
+
"""Parse one server envelope into a typed event (subclass hook)."""
|
|
83
|
+
raise NotImplementedError
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def url(self) -> str:
|
|
87
|
+
"""The WebSocket URL this client connects to."""
|
|
88
|
+
return self._ws_base_url + self._endpoint_path
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def is_open(self) -> bool:
|
|
92
|
+
"""Whether the connection is currently open."""
|
|
93
|
+
return self._open
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def close_info(self) -> CloseInfo | None:
|
|
97
|
+
"""Close code and reason once the connection has ended, else ``None``."""
|
|
98
|
+
return self._close_info
|
|
99
|
+
|
|
100
|
+
async def connect(self) -> "SessionSocketClient[EventT]":
|
|
101
|
+
"""Open the WebSocket connection and start receiving events.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
This client, for chaining.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
InterhumanConfigError: If the client was already connected.
|
|
108
|
+
InterhumanError: If the handshake fails (bad credentials or scope,
|
|
109
|
+
unreachable host).
|
|
110
|
+
"""
|
|
111
|
+
if self._ws is not None:
|
|
112
|
+
raise InterhumanConfigError(
|
|
113
|
+
f"{self._display_name} client is already connected; "
|
|
114
|
+
"create a new client for a new session."
|
|
115
|
+
)
|
|
116
|
+
token = await self._token_provider.get_token()
|
|
117
|
+
try:
|
|
118
|
+
self._ws = await ws_connect(
|
|
119
|
+
self.url,
|
|
120
|
+
additional_headers={"Authorization": f"Bearer {token}"},
|
|
121
|
+
)
|
|
122
|
+
except InvalidHandshake as exc:
|
|
123
|
+
raise InterhumanError(
|
|
124
|
+
f"{self._display_name} WebSocket handshake failed: {exc}. {self._scope_hint}"
|
|
125
|
+
) from exc
|
|
126
|
+
except OSError as exc:
|
|
127
|
+
raise InterhumanError(
|
|
128
|
+
f"{self._display_name} WebSocket connection failed: {exc}"
|
|
129
|
+
) from exc
|
|
130
|
+
self._open = True
|
|
131
|
+
self._session_ready = asyncio.get_running_loop().create_future()
|
|
132
|
+
self._receiver = asyncio.create_task(self._receive_loop())
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
async def __aenter__(self) -> "SessionSocketClient[EventT]":
|
|
136
|
+
"""Connect on entering an ``async with`` block."""
|
|
137
|
+
return await self.connect()
|
|
138
|
+
|
|
139
|
+
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
140
|
+
"""Close the connection on leaving an ``async with`` block."""
|
|
141
|
+
await self.close()
|
|
142
|
+
|
|
143
|
+
def __aiter__(self) -> "SessionSocketClient[EventT]":
|
|
144
|
+
"""Iterate over typed server events until the connection closes."""
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
async def __anext__(self) -> EventT:
|
|
148
|
+
"""Return the next server event.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
StopAsyncIteration: When the connection has closed and every
|
|
152
|
+
buffered event has been consumed.
|
|
153
|
+
InterhumanError: When the server sent a frame that is not a valid
|
|
154
|
+
JSON envelope. The connection stays open and later events are
|
|
155
|
+
preserved, but the raise exits an ``async for`` loop - start a
|
|
156
|
+
new ``async for`` (or call ``__anext__``) to keep consuming.
|
|
157
|
+
"""
|
|
158
|
+
item = await self._events.get()
|
|
159
|
+
if item is _QUEUE_END:
|
|
160
|
+
self._events.put_nowait(_QUEUE_END)
|
|
161
|
+
raise StopAsyncIteration
|
|
162
|
+
if isinstance(item, InterhumanError):
|
|
163
|
+
raise item
|
|
164
|
+
return item
|
|
165
|
+
|
|
166
|
+
async def wait_for_session_ready(self) -> EventT:
|
|
167
|
+
"""Wait for the server's ``session.ready`` event.
|
|
168
|
+
|
|
169
|
+
The event is also delivered through iteration; this helper simply
|
|
170
|
+
awaits it (or returns it if it already arrived).
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
The ``session.ready`` event.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
InterhumanError: If the connection closes before the session
|
|
177
|
+
becomes ready (typically an auth or scope failure).
|
|
178
|
+
"""
|
|
179
|
+
if self._session_ready is None:
|
|
180
|
+
raise InterhumanConfigError(
|
|
181
|
+
f"{self._display_name} client is not connected; call connect() first."
|
|
182
|
+
)
|
|
183
|
+
return await asyncio.shield(self._session_ready)
|
|
184
|
+
|
|
185
|
+
async def send_video(self, chunk: bytes | bytearray | memoryview) -> None:
|
|
186
|
+
"""Send one binary video chunk.
|
|
187
|
+
|
|
188
|
+
The first chunk must carry the container's init header; later chunks
|
|
189
|
+
are continuation fragments of the same WebM or fragmented-MP4 stream.
|
|
190
|
+
The SDK does not enforce a chunk size; the server rejects chunks above
|
|
191
|
+
its limit (32 MB by default, reported in ``session.ready`` as
|
|
192
|
+
``max_segment_size_bytes``) with an ``ih6002`` error.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
chunk: The raw video bytes to send.
|
|
196
|
+
"""
|
|
197
|
+
await self._send(bytes(chunk))
|
|
198
|
+
|
|
199
|
+
async def request_close(self) -> None:
|
|
200
|
+
"""Ask the server to drain in-flight analysis and end the session.
|
|
201
|
+
|
|
202
|
+
The server acknowledges with ``session.closing``, emits any final
|
|
203
|
+
events, sends ``session.ended``, and closes the socket normally. Keep
|
|
204
|
+
iterating to observe the drain; for an immediate teardown use
|
|
205
|
+
:meth:`close` instead.
|
|
206
|
+
"""
|
|
207
|
+
await self._send_json({"type": "session.close"})
|
|
208
|
+
|
|
209
|
+
async def close(self, code: int = 1000, reason: str = "") -> None:
|
|
210
|
+
"""Close the WebSocket immediately, discarding in-flight analysis.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
code: WebSocket close code to send.
|
|
214
|
+
reason: Optional close reason.
|
|
215
|
+
"""
|
|
216
|
+
if self._ws is None:
|
|
217
|
+
return
|
|
218
|
+
await self._ws.close(code, reason)
|
|
219
|
+
if self._receiver is not None:
|
|
220
|
+
await self._receiver
|
|
221
|
+
|
|
222
|
+
async def wait_closed(self) -> CloseInfo:
|
|
223
|
+
"""Wait until the connection has fully closed.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
The session's close code and reason.
|
|
227
|
+
"""
|
|
228
|
+
if self._ws is None:
|
|
229
|
+
raise InterhumanConfigError(f"{self._display_name} client was never connected.")
|
|
230
|
+
if self._receiver is not None:
|
|
231
|
+
await asyncio.shield(self._receiver)
|
|
232
|
+
assert self._close_info is not None
|
|
233
|
+
return self._close_info
|
|
234
|
+
|
|
235
|
+
async def _send(self, message: bytes | str) -> None:
|
|
236
|
+
"""Send a raw frame, translating closed-connection errors."""
|
|
237
|
+
if self._ws is None:
|
|
238
|
+
raise InterhumanConfigError(
|
|
239
|
+
f"{self._display_name} client is not connected; call connect() first."
|
|
240
|
+
)
|
|
241
|
+
if not self._open:
|
|
242
|
+
raise InterhumanConfigError(
|
|
243
|
+
f"{self._display_name} connection is closed; nothing can be sent."
|
|
244
|
+
)
|
|
245
|
+
try:
|
|
246
|
+
await self._ws.send(message)
|
|
247
|
+
except ConnectionClosed as exc:
|
|
248
|
+
raise InterhumanError(f"{self._display_name} connection closed while sending.") from exc
|
|
249
|
+
|
|
250
|
+
async def _send_json(self, payload: dict[str, Any]) -> None:
|
|
251
|
+
"""Serialize ``payload`` and send it as a text frame."""
|
|
252
|
+
await self._send(json.dumps(payload))
|
|
253
|
+
|
|
254
|
+
def _dispatch(self, raw: str | bytes) -> None:
|
|
255
|
+
"""Parse one inbound frame and enqueue the resulting event or error."""
|
|
256
|
+
if isinstance(raw, bytes):
|
|
257
|
+
self._events.put_nowait(
|
|
258
|
+
InterhumanError(f"{self._display_name} server sent an unexpected binary frame.")
|
|
259
|
+
)
|
|
260
|
+
return
|
|
261
|
+
try:
|
|
262
|
+
payload = json.loads(raw)
|
|
263
|
+
except ValueError:
|
|
264
|
+
self._events.put_nowait(
|
|
265
|
+
InterhumanError(f"{self._display_name} server sent a malformed JSON frame.")
|
|
266
|
+
)
|
|
267
|
+
return
|
|
268
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("type"), str):
|
|
269
|
+
self._events.put_nowait(
|
|
270
|
+
InterhumanError(
|
|
271
|
+
f"{self._display_name} server sent an envelope without a string 'type'."
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
return
|
|
275
|
+
try:
|
|
276
|
+
event = self._parse_event(payload)
|
|
277
|
+
except ValidationError as exc:
|
|
278
|
+
self._events.put_nowait(
|
|
279
|
+
InterhumanError(
|
|
280
|
+
f"{self._display_name} server sent an invalid '{payload['type']}' "
|
|
281
|
+
f"envelope: {exc}"
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
return
|
|
285
|
+
ready = self._session_ready
|
|
286
|
+
if payload["type"] == "session.ready" and ready is not None and not ready.done():
|
|
287
|
+
ready.set_result(event)
|
|
288
|
+
self._events.put_nowait(event)
|
|
289
|
+
|
|
290
|
+
async def _receive_loop(self) -> None:
|
|
291
|
+
"""Receive frames until the connection closes, then finalize state."""
|
|
292
|
+
assert self._ws is not None
|
|
293
|
+
try:
|
|
294
|
+
async for raw in self._ws:
|
|
295
|
+
self._dispatch(raw)
|
|
296
|
+
except ConnectionClosed:
|
|
297
|
+
pass
|
|
298
|
+
finally:
|
|
299
|
+
self._open = False
|
|
300
|
+
code = self._ws.close_code if self._ws.close_code is not None else 1006
|
|
301
|
+
reason = self._ws.close_reason or ""
|
|
302
|
+
self._close_info = CloseInfo(code=code, reason=reason)
|
|
303
|
+
ready = self._session_ready
|
|
304
|
+
if ready is not None and not ready.done():
|
|
305
|
+
ready.set_exception(
|
|
306
|
+
InterhumanError(
|
|
307
|
+
f"{self._display_name} connection closed before the session became "
|
|
308
|
+
f"ready (code {code}, reason {reason!r}). {self._scope_hint}"
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
# Mark the exception as observed so sessions that never await
|
|
312
|
+
# readiness do not log "Future exception was never retrieved".
|
|
313
|
+
ready.exception()
|
|
314
|
+
self._events.put_nowait(_QUEUE_END)
|
interhumanai/_version.py
ADDED