lowcoai-docs 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.
@@ -0,0 +1,23 @@
1
+ """Official Python client for the lowco document service (files, folders, sharing,
2
+ triggers, webhooks)."""
3
+
4
+ from . import types as _types
5
+ from ._base import API_PREFIX, BASE_URL
6
+ from ._client import AsyncDocsClient, DocsClient
7
+ from ._errors import DocsError
8
+ from ._http import AsyncHttpClient, HttpClient
9
+ from .types import * # noqa: F403
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = [
14
+ "API_PREFIX",
15
+ "BASE_URL",
16
+ "AsyncDocsClient",
17
+ "AsyncHttpClient",
18
+ "DocsClient",
19
+ "DocsError",
20
+ "HttpClient",
21
+ "__version__",
22
+ ]
23
+ __all__ += _types.__all__
lowcoai/docs/_base.py ADDED
@@ -0,0 +1,341 @@
1
+ """Transport-independent pieces shared by the sync and async HTTP clients.
2
+
3
+ Header precedence, path / query / multipart encoding, envelope unwrapping,
4
+ redirect / binary / 202-restoring decoding and error mapping.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from collections.abc import Mapping, Sequence
12
+ from typing import IO, Any, Literal
13
+ from urllib.parse import quote
14
+
15
+ import httpx
16
+
17
+ from ._errors import DocsError
18
+ from .types import HEADER_ORG_ID, Binary, FileInput, NodeFileResult, UploadFile
19
+
20
+ BASE_URL = "https://api.lowco.ai"
21
+ API_PREFIX = "/v1/documents"
22
+ DEFAULT_TIMEOUT = 30.0
23
+
24
+ ACCEPT_JSON = "application/json"
25
+ ACCEPT_ANY = "*/*"
26
+
27
+ BodyKind = Literal["none", "json", "multipart"]
28
+ MultipartFields = Mapping[str, str | Sequence[str] | None]
29
+ # httpx ``files=`` entries: (field, (filename, content, content type or None)).
30
+ MultipartFile = tuple[str, tuple[str, bytes | IO[bytes], str | None]]
31
+
32
+
33
+ class BaseHttpClient:
34
+ """Holds configuration and builds / decodes requests for both clients."""
35
+
36
+ def __init__(
37
+ self,
38
+ token: str,
39
+ *,
40
+ org_id: str,
41
+ timeout: float | None,
42
+ headers: Mapping[str, str] | None,
43
+ ) -> None:
44
+ if not token or not token.strip():
45
+ raise ValueError("DocsClient: `token` is required. Pass a user token or API key.")
46
+ if not org_id or not org_id.strip():
47
+ raise ValueError(
48
+ "DocsClient: `org_id` is required. The document service rejects every "
49
+ "request without an X-Org-Id header."
50
+ )
51
+ self._token = token
52
+ self._org_id = org_id
53
+ self._timeout = timeout
54
+ self._extra_headers: dict[str, str] = dict(headers or {})
55
+
56
+ def _url(self, path: str) -> str:
57
+ return BASE_URL + (path if path.startswith("/") else "/" + path)
58
+
59
+ def _headers(
60
+ self, body: BodyKind, accept: str, headers: Mapping[str, str] | None
61
+ ) -> httpx.Headers:
62
+ # Defaults, then configured and per-request headers, then the body's
63
+ # Content-Type; Authorization and X-Org-Id are added only when not
64
+ # already supplied (case-insensitive).
65
+ out = httpx.Headers({"Accept": accept})
66
+ for source in (self._extra_headers, headers or {}):
67
+ for key, value in source.items():
68
+ out[key] = value
69
+ if body == "json":
70
+ out["Content-Type"] = "application/json"
71
+ elif body == "multipart" and "Content-Type" in out:
72
+ # httpx sets multipart/form-data with the boundary only when absent.
73
+ del out["Content-Type"]
74
+ if not out.get("Authorization"):
75
+ out["Authorization"] = f"Bearer {self._token}"
76
+ if not out.get(HEADER_ORG_ID):
77
+ out[HEADER_ORG_ID] = self._org_id
78
+ return out
79
+
80
+
81
+ # --- paths, queries, bodies ---------------------------------------------------
82
+
83
+ # encodeURIComponent leaves A-Z a-z 0-9 - _ . ! ~ * ' ( ) unescaped.
84
+ _URI_COMPONENT_SAFE = "-_.!~*'()"
85
+
86
+
87
+ def seg(value: str) -> str:
88
+ """Percent-encodes one path segment like ``encodeURIComponent`` (``/`` included).
89
+
90
+ Raises :class:`DocsError` for ``.`` / ``..``: URL normalisation would remove
91
+ the segment and silently hit a different route.
92
+ """
93
+ text = str(value)
94
+ if text in (".", ".."):
95
+ raise DocsError(
96
+ f"Invalid path segment {text!r}: '.' and '..' cannot be sent as path segments",
97
+ 0,
98
+ None,
99
+ )
100
+ return quote(text, safe=_URI_COMPONENT_SAFE)
101
+
102
+
103
+ def wildcard(path: str) -> str:
104
+ """Encodes a multi-segment path for a ``/*`` route: a leading ``/`` is
105
+ stripped and each segment is encoded like :func:`seg`, keeping ``/``."""
106
+ return "/".join(seg(part) for part in str(path).lstrip("/").split("/"))
107
+
108
+
109
+ def flag(value: bool | None) -> str | None:
110
+ """``True`` → ``"1"``; ``False`` / ``None`` → omitted (for presence-only flags)."""
111
+ return "1" if value else None
112
+
113
+
114
+ def encode_query(query: Mapping[str, Any] | None) -> dict[str, str] | None:
115
+ """Encodes query values: ``None`` is skipped, booleans become ``"true"`` /
116
+ ``"false"``, strings and numbers are stringified and anything else is
117
+ sent as JSON."""
118
+ if not query:
119
+ return None
120
+ out: dict[str, str] = {}
121
+ for key, value in query.items():
122
+ if value is None:
123
+ continue
124
+ if isinstance(value, bool):
125
+ out[key] = "true" if value else "false"
126
+ elif isinstance(value, str):
127
+ out[key] = value
128
+ elif isinstance(value, int):
129
+ out[key] = str(value)
130
+ elif isinstance(value, float):
131
+ out[key] = str(int(value)) if value.is_integer() else repr(value)
132
+ else:
133
+ out[key] = _dumps(value)
134
+ return out or None
135
+
136
+
137
+ def encode_body(body: Any) -> bytes | None:
138
+ """``None`` means "no body"."""
139
+ if body is None:
140
+ return None
141
+ return _dumps(body).encode("utf-8")
142
+
143
+
144
+ def to_upload_file(file: FileInput) -> UploadFile:
145
+ """Normalises an upload argument to an :class:`UploadFile`."""
146
+ if isinstance(file, UploadFile):
147
+ return file
148
+ if isinstance(file, tuple) and len(file) == 2:
149
+ return UploadFile(file[0], file[1])
150
+ if isinstance(file, tuple) and len(file) == 3:
151
+ return UploadFile(file[0], file[1], file[2])
152
+ raise TypeError(
153
+ "Upload files must be UploadFile instances or (name, data[, content_type]) tuples, "
154
+ f"not {type(file).__name__}"
155
+ )
156
+
157
+
158
+ def encode_multipart(
159
+ files: Sequence[tuple[str, FileInput]], fields: MultipartFields | None
160
+ ) -> tuple[list[MultipartFile], dict[str, str | list[str]]]:
161
+ """Builds httpx ``files=`` / ``data=`` arguments. ``None`` fields are skipped;
162
+ a sequence value repeats the field (in order)."""
163
+ parts: list[MultipartFile] = []
164
+ for field_name, file in files:
165
+ upload = to_upload_file(file)
166
+ parts.append((field_name, (upload.name, upload.data, upload.content_type)))
167
+ data: dict[str, str | list[str]] = {}
168
+ for key, value in (fields or {}).items():
169
+ if value is None:
170
+ continue
171
+ data[key] = value if isinstance(value, str) else [str(v) for v in value]
172
+ return parts, data
173
+
174
+
175
+ # --- responses ----------------------------------------------------------------
176
+
177
+
178
+ def decode_response(status: int, text: str) -> Any:
179
+ """Returns the decoded payload, unwrapping the ``data`` envelope."""
180
+ if not 200 <= status < 300:
181
+ raise http_error(status, text)
182
+ if not text.strip():
183
+ return None
184
+ try:
185
+ payload = json.loads(text)
186
+ except ValueError as exc:
187
+ raise DocsError(f"Invalid JSON in response body (status {status})", status, text) from exc
188
+ if isinstance(payload, dict) and "data" in payload:
189
+ return payload["data"]
190
+ return payload
191
+
192
+
193
+ def check_response(status: int, text: str) -> None:
194
+ """Status check for void methods: a 2xx body is ignored, JSON or not."""
195
+ if not 200 <= status < 300:
196
+ raise http_error(status, text)
197
+
198
+
199
+ def decode_text(status: int, text: str) -> str:
200
+ """Returns a 2xx body as text (``text/plain`` endpoints)."""
201
+ check_response(status, text)
202
+ return text
203
+
204
+
205
+ def decode_redirect(resp: httpx.Response) -> str:
206
+ """Returns the ``Location`` of a 3xx response (the redirect is not followed).
207
+
208
+ Absolute URLs (presigned or CDN) come back untouched so their signature
209
+ survives; a relative one is resolved against the request URL.
210
+ """
211
+ status = resp.status_code
212
+ location = resp.headers.get("location")
213
+ if 300 <= status < 400 and location:
214
+ if _ABSOLUTE_URL.match(location):
215
+ return location
216
+ return str(resp.request.url.join(location))
217
+ if not 200 <= status < 400:
218
+ raise http_error(status, resp.text)
219
+ raise DocsError(
220
+ f"Expected a redirect with a Location header, got status {status}",
221
+ status,
222
+ parse_payload(resp.text),
223
+ )
224
+
225
+
226
+ def decode_binary(resp: httpx.Response) -> Binary:
227
+ """Returns a 2xx body as :class:`Binary`."""
228
+ if not 200 <= resp.status_code < 300:
229
+ raise http_error(resp.status_code, resp.text)
230
+ return _binary(resp)
231
+
232
+
233
+ def decode_node_file(resp: httpx.Response) -> NodeFileResult:
234
+ """307 → ``url``, 202 → ``restoring`` (+ ``retry_after``), other 2xx → ``content``."""
235
+ status = resp.status_code
236
+ if 300 <= status < 400:
237
+ return NodeFileResult(url=decode_redirect(resp))
238
+ if status == 202:
239
+ return NodeFileResult(
240
+ restoring=decode_response(status, resp.text),
241
+ retry_after=_retry_after(resp.headers.get("retry-after")),
242
+ )
243
+ return NodeFileResult(content=decode_binary(resp))
244
+
245
+
246
+ def parse_payload(text: str) -> Any:
247
+ """Parsed JSON body; the raw text when it is not JSON; ``None`` when empty."""
248
+ if not text.strip():
249
+ return None
250
+ try:
251
+ return json.loads(text)
252
+ except ValueError:
253
+ return text
254
+
255
+
256
+ def http_error(status: int, text: str) -> DocsError:
257
+ payload = parse_payload(text)
258
+ message, code = error_details(status, payload)
259
+ return DocsError(message, status, payload, code=code)
260
+
261
+
262
+ # Platform error codes sent as ``error.message``, e.g. ``AAS-00106``.
263
+ _PLATFORM_CODE = re.compile(r"^[A-Z][A-Z0-9]*-\d+$")
264
+ _ABSOLUTE_URL = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:")
265
+
266
+
267
+ def error_details(status: int, payload: Any) -> tuple[str, str | None]:
268
+ """Best ``(message, platform code)`` from ``{"status":0,"error":{"message",
269
+ "code","details"}}`` or ``{"message": "..."}``; a generic message otherwise.
270
+
271
+ When ``error.message`` is a platform code (``AAS-00106``) it becomes the
272
+ code and a non-empty ``details`` the message.
273
+ """
274
+ code: str | None = None
275
+ candidates: list[Any] = []
276
+ if isinstance(payload, dict):
277
+ error = payload.get("error")
278
+ if isinstance(error, dict):
279
+ message, details = error.get("message"), error.get("details")
280
+ if isinstance(message, str) and _PLATFORM_CODE.match(message.strip()):
281
+ code = message.strip()
282
+ candidates += [details, message]
283
+ else:
284
+ candidates += [message, details]
285
+ else:
286
+ candidates.append(error)
287
+ candidates.append(payload.get("message"))
288
+ for candidate in candidates:
289
+ if isinstance(candidate, str) and candidate.strip():
290
+ return candidate, code
291
+ return f"Request failed with status {status}", code
292
+
293
+
294
+ def transport_error(exc: httpx.HTTPError) -> DocsError:
295
+ return DocsError(str(exc) or type(exc).__name__, 0, None)
296
+
297
+
298
+ _QUOTED_FILENAME = re.compile(r'filename\s*=\s*"((?:\\.|[^"\\])*)"', re.IGNORECASE)
299
+ _TOKEN_FILENAME = re.compile(r"filename\s*=\s*([^;\s\"]+)", re.IGNORECASE)
300
+
301
+
302
+ def file_name_from(headers: httpx.Headers) -> str | None:
303
+ """``X-File-Name``, else the ``Content-Disposition`` ``filename``."""
304
+ name = headers.get("x-file-name")
305
+ if name:
306
+ return name
307
+ disposition = headers.get("content-disposition") or ""
308
+ match = _QUOTED_FILENAME.search(disposition)
309
+ if match:
310
+ return re.sub(r"\\(.)", r"\1", match.group(1))
311
+ match = _TOKEN_FILENAME.search(disposition)
312
+ return match.group(1) if match else None
313
+
314
+
315
+ def _binary(resp: httpx.Response) -> Binary:
316
+ return Binary(
317
+ data=resp.content,
318
+ content_type=resp.headers.get("content-type") or "application/octet-stream",
319
+ file_name=file_name_from(resp.headers),
320
+ )
321
+
322
+
323
+ def _retry_after(value: str | None) -> int | None:
324
+ if value is None:
325
+ return None
326
+ try:
327
+ return int(value.strip())
328
+ except ValueError:
329
+ return None
330
+
331
+
332
+ def _dumps(value: Any) -> str:
333
+ # Compact, non-ASCII-escaping output like JavaScript's JSON.stringify.
334
+ return json.dumps(value, separators=(",", ":"), ensure_ascii=False, default=_json_default)
335
+
336
+
337
+ def _json_default(value: Any) -> Any:
338
+ # Methods accept ``Mapping`` bodies; ``json`` only serialises real dicts.
339
+ if isinstance(value, Mapping):
340
+ return dict(value)
341
+ raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+
5
+ import httpx
6
+ from typing_extensions import Self
7
+
8
+ from ._base import API_PREFIX, DEFAULT_TIMEOUT, seg
9
+ from ._http import AsyncHttpClient, HttpClient
10
+ from .resources import (
11
+ AppFilesResource,
12
+ AsyncAppFilesResource,
13
+ AsyncAutomationResource,
14
+ AsyncBucketsResource,
15
+ AsyncFilesResource,
16
+ AsyncFoldersResource,
17
+ AsyncLibraryResource,
18
+ AsyncNodesResource,
19
+ AsyncSharingResource,
20
+ AsyncTriggersResource,
21
+ AsyncWebhooksResource,
22
+ AutomationResource,
23
+ BucketsResource,
24
+ FilesResource,
25
+ FoldersResource,
26
+ LibraryResource,
27
+ NodesResource,
28
+ SharingResource,
29
+ TriggersResource,
30
+ WebhooksResource,
31
+ )
32
+ from .types import Document
33
+
34
+ __all__ = ["AsyncDocsClient", "DocsClient"]
35
+
36
+
37
+ class DocsClient:
38
+ """Client for the lowco document service (routes under ``/v1/documents``).
39
+
40
+ ``token`` (a user token or API key) and ``org_id`` are required: they are
41
+ sent as ``Authorization: Bearer <token>`` and ``X-Org-Id`` on every
42
+ request. Endpoints are grouped into resources: ``client.buckets``,
43
+ ``client.folders``, ``client.files``, ``client.nodes``, ``client.library``,
44
+ ``client.sharing``, ``client.automation``, ``client.app_files``,
45
+ ``client.triggers`` and ``client.webhooks``; :meth:`health` and
46
+ :meth:`search` live on the client. Use it as a context manager, or call
47
+ :meth:`close`, to release the underlying connection pool.
48
+ """
49
+
50
+ buckets: BucketsResource
51
+ folders: FoldersResource
52
+ files: FilesResource
53
+ nodes: NodesResource
54
+ library: LibraryResource
55
+ sharing: SharingResource
56
+ automation: AutomationResource
57
+ app_files: AppFilesResource
58
+ triggers: TriggersResource
59
+ webhooks: WebhooksResource
60
+
61
+ def __init__(
62
+ self,
63
+ token: str,
64
+ *,
65
+ org_id: str,
66
+ timeout: float | None = DEFAULT_TIMEOUT,
67
+ headers: Mapping[str, str] | None = None,
68
+ http_client: httpx.Client | None = None,
69
+ ) -> None:
70
+ """
71
+ Args:
72
+ token: User token or API key. Required.
73
+ org_id: Organization id, sent as the ``X-Org-Id`` header. Required.
74
+ timeout: Per-request timeout in seconds (default 30). ``None`` disables it.
75
+ headers: Extra headers added to every request. An ``Authorization`` or
76
+ ``X-Org-Id`` entry here takes precedence over ``token`` / ``org_id``.
77
+ http_client: Custom ``httpx.Client`` (proxies, retries, mocks). It is
78
+ not closed by :meth:`close`.
79
+
80
+ Raises:
81
+ ValueError: ``token`` or ``org_id`` is empty.
82
+ """
83
+ self._http = HttpClient(
84
+ token, org_id=org_id, timeout=timeout, headers=headers, http_client=http_client
85
+ )
86
+ self.buckets = BucketsResource(self._http)
87
+ self.folders = FoldersResource(self._http)
88
+ self.files = FilesResource(self._http)
89
+ self.nodes = NodesResource(self._http)
90
+ self.library = LibraryResource(self._http)
91
+ self.sharing = SharingResource(self._http)
92
+ self.automation = AutomationResource(self._http)
93
+ self.app_files = AppFilesResource(self._http)
94
+ self.triggers = TriggersResource(self._http)
95
+ self.webhooks = WebhooksResource(self._http)
96
+
97
+ def health(self) -> str:
98
+ """Liveness probe (``GET /health``); returns the service's text, ``"Working!"``."""
99
+ return self._http.request_text("GET", "/health")
100
+
101
+ def search(self, bucket: str, q: str) -> list[Document]:
102
+ """Finds files and folders whose name matches ``q``, then documents whose
103
+ extracted text matches (``metadata.matchedBy == "content"``)."""
104
+ return self._http.request("GET", f"{API_PREFIX}/{seg(bucket)}/search", query={"q": q})
105
+
106
+ def close(self) -> None:
107
+ """Closes the internally created ``httpx.Client`` (an injected one is left open)."""
108
+ self._http.close()
109
+
110
+ def __enter__(self) -> Self:
111
+ return self
112
+
113
+ def __exit__(self, *exc_info: object) -> None:
114
+ self.close()
115
+
116
+
117
+ class AsyncDocsClient:
118
+ """Asyncio client for the lowco document service (same surface as
119
+ :class:`DocsClient`; every endpoint method is a coroutine).
120
+
121
+ Use it as an async context manager, or call :meth:`aclose`, to release the
122
+ underlying connection pool.
123
+ """
124
+
125
+ buckets: AsyncBucketsResource
126
+ folders: AsyncFoldersResource
127
+ files: AsyncFilesResource
128
+ nodes: AsyncNodesResource
129
+ library: AsyncLibraryResource
130
+ sharing: AsyncSharingResource
131
+ automation: AsyncAutomationResource
132
+ app_files: AsyncAppFilesResource
133
+ triggers: AsyncTriggersResource
134
+ webhooks: AsyncWebhooksResource
135
+
136
+ def __init__(
137
+ self,
138
+ token: str,
139
+ *,
140
+ org_id: str,
141
+ timeout: float | None = DEFAULT_TIMEOUT,
142
+ headers: Mapping[str, str] | None = None,
143
+ http_client: httpx.AsyncClient | None = None,
144
+ ) -> None:
145
+ """
146
+ Args:
147
+ token: User token or API key. Required.
148
+ org_id: Organization id, sent as the ``X-Org-Id`` header. Required.
149
+ timeout: Per-request timeout in seconds (default 30). ``None`` disables it.
150
+ headers: Extra headers added to every request. An ``Authorization`` or
151
+ ``X-Org-Id`` entry here takes precedence over ``token`` / ``org_id``.
152
+ http_client: Custom ``httpx.AsyncClient`` (proxies, retries, mocks). It
153
+ is not closed by :meth:`aclose`.
154
+
155
+ Raises:
156
+ ValueError: ``token`` or ``org_id`` is empty.
157
+ """
158
+ self._http = AsyncHttpClient(
159
+ token, org_id=org_id, timeout=timeout, headers=headers, http_client=http_client
160
+ )
161
+ self.buckets = AsyncBucketsResource(self._http)
162
+ self.folders = AsyncFoldersResource(self._http)
163
+ self.files = AsyncFilesResource(self._http)
164
+ self.nodes = AsyncNodesResource(self._http)
165
+ self.library = AsyncLibraryResource(self._http)
166
+ self.sharing = AsyncSharingResource(self._http)
167
+ self.automation = AsyncAutomationResource(self._http)
168
+ self.app_files = AsyncAppFilesResource(self._http)
169
+ self.triggers = AsyncTriggersResource(self._http)
170
+ self.webhooks = AsyncWebhooksResource(self._http)
171
+
172
+ async def health(self) -> str:
173
+ """Liveness probe (``GET /health``); returns the service's text, ``"Working!"``."""
174
+ return await self._http.request_text("GET", "/health")
175
+
176
+ async def search(self, bucket: str, q: str) -> list[Document]:
177
+ """Finds files and folders whose name matches ``q``, then documents whose
178
+ extracted text matches (``metadata.matchedBy == "content"``)."""
179
+ return await self._http.request("GET", f"{API_PREFIX}/{seg(bucket)}/search", query={"q": q})
180
+
181
+ async def aclose(self) -> None:
182
+ """Closes the internally created ``httpx.AsyncClient`` (an injected one is left open)."""
183
+ await self._http.aclose()
184
+
185
+ async def __aenter__(self) -> Self:
186
+ return self
187
+
188
+ async def __aexit__(self, *exc_info: object) -> None:
189
+ await self.aclose()
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ __all__ = ["DocsError"]
6
+
7
+
8
+ class DocsError(Exception):
9
+ """Raised for every non-2xx response, every transport failure, every
10
+ response the SDK cannot decode and every path segment it refuses to send.
11
+
12
+ * ``message`` — the best message the error body offers: for a
13
+ ``{"status": 0, "error": {...}}`` body, ``error.details`` when
14
+ ``error.message`` is a platform code (e.g. ``AAS-00106``), else
15
+ ``error.message`` (then ``details``); for a ``{"message": "..."}`` body,
16
+ ``message``; ``"Request failed with status <n>"`` when there is none. For
17
+ transport failures / timeouts, the underlying error text.
18
+ * ``status`` — the HTTP status code, or ``0`` when no response was received
19
+ (connection error, timeout, rejected ``.`` / ``..`` path segment).
20
+ * ``code`` — the platform error code (``error.message`` of an enveloped error
21
+ when it looks like ``AAS-00106``), else ``None``.
22
+ * ``payload`` — the parsed JSON error body; the raw text when the body is
23
+ not JSON; ``None`` when it is empty or no response was received.
24
+ """
25
+
26
+ def __init__(
27
+ self, message: str, status: int, payload: Any = None, *, code: str | None = None
28
+ ) -> None:
29
+ super().__init__(message)
30
+ self.message = message
31
+ self.status = status
32
+ self.payload = payload
33
+ self.code = code
34
+
35
+ def __repr__(self) -> str:
36
+ return (
37
+ f"DocsError(message={self.message!r}, status={self.status!r}, "
38
+ f"code={self.code!r}, payload={self.payload!r})"
39
+ )