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.
- lowcoai/docs/__init__.py +23 -0
- lowcoai/docs/_base.py +341 -0
- lowcoai/docs/_client.py +189 -0
- lowcoai/docs/_errors.py +39 -0
- lowcoai/docs/_http.py +338 -0
- lowcoai/docs/py.typed +0 -0
- lowcoai/docs/resources/__init__.py +41 -0
- lowcoai/docs/resources/app_files.py +86 -0
- lowcoai/docs/resources/automation.py +94 -0
- lowcoai/docs/resources/buckets.py +41 -0
- lowcoai/docs/resources/files.py +179 -0
- lowcoai/docs/resources/folders.py +266 -0
- lowcoai/docs/resources/library.py +112 -0
- lowcoai/docs/resources/nodes.py +64 -0
- lowcoai/docs/resources/sharing.py +128 -0
- lowcoai/docs/resources/triggers.py +67 -0
- lowcoai/docs/resources/webhooks.py +73 -0
- lowcoai/docs/types.py +617 -0
- lowcoai_docs-0.1.0.dist-info/METADATA +271 -0
- lowcoai_docs-0.1.0.dist-info/RECORD +22 -0
- lowcoai_docs-0.1.0.dist-info/WHEEL +4 -0
- lowcoai_docs-0.1.0.dist-info/licenses/LICENSE +21 -0
lowcoai/docs/__init__.py
ADDED
|
@@ -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")
|
lowcoai/docs/_client.py
ADDED
|
@@ -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()
|
lowcoai/docs/_errors.py
ADDED
|
@@ -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
|
+
)
|