tazo-sdk 0.1.0__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.
tazo/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """Tazo's typed SDK for uploaded and streaming audio decisions."""
2
+
3
+ from ._audio import wav_chunks
4
+ from ._version import __version__
5
+ from .client import AsyncTazo, Tazo
6
+ from .errors import (
7
+ AuthenticationError,
8
+ ConnectionError,
9
+ DecisionPendingError,
10
+ IdempotencyConflictError,
11
+ InvalidRequestError,
12
+ NotFoundError,
13
+ PermissionDeniedError,
14
+ ProtocolError,
15
+ RateLimitError,
16
+ RequestTooLargeError,
17
+ ServiceError,
18
+ StreamingError,
19
+ TazoError,
20
+ TimeoutError,
21
+ )
22
+ from .types import ChoiceQuestion, NoulQuestion
23
+
24
+ __all__ = [
25
+ "AsyncTazo",
26
+ "AuthenticationError",
27
+ "ChoiceQuestion",
28
+ "ConnectionError",
29
+ "DecisionPendingError",
30
+ "IdempotencyConflictError",
31
+ "InvalidRequestError",
32
+ "NotFoundError",
33
+ "NoulQuestion",
34
+ "PermissionDeniedError",
35
+ "ProtocolError",
36
+ "RateLimitError",
37
+ "RequestTooLargeError",
38
+ "ServiceError",
39
+ "StreamingError",
40
+ "Tazo",
41
+ "TazoError",
42
+ "TimeoutError",
43
+ "__version__",
44
+ "wav_chunks",
45
+ ]
tazo/_audio.py ADDED
@@ -0,0 +1,87 @@
1
+ """Bounded, replayable uploads and standard-library WAV helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import mimetypes
6
+ import os
7
+ import wave
8
+ from collections.abc import Iterator
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import BinaryIO
12
+
13
+ from .errors import InvalidRequestError, RequestTooLargeError
14
+
15
+ AudioInput = str | os.PathLike[str] | bytes | BinaryIO
16
+ MAX_UPLOAD_BYTES = 16 * 1024 * 1024
17
+
18
+
19
+ @dataclass(frozen=True, repr=False)
20
+ class Upload:
21
+ """One immutable snapshot, reused unchanged for every HTTP attempt."""
22
+
23
+ data: bytes
24
+ filename: str
25
+ content_type: str
26
+
27
+
28
+ def snapshot_audio(audio: AudioInput, filename: str | None, content_type: str | None) -> Upload:
29
+ name = "audio"
30
+ if isinstance(audio, bytes):
31
+ data = audio
32
+ elif isinstance(audio, (str, os.PathLike)):
33
+ path = Path(audio)
34
+ name = path.name
35
+ with path.open("rb") as handle:
36
+ data = handle.read(MAX_UPLOAD_BYTES + 1)
37
+ else:
38
+ candidate = getattr(audio, "name", None)
39
+ if isinstance(candidate, str):
40
+ name = Path(candidate).name
41
+ position = audio.tell() if audio.seekable() else None
42
+ try:
43
+ data = audio.read(MAX_UPLOAD_BYTES + 1)
44
+ finally:
45
+ if position is not None:
46
+ audio.seek(position)
47
+ if not isinstance(data, bytes):
48
+ raise InvalidRequestError("Audio must be a binary file, bytes, or a filesystem path.")
49
+ if not data:
50
+ raise InvalidRequestError("Audio must not be empty.")
51
+ if len(data) > MAX_UPLOAD_BYTES:
52
+ raise RequestTooLargeError("Audio uploads are limited to 16 MiB.")
53
+ name = Path(filename).name if filename else name
54
+ media_type = content_type or mimetypes.guess_type(name)[0] or "application/octet-stream"
55
+ return Upload(data, name, media_type)
56
+
57
+
58
+ def wav_chunks(path: str | os.PathLike[str], *, chunk_ms: int = 100) -> Iterator[bytes]:
59
+ """Yield raw mono 16 kHz PCM16 from a WAV, without headers or real-time pacing.
60
+
61
+ Args:
62
+ path: PCM16 WAV recording. Compressed WAV and other formats are rejected.
63
+ chunk_ms: Chunk size from 1 to 200 milliseconds.
64
+
65
+ Yields:
66
+ Binary audio frames suitable for ``session.send_audio``.
67
+ """
68
+ if isinstance(chunk_ms, bool) or not isinstance(chunk_ms, int) or not 1 <= chunk_ms <= 200:
69
+ raise InvalidRequestError("chunk_ms must be an integer between 1 and 200.")
70
+ with wave.open(str(path), "rb") as handle:
71
+ if (
72
+ handle.getnchannels(),
73
+ handle.getsampwidth(),
74
+ handle.getframerate(),
75
+ handle.getcomptype(),
76
+ ) != (1, 2, 16000, "NONE"):
77
+ raise InvalidRequestError("Streaming requires a mono, 16 kHz, PCM16 WAV.")
78
+ if handle.getnframes() == 0:
79
+ raise InvalidRequestError("Audio must not be empty.")
80
+ remaining = handle.getnframes()
81
+ while remaining:
82
+ frames = min(chunk_ms * 16, remaining)
83
+ data = handle.readframes(frames)
84
+ if len(data) != frames * 2:
85
+ raise InvalidRequestError("The WAV file is truncated.")
86
+ remaining -= frames
87
+ yield data
tazo/_http.py ADDED
@@ -0,0 +1,327 @@
1
+ """Shared request policy with native sync and async HTTP adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import builtins
7
+ import math
8
+ import os
9
+ import random
10
+ import time
11
+ from datetime import UTC, datetime
12
+ from email.utils import parsedate_to_datetime
13
+ from typing import TypeVar
14
+ from urllib.parse import urlsplit
15
+
16
+ import httpx
17
+ from pydantic import BaseModel, ValidationError
18
+
19
+ from ._audio import Upload
20
+ from ._version import __version__
21
+ from .errors import (
22
+ AuthenticationError,
23
+ ConnectionError,
24
+ DecisionPendingError,
25
+ IdempotencyConflictError,
26
+ InvalidRequestError,
27
+ NotFoundError,
28
+ PermissionDeniedError,
29
+ ProtocolError,
30
+ RateLimitError,
31
+ RequestTooLargeError,
32
+ ServiceError,
33
+ TazoError,
34
+ TimeoutError,
35
+ )
36
+ from .types import DecisionResponse
37
+
38
+ ResponseT = TypeVar("ResponseT", bound=BaseModel)
39
+ DEFAULT_TIMEOUT = httpx.Timeout(connect=5, read=65, write=30, pool=5)
40
+
41
+
42
+ def retry_after(value: str | None) -> float | None:
43
+ if not value:
44
+ return None
45
+ try:
46
+ delay = float(value)
47
+ except ValueError:
48
+ try:
49
+ instant = parsedate_to_datetime(value)
50
+ if instant.tzinfo is None:
51
+ instant = instant.replace(tzinfo=UTC)
52
+ delay = (instant - datetime.now(UTC)).total_seconds()
53
+ except (TypeError, ValueError, OverflowError):
54
+ return None
55
+ return max(0, delay) if math.isfinite(delay) else None
56
+
57
+
58
+ def response_error(response: httpx.Response, key: str | None) -> TazoError:
59
+ code = None
60
+ message = f"Tazo returned HTTP {response.status_code}."
61
+ retryable = response.status_code in {408, 429, 500, 502, 503, 504}
62
+ request_id = response.headers.get("X-Tazo-Request-Id")
63
+ try:
64
+ body = response.json()
65
+ except ValueError:
66
+ body = None
67
+ if isinstance(body, dict):
68
+ request_id = request_id or (
69
+ body.get("request_id") if isinstance(body.get("request_id"), str) else None
70
+ )
71
+ error = body.get("error")
72
+ if isinstance(error, dict):
73
+ code = error.get("code") if isinstance(error.get("code"), str) else None
74
+ if isinstance(error.get("message"), str):
75
+ message = error["message"]
76
+ if isinstance(error.get("retryable"), bool):
77
+ retryable = error["retryable"]
78
+ cls: type[TazoError] = {
79
+ 400: InvalidRequestError,
80
+ 401: AuthenticationError,
81
+ 403: PermissionDeniedError,
82
+ 404: NotFoundError,
83
+ 409: IdempotencyConflictError,
84
+ 413: RequestTooLargeError,
85
+ 422: InvalidRequestError,
86
+ 429: RateLimitError,
87
+ }.get(response.status_code, ServiceError)
88
+ # Authentication and invalid input must never cause retry loops, even if a
89
+ # malformed server response sets retryable=true.
90
+ if response.status_code in {400, 401, 403, 404, 409, 413, 422}:
91
+ retryable = False
92
+ if code == "decision_pending":
93
+ cls = DecisionPendingError
94
+ return cls(
95
+ message,
96
+ status_code=response.status_code,
97
+ code=code,
98
+ request_id=request_id,
99
+ idempotency_key=key,
100
+ retryable=retryable,
101
+ retry_after=retry_after(response.headers.get("Retry-After")),
102
+ )
103
+
104
+
105
+ class HTTPPolicy:
106
+ def __init__(
107
+ self,
108
+ *,
109
+ api_key: str | None,
110
+ base_url: str,
111
+ timeout: float | httpx.Timeout,
112
+ max_retries: int,
113
+ operation_timeout: float,
114
+ ) -> None:
115
+ parsed = urlsplit(base_url)
116
+ if (
117
+ parsed.scheme not in {"http", "https"}
118
+ or not parsed.netloc
119
+ or parsed.username
120
+ or parsed.password
121
+ or parsed.query
122
+ or parsed.fragment
123
+ ):
124
+ raise ValueError(
125
+ "base_url must be an HTTP(S) URL without credentials, query, or fragment."
126
+ )
127
+ if isinstance(max_retries, bool) or not isinstance(max_retries, int) or max_retries < 0:
128
+ raise ValueError("max_retries must be a nonnegative integer.")
129
+ if not math.isfinite(operation_timeout) or operation_timeout <= 0:
130
+ raise ValueError("operation_timeout must be positive and finite.")
131
+ self.base_url = parsed.geturl().rstrip("/")
132
+ self._api_key = api_key if api_key is not None else os.getenv("TAZO_API_KEY")
133
+ if self._api_key is not None and (
134
+ not self._api_key.strip()
135
+ or not self._api_key.isascii()
136
+ or any(ord(c) < 32 or ord(c) == 127 for c in self._api_key)
137
+ ):
138
+ raise ValueError("api_key must contain printable ASCII characters.")
139
+ self.timeout = timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(timeout)
140
+ self.max_retries = max_retries
141
+ self.operation_timeout = operation_timeout
142
+ self.closed = False
143
+
144
+ def credential(self) -> str:
145
+ if not self._api_key:
146
+ raise AuthenticationError("Set TAZO_API_KEY or pass api_key to the client.")
147
+ return self._api_key
148
+
149
+ def headers(self, *, authenticated: bool, key: str | None) -> dict[str, str]:
150
+ if self.closed:
151
+ raise TazoError("This client is closed.")
152
+ headers = {"User-Agent": f"tazo-python/{__version__}", "Accept": "application/json"}
153
+ if authenticated:
154
+ headers["Authorization"] = f"Bearer {self.credential()}"
155
+ if key is not None:
156
+ headers["Idempotency-Key"] = key
157
+ return headers
158
+
159
+ def attempt_timeout(self, remaining: float) -> httpx.Timeout:
160
+ def cap(value: float | None) -> float:
161
+ return remaining if value is None else min(value, remaining)
162
+
163
+ return httpx.Timeout(
164
+ **{
165
+ name: cap(getattr(self.timeout, name))
166
+ for name in ("connect", "read", "write", "pool")
167
+ }
168
+ )
169
+
170
+ def delay(self, error: TazoError, attempt: int) -> float:
171
+ if error.retry_after is not None:
172
+ return error.retry_after
173
+ return random.uniform(0, min(8, 0.5 * 2 ** min(attempt, 8)))
174
+
175
+ @staticmethod
176
+ def decode(response: httpx.Response, model: type[ResponseT], key: str | None) -> ResponseT:
177
+ try:
178
+ result = model.model_validate_json(response.content)
179
+ except ValidationError:
180
+ raise ProtocolError(
181
+ "Tazo returned an invalid response.",
182
+ status_code=response.status_code,
183
+ request_id=response.headers.get("X-Tazo-Request-Id"),
184
+ idempotency_key=key,
185
+ ) from None
186
+ if isinstance(result, DecisionResponse):
187
+ result.request_id = response.headers.get("X-Tazo-Request-Id")
188
+ result.idempotency_key = key
189
+ return result
190
+
191
+ @staticmethod
192
+ def deadline_error(key: str | None, last: TazoError | None) -> TazoError:
193
+ if last is not None:
194
+ return last
195
+ return TimeoutError(
196
+ "The operation deadline was reached; accepted decision work may continue.",
197
+ idempotency_key=key,
198
+ retryable=True,
199
+ )
200
+
201
+
202
+ class HTTPClient(HTTPPolicy):
203
+ def __init__(self, *, http_client: httpx.Client | None = None, **kwargs: object) -> None:
204
+ # Public constructors provide the typed policy arguments explicitly.
205
+ super().__init__(**kwargs) # type: ignore[arg-type]
206
+ self._client = http_client or httpx.Client()
207
+ self._owns_client = http_client is None
208
+
209
+ def close(self) -> None:
210
+ if not self.closed and self._owns_client:
211
+ self._client.close()
212
+ self.closed = True
213
+
214
+ def request(
215
+ self,
216
+ method: str,
217
+ path: str,
218
+ model: type[ResponseT],
219
+ *,
220
+ authenticated: bool = True,
221
+ key: str | None = None,
222
+ request_json: str | None = None,
223
+ upload: Upload | None = None,
224
+ health: bool = False,
225
+ ) -> ResponseT:
226
+ headers = self.headers(authenticated=authenticated, key=key)
227
+ deadline = time.monotonic() + self.operation_timeout
228
+ last: TazoError | None = None
229
+ for attempt in range(self.max_retries + 1):
230
+ remaining = deadline - time.monotonic()
231
+ if remaining <= 0:
232
+ raise self.deadline_error(key, last)
233
+ try:
234
+ response = self._client.request(
235
+ method,
236
+ f"{self.base_url}{path}",
237
+ headers=headers,
238
+ timeout=self.attempt_timeout(remaining),
239
+ follow_redirects=False,
240
+ files=None
241
+ if upload is None
242
+ else {
243
+ "request": (None, request_json or "", "application/json"),
244
+ "audio": (upload.filename, upload.data, upload.content_type),
245
+ },
246
+ )
247
+ except httpx.TimeoutException:
248
+ last = TimeoutError("Tazo request timed out.", idempotency_key=key, retryable=True)
249
+ except httpx.TransportError:
250
+ last = ConnectionError(
251
+ "Could not connect to Tazo.", idempotency_key=key, retryable=True
252
+ )
253
+ else:
254
+ if response.status_code == 200 or (health and response.status_code == 503):
255
+ return self.decode(response, model, key)
256
+ last = response_error(response, key)
257
+ if not last.retryable or attempt == self.max_retries:
258
+ raise last
259
+ delay = self.delay(last, attempt)
260
+ if delay >= deadline - time.monotonic():
261
+ raise last
262
+ time.sleep(delay)
263
+ raise AssertionError("Unreachable retry state")
264
+
265
+
266
+ class AsyncHTTPClient(HTTPPolicy):
267
+ def __init__(self, *, http_client: httpx.AsyncClient | None = None, **kwargs: object) -> None:
268
+ super().__init__(**kwargs) # type: ignore[arg-type]
269
+ self._client = http_client or httpx.AsyncClient()
270
+ self._owns_client = http_client is None
271
+
272
+ async def close(self) -> None:
273
+ if not self.closed and self._owns_client:
274
+ await self._client.aclose()
275
+ self.closed = True
276
+
277
+ async def request(
278
+ self,
279
+ method: str,
280
+ path: str,
281
+ model: type[ResponseT],
282
+ *,
283
+ authenticated: bool = True,
284
+ key: str | None = None,
285
+ request_json: str | None = None,
286
+ upload: Upload | None = None,
287
+ health: bool = False,
288
+ ) -> ResponseT:
289
+ headers = self.headers(authenticated=authenticated, key=key)
290
+ deadline = time.monotonic() + self.operation_timeout
291
+ last: TazoError | None = None
292
+ for attempt in range(self.max_retries + 1):
293
+ remaining = deadline - time.monotonic()
294
+ if remaining <= 0:
295
+ raise self.deadline_error(key, last)
296
+ try:
297
+ async with asyncio.timeout(remaining):
298
+ response = await self._client.request(
299
+ method,
300
+ f"{self.base_url}{path}",
301
+ headers=headers,
302
+ timeout=self.attempt_timeout(remaining),
303
+ follow_redirects=False,
304
+ files=None
305
+ if upload is None
306
+ else {
307
+ "request": (None, request_json or "", "application/json"),
308
+ "audio": (upload.filename, upload.data, upload.content_type),
309
+ },
310
+ )
311
+ except (httpx.TimeoutException, builtins.TimeoutError):
312
+ last = TimeoutError("Tazo request timed out.", idempotency_key=key, retryable=True)
313
+ except httpx.TransportError:
314
+ last = ConnectionError(
315
+ "Could not connect to Tazo.", idempotency_key=key, retryable=True
316
+ )
317
+ else:
318
+ if response.status_code == 200 or (health and response.status_code == 503):
319
+ return self.decode(response, model, key)
320
+ last = response_error(response, key)
321
+ if not last.retryable or attempt == self.max_retries:
322
+ raise last
323
+ delay = self.delay(last, attempt)
324
+ if delay >= deadline - time.monotonic():
325
+ raise last
326
+ await asyncio.sleep(delay)
327
+ raise AssertionError("Unreachable retry state")
tazo/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
tazo/client.py ADDED
@@ -0,0 +1,99 @@
1
+ """Public client configuration and connection ownership."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ from ._http import DEFAULT_TIMEOUT, AsyncHTTPClient, HTTPClient
8
+ from .resources import AsyncDecisions, AsyncHealth, AsyncModels, Decisions, Health, Models
9
+
10
+
11
+ class Tazo:
12
+ """Synchronous client for the four public Tazo operations.
13
+
14
+ Args:
15
+ api_key: Bearer credential, or read TAZO_API_KEY. May be omitted for health.
16
+ base_url: API origin, optionally with a proxy path prefix; no /v1 suffix.
17
+ timeout: Per-network-phase timeout in seconds, or an httpx.Timeout.
18
+ max_retries: Additional HTTP attempts after the first (default two).
19
+ operation_timeout: HTTP retry budget in seconds (default 180). Each network
20
+ phase is capped by the remaining budget. A synchronous in-flight request
21
+ can outlast this budget across multiple network phases/body chunks.
22
+ http_client: Optional caller-owned HTTPX client; the SDK will not close it.
23
+
24
+ Use as a context manager, or call close. Streaming sockets have their own
25
+ context managers and must be closed before closing this client.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ *,
31
+ api_key: str | None = None,
32
+ base_url: str = "https://api.tazolabs.com",
33
+ timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
34
+ max_retries: int = 2,
35
+ operation_timeout: float = 180,
36
+ http_client: httpx.Client | None = None,
37
+ ) -> None:
38
+ self._http = HTTPClient(
39
+ api_key=api_key,
40
+ base_url=base_url,
41
+ timeout=timeout,
42
+ max_retries=max_retries,
43
+ operation_timeout=operation_timeout,
44
+ http_client=http_client,
45
+ )
46
+ self.decisions = Decisions(self._http)
47
+ self.models = Models(self._http)
48
+ self.health = Health(self._http)
49
+
50
+ def close(self) -> None:
51
+ """Release the SDK-owned HTTP pool. Safe to call more than once."""
52
+ self._http.close()
53
+
54
+ def __enter__(self) -> Tazo:
55
+ return self
56
+
57
+ def __exit__(self, *args: object) -> None:
58
+ self.close()
59
+
60
+
61
+ class AsyncTazo:
62
+ """Native async client with the same configuration as Tazo.
63
+
64
+ Await create, list, and check. Use async with for streaming sessions.
65
+ operation_timeout also enforces an async deadline during each HTTP attempt.
66
+ Supplied HTTPX clients remain caller-owned.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ *,
72
+ api_key: str | None = None,
73
+ base_url: str = "https://api.tazolabs.com",
74
+ timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
75
+ max_retries: int = 2,
76
+ operation_timeout: float = 180,
77
+ http_client: httpx.AsyncClient | None = None,
78
+ ) -> None:
79
+ self._http = AsyncHTTPClient(
80
+ api_key=api_key,
81
+ base_url=base_url,
82
+ timeout=timeout,
83
+ max_retries=max_retries,
84
+ operation_timeout=operation_timeout,
85
+ http_client=http_client,
86
+ )
87
+ self.decisions = AsyncDecisions(self._http)
88
+ self.models = AsyncModels(self._http)
89
+ self.health = AsyncHealth(self._http)
90
+
91
+ async def close(self) -> None:
92
+ """Release the SDK-owned HTTP pool."""
93
+ await self._http.close()
94
+
95
+ async def __aenter__(self) -> AsyncTazo:
96
+ return self
97
+
98
+ async def __aexit__(self, *args: object) -> None:
99
+ await self.close()
tazo/errors.py ADDED
@@ -0,0 +1,84 @@
1
+ """Exceptions shared by the sync and async clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class TazoError(Exception):
7
+ """Base SDK error with recovery metadata and no stored credentials or audio."""
8
+
9
+ def __init__(
10
+ self,
11
+ message: str,
12
+ *,
13
+ status_code: int | None = None,
14
+ code: str | None = None,
15
+ request_id: str | None = None,
16
+ idempotency_key: str | None = None,
17
+ retryable: bool = False,
18
+ retry_after: float | None = None,
19
+ ) -> None:
20
+ super().__init__(message)
21
+ self.status_code = status_code
22
+ self.code = code
23
+ self.request_id = request_id
24
+ self.idempotency_key = idempotency_key
25
+ self.retryable = retryable
26
+ self.retry_after = retry_after
27
+
28
+
29
+ class AuthenticationError(TazoError):
30
+ """Missing or rejected bearer credential."""
31
+
32
+
33
+ class PermissionDeniedError(TazoError):
34
+ """The authenticated caller lacks access."""
35
+
36
+
37
+ class InvalidRequestError(TazoError):
38
+ """Invalid local input or an HTTP 400/422 rejection."""
39
+
40
+
41
+ class NotFoundError(TazoError):
42
+ """The requested model was not found."""
43
+
44
+
45
+ class RequestTooLargeError(InvalidRequestError):
46
+ """Audio or request exceeds the service's size limit."""
47
+
48
+
49
+ class RateLimitError(TazoError):
50
+ """Account admission limit reached; inspect retry_after."""
51
+
52
+
53
+ class IdempotencyConflictError(TazoError):
54
+ """An operation key was reused with different input."""
55
+
56
+
57
+ class DecisionPendingError(TazoError):
58
+ """Accepted work continues. Retain the key and retry the same input later."""
59
+
60
+
61
+ class ConnectionError(TazoError):
62
+ """Transport connection failed; acceptance may be uncertain for writes."""
63
+
64
+
65
+ class TimeoutError(TazoError):
66
+ """A network timeout or the overall operation deadline was reached."""
67
+
68
+
69
+ class ProtocolError(TazoError):
70
+ """The server response doesn't match the public contract."""
71
+
72
+
73
+ class ServiceError(TazoError):
74
+ """The service returned an unsuccessful response."""
75
+
76
+
77
+ class StreamingError(TazoError):
78
+ """A fatal streaming failure or unexpected disconnect; audio is never replayed."""
79
+
80
+ def __init__(
81
+ self, message: str, *, code: str | None = None, close_code: int | None = None
82
+ ) -> None:
83
+ super().__init__(message, code=code)
84
+ self.close_code = close_code
tazo/py.typed ADDED
File without changes