talqing 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.
talqing/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """Python client for the Talqing API.
2
+
3
+ ```python
4
+ from talqing import Talqing
5
+
6
+ with Talqing.from_env() as talqing:
7
+ agent = talqing.agents.get(agent_id)
8
+ ```
9
+
10
+ The API surface under `talqing.agents`, `talqing.calls` and the rest is
11
+ generated from `openapi/openapi.json` — it is the same set of operations, under
12
+ the same names, as the TypeScript SDK. Every request and response shape is
13
+ exported from here too, as a TypedDict.
14
+ """
15
+
16
+ from ._transport import OMIT, AsyncStream, Stream
17
+ from ._version import __version__
18
+ from .client import AsyncTalqing, Talqing, paginate, paginate_async
19
+ from .errors import TalqingAPIError
20
+ from .gen.types import * # noqa: F403
21
+ from .gen.types import __all__ as _types
22
+
23
+ __all__ = [
24
+ "AsyncStream",
25
+ "AsyncTalqing",
26
+ "OMIT",
27
+ "Stream",
28
+ "Talqing",
29
+ "TalqingAPIError",
30
+ "__version__",
31
+ "paginate",
32
+ "paginate_async",
33
+ *_types,
34
+ ]
talqing/_transport.py ADDED
@@ -0,0 +1,361 @@
1
+ """The half of the SDK the OpenAPI document cannot describe: the HTTP itself.
2
+
3
+ `gen/` says what every operation is; this says how one is sent. It carries the
4
+ credentials, turns a non-2xx into `TalqingAPIError`, reads an event stream, and
5
+ holds the sentinel that separates "leave this alone" from "set it to null".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json as jsonlib
11
+ from types import TracebackType
12
+ from typing import Any, AsyncIterator, Generic, Iterator, Literal, Mapping, TypeVar
13
+ from urllib.parse import quote
14
+
15
+ import httpx
16
+
17
+ from ._version import __version__
18
+ from .errors import TalqingAPIError
19
+
20
+ T = TypeVar("T")
21
+
22
+ Expect = Literal["json", "none", "bytes"]
23
+
24
+
25
+ class _Omit:
26
+ """The absence of an argument, which is not the same as `None`.
27
+
28
+ Several PATCH bodies take `null` as a real value — `orgs.update(
29
+ retention_days=None)` means "keep everything forever" — so a default of
30
+ `None` would leave "leave the policy alone" unsayable. An argument still at
31
+ `OMIT` is not sent at all, and the server applies its own default.
32
+ """
33
+
34
+ __slots__ = ()
35
+
36
+ def __repr__(self) -> str:
37
+ return "OMIT"
38
+
39
+ def __bool__(self) -> bool:
40
+ return False
41
+
42
+
43
+ # Typed `Any` deliberately: this is the default of every optional argument in
44
+ # `gen/`, and typing it as itself would put `| _Omit` in three hundred
45
+ # signatures for no reader's benefit.
46
+ OMIT: Any = _Omit()
47
+
48
+
49
+ def encode_path(value: Any) -> str:
50
+ """One path segment, escaped. `safe=""` because a path parameter is never a path."""
51
+ return quote(str(value), safe="")
52
+
53
+
54
+ def sent(values: Mapping[str, Any] | None) -> dict[str, Any] | None:
55
+ """Drop the arguments the caller never passed."""
56
+ if values is None:
57
+ return None
58
+ return {key: value for key, value in values.items() if value is not OMIT}
59
+
60
+
61
+ def raise_for_status(response: httpx.Response) -> None:
62
+ if response.is_success:
63
+ return
64
+ # The contract is strict: the body is always {"detail": {"message", "errors"}}.
65
+ # The fallback below is for a failure that never reached the API at all — a
66
+ # proxy's HTML 502, a gateway timeout — not for a second response shape.
67
+ detail: Any
68
+ errors: list[str] = []
69
+ try:
70
+ detail = response.json()["detail"]
71
+ message = detail["message"]
72
+ errors = [str(error) for error in detail["errors"]]
73
+ except (ValueError, KeyError, TypeError):
74
+ detail = response.text or response.reason_phrase
75
+ message = detail
76
+ raise TalqingAPIError(
77
+ message,
78
+ status_code=response.status_code,
79
+ detail=detail,
80
+ errors=errors,
81
+ response=response,
82
+ )
83
+
84
+
85
+ class _Decoder:
86
+ """The `text/event-stream` state machine, fed one line at a time.
87
+
88
+ A class rather than a generator because `async for` cannot drive a
89
+ synchronous one, and the parsing itself must not be written twice.
90
+
91
+ The `event:` line is not read. Every frame repeats its own name in the
92
+ JSON's `event` field — what the unions in `gen/types.py` discriminate on —
93
+ so reading it twice would only create somewhere for the two to disagree.
94
+ """
95
+
96
+ def __init__(self) -> None:
97
+ self._data: list[str] = []
98
+
99
+ def feed(self, line: str) -> list[Any]:
100
+ """The frames this line completed: none, or one. A list, so "not yet"
101
+ needs no sentinel to tell it from a frame that decoded to `None`."""
102
+ if line == "":
103
+ return self.flush()
104
+ if line.startswith(":"):
105
+ return [] # a keep-alive, sent every 15s so idle proxies hold on
106
+ field, _, value = line.partition(":")
107
+ if field == "data":
108
+ self._data.append(value[1:] if value.startswith(" ") else value)
109
+ return []
110
+
111
+ def flush(self) -> list[Any]:
112
+ if not self._data:
113
+ return []
114
+ frame = jsonlib.loads("\n".join(self._data))
115
+ self._data = []
116
+ return [frame]
117
+
118
+
119
+ def _options(expect: Expect) -> dict[str, Any]:
120
+ """The request options that depend on what is coming back.
121
+
122
+ The two recording endpoints answer `302` and the media is what the caller
123
+ asked for, so those follow the redirect. Nothing else in the API redirects,
124
+ and a POST that silently followed one would be a surprise worth avoiding.
125
+ httpx drops the Authorization header when a redirect leaves our origin,
126
+ which is what makes handing the request on to object storage safe.
127
+ """
128
+ binary = expect == "bytes"
129
+ return {
130
+ "headers": {"Accept": "*/*" if binary else "application/json"},
131
+ "follow_redirects": binary,
132
+ }
133
+
134
+
135
+ def _decode(response: httpx.Response, expect: Expect) -> Any:
136
+ if expect == "none":
137
+ return None
138
+ if expect == "bytes":
139
+ return response.content
140
+ return response.json()
141
+
142
+
143
+ def _headers(token: str, extra: Mapping[str, str] | None) -> dict[str, str]:
144
+ headers = {
145
+ "Authorization": f"Bearer {token}",
146
+ "User-Agent": f"talqing-python/{__version__}",
147
+ }
148
+ headers.update(extra or {})
149
+ return headers
150
+
151
+
152
+ class Stream(Generic[T]):
153
+ """An open `text/event-stream`, as an iterator of decoded frames.
154
+
155
+ Iterating opens the connection and closes it when the stream ends or the
156
+ loop is left. Use it as a context manager when the loop may exit early::
157
+
158
+ with talqing.conversations.events(conversation_id) as events:
159
+ for event in events:
160
+ if event["event"] == "turn":
161
+ break
162
+ """
163
+
164
+ def __init__(
165
+ self, client: httpx.Client, url: str, params: Mapping[str, Any] | None
166
+ ) -> None:
167
+ self._context = client.stream(
168
+ "GET", url, params=params, headers={"Accept": "text/event-stream"}
169
+ )
170
+ self._response: httpx.Response | None = None
171
+ self._spent = False
172
+
173
+ def __enter__(self) -> "Stream[T]":
174
+ self._open()
175
+ return self
176
+
177
+ def __exit__(
178
+ self,
179
+ kind: type[BaseException] | None,
180
+ error: BaseException | None,
181
+ trace: TracebackType | None,
182
+ ) -> None:
183
+ self.close()
184
+
185
+ def __iter__(self) -> Iterator[T]:
186
+ self._open()
187
+ assert self._response is not None
188
+ decoder = _Decoder()
189
+ try:
190
+ for line in self._response.iter_lines():
191
+ yield from decoder.feed(line)
192
+ yield from decoder.flush()
193
+ finally:
194
+ self.close()
195
+
196
+ def _open(self) -> None:
197
+ if self._response is not None:
198
+ return
199
+ if self._spent:
200
+ raise RuntimeError("this stream is finished; open a new one to watch again")
201
+ self._response = self._context.__enter__()
202
+ if not self._response.is_success:
203
+ # Nothing has been read yet, so the error body is still on the wire.
204
+ self._response.read()
205
+ try:
206
+ raise_for_status(self._response)
207
+ finally:
208
+ self.close()
209
+
210
+ def close(self) -> None:
211
+ if self._response is None:
212
+ return
213
+ self._response = None
214
+ self._spent = True
215
+ self._context.__exit__(None, None, None)
216
+
217
+
218
+ class AsyncStream(Generic[T]):
219
+ """`Stream`, awaited. See it for the shape; this differs only in `async for`."""
220
+
221
+ def __init__(
222
+ self, client: httpx.AsyncClient, url: str, params: Mapping[str, Any] | None
223
+ ) -> None:
224
+ self._context = client.stream(
225
+ "GET", url, params=params, headers={"Accept": "text/event-stream"}
226
+ )
227
+ self._response: httpx.Response | None = None
228
+ self._spent = False
229
+
230
+ async def __aenter__(self) -> "AsyncStream[T]":
231
+ await self._open()
232
+ return self
233
+
234
+ async def __aexit__(
235
+ self,
236
+ kind: type[BaseException] | None,
237
+ error: BaseException | None,
238
+ trace: TracebackType | None,
239
+ ) -> None:
240
+ await self.aclose()
241
+
242
+ async def __aiter__(self) -> AsyncIterator[T]:
243
+ await self._open()
244
+ assert self._response is not None
245
+ decoder = _Decoder()
246
+ try:
247
+ async for line in self._response.aiter_lines():
248
+ for frame in decoder.feed(line):
249
+ yield frame
250
+ for frame in decoder.flush():
251
+ yield frame
252
+ finally:
253
+ await self.aclose()
254
+
255
+ async def _open(self) -> None:
256
+ if self._response is not None:
257
+ return
258
+ if self._spent:
259
+ raise RuntimeError("this stream is finished; open a new one to watch again")
260
+ self._response = await self._context.__aenter__()
261
+ if not self._response.is_success:
262
+ await self._response.aread()
263
+ try:
264
+ raise_for_status(self._response)
265
+ finally:
266
+ await self.aclose()
267
+
268
+ async def aclose(self) -> None:
269
+ if self._response is None:
270
+ return
271
+ self._response = None
272
+ self._spent = True
273
+ await self._context.__aexit__(None, None, None)
274
+
275
+
276
+ class Transport:
277
+ """One configured `httpx.Client`, and the two things every method needs."""
278
+
279
+ def __init__(
280
+ self,
281
+ *,
282
+ base_url: str,
283
+ token: str,
284
+ timeout: float | httpx.Timeout,
285
+ headers: Mapping[str, str] | None,
286
+ transport: httpx.BaseTransport | None,
287
+ ) -> None:
288
+ self.base_url = base_url
289
+ self.http = httpx.Client(
290
+ base_url=base_url,
291
+ timeout=timeout,
292
+ transport=transport,
293
+ headers=_headers(token, headers),
294
+ )
295
+
296
+ def request(
297
+ self,
298
+ method: str,
299
+ path: str,
300
+ *,
301
+ query: Mapping[str, Any] | None = None,
302
+ body: Mapping[str, Any] | None = None,
303
+ expect: Expect = "json",
304
+ ) -> Any:
305
+ response = self.http.request(
306
+ method, path, params=sent(query), json=sent(body), **_options(expect)
307
+ )
308
+ raise_for_status(response)
309
+ return _decode(response, expect)
310
+
311
+ def stream(
312
+ self, path: str, *, query: Mapping[str, Any] | None = None
313
+ ) -> Stream[Any]:
314
+ return Stream(self.http, path, sent(query))
315
+
316
+ def close(self) -> None:
317
+ self.http.close()
318
+
319
+
320
+ class AsyncTransport:
321
+ """`Transport`, awaited."""
322
+
323
+ def __init__(
324
+ self,
325
+ *,
326
+ base_url: str,
327
+ token: str,
328
+ timeout: float | httpx.Timeout,
329
+ headers: Mapping[str, str] | None,
330
+ transport: httpx.AsyncBaseTransport | None,
331
+ ) -> None:
332
+ self.base_url = base_url
333
+ self.http = httpx.AsyncClient(
334
+ base_url=base_url,
335
+ timeout=timeout,
336
+ transport=transport,
337
+ headers=_headers(token, headers),
338
+ )
339
+
340
+ async def request(
341
+ self,
342
+ method: str,
343
+ path: str,
344
+ *,
345
+ query: Mapping[str, Any] | None = None,
346
+ body: Mapping[str, Any] | None = None,
347
+ expect: Expect = "json",
348
+ ) -> Any:
349
+ response = await self.http.request(
350
+ method, path, params=sent(query), json=sent(body), **_options(expect)
351
+ )
352
+ raise_for_status(response)
353
+ return _decode(response, expect)
354
+
355
+ def stream(
356
+ self, path: str, *, query: Mapping[str, Any] | None = None
357
+ ) -> AsyncStream[Any]:
358
+ return AsyncStream(self.http, path, sent(query))
359
+
360
+ async def close(self) -> None:
361
+ await self.http.aclose()
talqing/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ # The one place the version is written. `pyproject.toml` reads it from here, and
2
+ # so does the User-Agent every request carries.
3
+ __version__ = "0.1.0"
talqing/client.py ADDED
@@ -0,0 +1,280 @@
1
+ """The client itself: credentials, the two browser redirects, and pagination.
2
+
3
+ Every one of the API's operations is generated into `gen/` from
4
+ `openapi/openapi.json`. What is left here is only what the document cannot say.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from types import TracebackType
11
+ from typing import Any, AsyncIterator, Awaitable, Callable, Iterator, Mapping, TypeVar
12
+ from urllib.parse import quote, urlencode
13
+
14
+ import httpx
15
+
16
+ from ._transport import AsyncTransport, Transport
17
+ from .gen.api import TalqingApi
18
+ from .gen.async_api import AsyncTalqingApi
19
+ from .gen.types import Page
20
+
21
+ T = TypeVar("T")
22
+
23
+ TOKEN_ENV = "TALQING_API_KEY"
24
+ BASE_URL_ENV = "TALQING_BASE_URL"
25
+
26
+
27
+ def _token(value: str | None) -> str:
28
+ if value and value.strip():
29
+ return value.strip()
30
+ raise ValueError(
31
+ "a Talqing personal access token is required: pass token=..., or set "
32
+ f"{TOKEN_ENV} and use Talqing.from_env(). Create one on the dashboard's "
33
+ "Tokens page."
34
+ )
35
+
36
+
37
+ def _base_url(value: str | None) -> str:
38
+ if value and value.strip():
39
+ return value.strip().rstrip("/")
40
+ # No default, deliberately. A client that quietly points at localhost fails
41
+ # in production as a connection error three layers down; one that refuses to
42
+ # start says what is actually wrong.
43
+ raise ValueError(
44
+ "base_url is required: pass base_url='https://api.in.talqing.com', or "
45
+ f"set {BASE_URL_ENV} and use Talqing.from_env()."
46
+ )
47
+
48
+
49
+ # Both routes are `include_in_schema=False` in the backend because they are
50
+ # browser redirects, not operations, so nothing generates them — which is also
51
+ # why the two clients must not each carry their own copy of the path.
52
+ def _google_login_url(base_url: str) -> str:
53
+ return f"{base_url}/v1/auth/google/start"
54
+
55
+
56
+ def _oauth_start_url(base_url: str, provider: str, integration_id: str | None) -> str:
57
+ url = f"{base_url}/v1/integrations/oauth/{quote(provider, safe='')}/start"
58
+ if integration_id:
59
+ url += "?" + urlencode({"integration_id": integration_id})
60
+ return url
61
+
62
+
63
+ def _from_env(overrides: dict[str, Any]) -> dict[str, Any]:
64
+ return {
65
+ "token": os.getenv(TOKEN_ENV),
66
+ "base_url": os.getenv(BASE_URL_ENV),
67
+ **overrides,
68
+ }
69
+
70
+
71
+ class Talqing(TalqingApi):
72
+ """A synchronous client for the Talqing API.
73
+
74
+ ```python
75
+ with Talqing(token=..., base_url="https://api.in.talqing.com") as talqing:
76
+ agent = talqing.agents.get(agent_id)
77
+ ```
78
+
79
+ Operations are reached by resource, the way `backend/api/sdk_surface.py`
80
+ names them: `talqing.agents.versions.rollback(agent_id, 3)`. Every one of
81
+ them raises `TalqingAPIError` on a non-2xx and returns the decoded body
82
+ otherwise.
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ token: str | None = None,
88
+ *,
89
+ base_url: str | None = None,
90
+ timeout: float | httpx.Timeout = 30.0,
91
+ headers: Mapping[str, str] | None = None,
92
+ transport: httpx.BaseTransport | None = None,
93
+ ) -> None:
94
+ super().__init__(
95
+ Transport(
96
+ base_url=_base_url(base_url),
97
+ token=_token(token),
98
+ timeout=timeout,
99
+ headers=headers,
100
+ transport=transport,
101
+ )
102
+ )
103
+
104
+ @classmethod
105
+ def from_env(cls, **overrides: Any) -> "Talqing":
106
+ """Build a client from `TALQING_API_KEY` and `TALQING_BASE_URL`.
107
+
108
+ Anything passed here wins over the environment.
109
+ """
110
+ return cls(**_from_env(overrides))
111
+
112
+ @property
113
+ def base_url(self) -> str:
114
+ return self._t.base_url
115
+
116
+ @property
117
+ def http(self) -> httpx.Client:
118
+ """The underlying `httpx.Client`, already carrying the base URL and token."""
119
+ return self._t.http
120
+
121
+ def request(
122
+ self,
123
+ method: str,
124
+ path: str,
125
+ *,
126
+ query: Mapping[str, Any] | None = None,
127
+ body: Mapping[str, Any] | None = None,
128
+ ) -> Any:
129
+ """Call a path directly, with this client's credentials and error handling.
130
+
131
+ The escape hatch for an endpoint that shipped since this SDK was
132
+ generated. Everything the document knows about already has a method.
133
+ """
134
+ return self._t.request(method, path, query=query, body=body)
135
+
136
+ def google_login_url(self) -> str:
137
+ """Where to send a browser to start Google sign-in."""
138
+ return _google_login_url(self.base_url)
139
+
140
+ def oauth_start_url(self, provider: str, integration_id: str | None = None) -> str:
141
+ """Where to send a browser to authorize an integration."""
142
+ return _oauth_start_url(self.base_url, provider, integration_id)
143
+
144
+ def close(self) -> None:
145
+ self._t.close()
146
+
147
+ def __enter__(self) -> "Talqing":
148
+ return self
149
+
150
+ def __exit__(
151
+ self,
152
+ kind: type[BaseException] | None,
153
+ error: BaseException | None,
154
+ trace: TracebackType | None,
155
+ ) -> None:
156
+ self.close()
157
+
158
+
159
+ class AsyncTalqing(AsyncTalqingApi):
160
+ """`Talqing`, awaited. See it for the shape; every operation here is a coroutine.
161
+
162
+ ```python
163
+ async with AsyncTalqing.from_env() as talqing:
164
+ agents = await talqing.agents.list()
165
+ ```
166
+
167
+ The exception is a stream, which is not a coroutine on either client:
168
+ `async for event in talqing.conversations.events(conversation_id)`.
169
+ """
170
+
171
+ def __init__(
172
+ self,
173
+ token: str | None = None,
174
+ *,
175
+ base_url: str | None = None,
176
+ timeout: float | httpx.Timeout = 30.0,
177
+ headers: Mapping[str, str] | None = None,
178
+ transport: httpx.AsyncBaseTransport | None = None,
179
+ ) -> None:
180
+ super().__init__(
181
+ AsyncTransport(
182
+ base_url=_base_url(base_url),
183
+ token=_token(token),
184
+ timeout=timeout,
185
+ headers=headers,
186
+ transport=transport,
187
+ )
188
+ )
189
+
190
+ @classmethod
191
+ def from_env(cls, **overrides: Any) -> "AsyncTalqing":
192
+ """Build a client from `TALQING_API_KEY` and `TALQING_BASE_URL`."""
193
+ return cls(**_from_env(overrides))
194
+
195
+ @property
196
+ def base_url(self) -> str:
197
+ return self._t.base_url
198
+
199
+ @property
200
+ def http(self) -> httpx.AsyncClient:
201
+ """The underlying `httpx.AsyncClient`, already carrying the base URL and token."""
202
+ return self._t.http
203
+
204
+ async def request(
205
+ self,
206
+ method: str,
207
+ path: str,
208
+ *,
209
+ query: Mapping[str, Any] | None = None,
210
+ body: Mapping[str, Any] | None = None,
211
+ ) -> Any:
212
+ """Call a path directly, with this client's credentials and error handling."""
213
+ return await self._t.request(method, path, query=query, body=body)
214
+
215
+ def google_login_url(self) -> str:
216
+ """Where to send a browser to start Google sign-in."""
217
+ return _google_login_url(self.base_url)
218
+
219
+ def oauth_start_url(self, provider: str, integration_id: str | None = None) -> str:
220
+ """Where to send a browser to authorize an integration."""
221
+ return _oauth_start_url(self.base_url, provider, integration_id)
222
+
223
+ async def close(self) -> None:
224
+ await self._t.close()
225
+
226
+ async def __aenter__(self) -> "AsyncTalqing":
227
+ return self
228
+
229
+ async def __aexit__(
230
+ self,
231
+ kind: type[BaseException] | None,
232
+ error: BaseException | None,
233
+ trace: TracebackType | None,
234
+ ) -> None:
235
+ await self.close()
236
+
237
+
238
+ def paginate(
239
+ page: Callable[..., Page[T]], *, limit: int = 200, **filters: Any
240
+ ) -> Iterator[T]:
241
+ """Walk every page of a list endpoint.
242
+
243
+ ```python
244
+ for agent in paginate(talqing.agents.list):
245
+ ...
246
+ for call in paginate(talqing.calls.list, agent_id=agent_id):
247
+ ...
248
+ ```
249
+
250
+ One helper rather than a `list_all_*` per endpoint: all 28 list endpoints
251
+ page the same way, so this does too. Anything else the endpoint filters on
252
+ is passed straight through.
253
+ """
254
+ offset = 0
255
+ while True:
256
+ result = page(limit=limit, offset=offset, **filters)
257
+ yield from result["items"]
258
+ if not result["has_more"]:
259
+ return
260
+ offset += limit
261
+
262
+
263
+ async def paginate_async(
264
+ page: Callable[..., Awaitable[Page[T]]], *, limit: int = 200, **filters: Any
265
+ ) -> AsyncIterator[T]:
266
+ """`paginate`, awaited.
267
+
268
+ ```python
269
+ async for agent in paginate_async(talqing.agents.list):
270
+ ...
271
+ ```
272
+ """
273
+ offset = 0
274
+ while True:
275
+ result = await page(limit=limit, offset=offset, **filters)
276
+ for item in result["items"]:
277
+ yield item
278
+ if not result["has_more"]:
279
+ return
280
+ offset += limit
talqing/errors.py ADDED
@@ -0,0 +1,32 @@
1
+ """The one exception this SDK raises."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ import httpx
8
+
9
+
10
+ class TalqingAPIError(Exception):
11
+ """Every error the API returns, in the one shape it returns them in.
12
+
13
+ The wire body is always ``{"detail": {"message", "errors"}}``, so there is
14
+ nothing to branch on: ``str(exc)`` is the message, ``exc.errors`` lists the
15
+ per-field problems when the failure had more than one, and ``exc.detail`` is
16
+ the decoded body for anything else you want off it.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ message: str,
22
+ *,
23
+ status_code: int,
24
+ detail: Any = None,
25
+ errors: Optional[list[str]] = None,
26
+ response: Optional[httpx.Response] = None,
27
+ ) -> None:
28
+ super().__init__(message)
29
+ self.status_code = status_code
30
+ self.detail = detail
31
+ self.errors = errors or []
32
+ self.response = response