lowcoai-docs 0.1.0__tar.gz
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-0.1.0/.gitignore +17 -0
- lowcoai_docs-0.1.0/LICENSE +21 -0
- lowcoai_docs-0.1.0/PKG-INFO +271 -0
- lowcoai_docs-0.1.0/README.md +249 -0
- lowcoai_docs-0.1.0/pyproject.toml +50 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/__init__.py +23 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/_base.py +341 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/_client.py +189 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/_errors.py +39 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/_http.py +338 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/py.typed +0 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/resources/__init__.py +41 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/resources/app_files.py +86 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/resources/automation.py +94 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/resources/buckets.py +41 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/resources/files.py +179 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/resources/folders.py +266 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/resources/library.py +112 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/resources/nodes.py +64 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/resources/sharing.py +128 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/resources/triggers.py +67 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/resources/webhooks.py +73 -0
- lowcoai_docs-0.1.0/src/lowcoai/docs/types.py +617 -0
- lowcoai_docs-0.1.0/tests/__init__.py +0 -0
- lowcoai_docs-0.1.0/tests/test_async_client.py +244 -0
- lowcoai_docs-0.1.0/tests/test_http.py +705 -0
- lowcoai_docs-0.1.0/tests/test_resources.py +631 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lowco AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lowcoai-docs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the lowco document service (files, folders, sharing, triggers, webhooks).
|
|
5
|
+
Project-URL: Repository, https://github.com/lowcoai/lowco-sdk
|
|
6
|
+
Project-URL: Source, https://github.com/lowcoai/lowco-sdk/tree/main/python/docs
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: client,documents,files,lowco,sdk,sharing,storage
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: httpx<1,>=0.25
|
|
15
|
+
Requires-Dist: typing-extensions>=4.7
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
20
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# lowcoai-docs
|
|
24
|
+
|
|
25
|
+
Python client for the lowco **document service**: files, folders, sharing, triggers and webhooks (routes under `/v1/documents/*`). Sync and asyncio clients with the same surface, built on [httpx](https://www.python-httpx.org/). Python 3.10+.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install lowcoai-docs
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from lowcoai.docs import DocsClient
|
|
35
|
+
|
|
36
|
+
with DocsClient("<token-or-api-key>", org_id="org_123") as client:
|
|
37
|
+
bucket = client.buckets.get()["name"] # the org's bucket, created on first use
|
|
38
|
+
|
|
39
|
+
client.folders.create(bucket, {"parentName": "", "folderName": "projects/2026"})
|
|
40
|
+
doc = client.files.upload(bucket, ("plan.md", b"# Plan"), parent_id="projects/2026")
|
|
41
|
+
|
|
42
|
+
for item in client.folders.list(bucket, prefix="projects/2026"):
|
|
43
|
+
print(item["type"], item["path"], item.get("size"))
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### asyncio
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from lowcoai.docs import AsyncDocsClient
|
|
50
|
+
|
|
51
|
+
async with AsyncDocsClient("<token-or-api-key>", org_id="org_123") as client:
|
|
52
|
+
bucket = (await client.buckets.get())["name"]
|
|
53
|
+
hits = await client.search(bucket, "quarterly report")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`AsyncDocsClient` has the same resources and methods as `DocsClient`; each endpoint method is a coroutine. Close it with `await client.aclose()` if you don't use `async with`.
|
|
57
|
+
|
|
58
|
+
## Options
|
|
59
|
+
|
|
60
|
+
| Argument | Description |
|
|
61
|
+
| ------------- | ------------------------------------------------------------------------------------ |
|
|
62
|
+
| `token` | User token or API key, sent as `Authorization: Bearer <token>`. Required. |
|
|
63
|
+
| `org_id` | Organization id, sent as the `X-Org-Id` header. Required — the service rejects every request without it. |
|
|
64
|
+
| `timeout` | Per-request timeout in seconds (default `30`). `None` disables it. |
|
|
65
|
+
| `headers` | Extra headers added to every request. An `Authorization` or `X-Org-Id` entry here wins over `token` / `org_id`. |
|
|
66
|
+
| `http_client` | Your own `httpx.Client` / `httpx.AsyncClient` (proxies, retries, test transports). It is not closed by the SDK. |
|
|
67
|
+
|
|
68
|
+
All requests go to `https://api.lowco.ai`. An empty `token` or `org_id` raises `ValueError`. The caller's identity comes from the token; the SDK sends no `X-User-Id` (add one through `headers` if your setup needs it).
|
|
69
|
+
|
|
70
|
+
## Data shapes
|
|
71
|
+
|
|
72
|
+
Request and response bodies are plain dicts typed as `TypedDict`s generated from the service's OpenAPI spec (`Document`, `FileContent`, `NodeView`, `Share`, `ShareLink`, `FolderConfig`, `Trigger`, `Webhook`, …), with keys exactly as on the wire (camelCase). A value returned by one call can be sent back as the body of another. The service's `{"status": 1, "data": ...}` envelope is unwrapped for you; calls whose payload is a confirmation message (most deletes, `star`, `unstar`, …) return that string.
|
|
73
|
+
|
|
74
|
+
`bucket` is the bucket name (`client.buckets.get()["name"]`). Paths are bucket keys such as `projects/plan.md` or `.users/u-123/notes/todo.md`. In URLs, single-segment parameters (bucket, ids, keys, tokens, app keys) are fully percent-encoded, while file and folder paths keep their `/` and have each segment encoded (a leading `/` is dropped). Segments that are exactly `.` or `..` are rejected with `DocsError` before anything is sent. Optional query arguments left as `None` are omitted.
|
|
75
|
+
|
|
76
|
+
## Surface
|
|
77
|
+
|
|
78
|
+
| Resource | Methods |
|
|
79
|
+
| ------------------- | ------- |
|
|
80
|
+
| `client` | `health()` (plain text), `search(bucket, q)` |
|
|
81
|
+
| `client.buckets` | `get()`, `stats(bucket)` |
|
|
82
|
+
| `client.folders` | `list(bucket, prefix=, sizes=)`, `list_at(bucket, path, sizes=)`, `list_public(bucket, prefix=, sizes=)`, `list_user(bucket, user_id, prefix=, sizes=)`, `list_app(bucket, app_id, prefix=, sizes=)`, `get(bucket, key)`, `create(bucket, body)`, `delete(bucket, key)`, `delete_by_path(bucket, path)`, `upload(bucket, files, relative_paths=, parent_id=, prefix=)`, `download_zip(bucket, path)`, `duplicate(bucket, body)`, `rename(bucket, body)` |
|
|
83
|
+
| `client.files` | `get(bucket, path)`, `read(bucket, path, meta=)`, `create_blank(bucket, body)`, `update(bucket, body)`, `update_at(bucket, path, body)`, `rename(bucket, body)`, `delete(bucket, path)`, `delete_by_path(bucket, path)`, `upload(bucket, file, parent_id=, on_conflict=)`, `download_url(bucket, path)`, `preview_url(bucket, path)`, `list_archive(bucket, path)` |
|
|
84
|
+
| `client.nodes` | `get(node_id)`, `resolve(node_id, download=False)`, `content(node_id)` |
|
|
85
|
+
| `client.library` | `star(bucket, node_id)`, `unstar(bucket, node_id)`, `star_path(bucket, path, type=)`, `unstar_path(bucket, path)`, `starred(bucket)`, `recent(bucket, limit=)`, `trash(bucket)`, `restore(bucket, node_id)`, `purge(bucket, node_id)` |
|
|
86
|
+
| `client.sharing` | `create(bucket, body)`, `list(bucket, path, type=)`, `delete(bucket, id)`, `shared_with_me(bucket, app_key=)`, `create_link(bucket, body)`, `list_links(bucket, path, type=)`, `delete_link(bucket, id)`, `resolve_link(token)` |
|
|
87
|
+
| `client.automation` | `list(bucket, prefix=)`, `create(bucket, body)`, `get(bucket, id)`, `update(bucket, id, body)`, `delete(bucket, id)`, `jobs(bucket, config_id=, limit=)` |
|
|
88
|
+
| `client.app_files` | `upload(app_key, file, path=, on_conflict=)`, `list(app_key, prefix=)`, `delete(app_key, path)`, `sweep(app_key, body)` |
|
|
89
|
+
| `client.triggers` | `list()`, `create(body)`, `get(id)`, `update(id, body)`, `delete(id)` |
|
|
90
|
+
| `client.webhooks` | `list(page=, limit=)`, `create(body)`, `get(id)`, `update(id, body)`, `delete(id)` |
|
|
91
|
+
|
|
92
|
+
`triggers.delete` and `webhooks.delete` return `None`.
|
|
93
|
+
|
|
94
|
+
## Examples
|
|
95
|
+
|
|
96
|
+
### Folders
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
# sizes=False skips recursive folder sizes (folders then report size 0)
|
|
100
|
+
client.folders.list(bucket, prefix="projects", sizes=False)
|
|
101
|
+
client.folders.list_user(bucket, "u-123", prefix="notes") # a personal drive
|
|
102
|
+
client.folders.rename(
|
|
103
|
+
bucket, {"parentName": "projects", "oldName": "2025", "newName": "archive/2025"}
|
|
104
|
+
)
|
|
105
|
+
client.folders.delete_by_path(bucket, "projects/old/") # any depth; goes to the trash
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Files
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
current = client.files.read(bucket, "notes/todo.md") # content + etag + role
|
|
112
|
+
client.files.update(
|
|
113
|
+
bucket,
|
|
114
|
+
{
|
|
115
|
+
"parentName": "notes",
|
|
116
|
+
"fileName": "todo.md",
|
|
117
|
+
"content": "# Todo\n- ship",
|
|
118
|
+
"ifMatch": current["etag"],
|
|
119
|
+
},
|
|
120
|
+
) # DocsError(status=412) when someone saved in between
|
|
121
|
+
client.files.create_blank(bucket, {"parentName": "notes", "fileName": "ideas.md"})
|
|
122
|
+
url = client.files.download_url(bucket, "reports/q3.pdf") # short-lived signed URL
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Nodes (permalinks)
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
result = client.nodes.resolve("7489521000000000042", download=True)
|
|
129
|
+
if result.url: # 307: a fresh short-lived URL
|
|
130
|
+
print(result.url)
|
|
131
|
+
elif result.content: # 200: bytes of a file just restored from cold storage
|
|
132
|
+
open(result.content.file_name or "file", "wb").write(result.content.data)
|
|
133
|
+
elif result.restoring: # 202: restore in progress
|
|
134
|
+
print(f"retry in {result.retry_after}s:", result.restoring["message"])
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`nodes.content(node_id)` streams the bytes instead of redirecting (`result.content`, or `result.restoring` while a restore runs).
|
|
138
|
+
|
|
139
|
+
### Library
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
client.library.star_path(bucket, "projects/plan.md")
|
|
143
|
+
client.library.recent(bucket, limit=20)
|
|
144
|
+
trashed = client.library.trash(bucket)
|
|
145
|
+
client.library.restore(bucket, trashed[0]["id"])
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Sharing
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
client.sharing.create(
|
|
152
|
+
bucket,
|
|
153
|
+
{
|
|
154
|
+
"path": ".users/u-123/q3.pdf",
|
|
155
|
+
"type": "file",
|
|
156
|
+
"subjectType": "user",
|
|
157
|
+
"subjectId": "u-456",
|
|
158
|
+
"role": "viewer",
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
grants = client.sharing.list(bucket, ".users/u-123/q3.pdf")
|
|
162
|
+
link = client.sharing.create_link(bucket, {"path": ".users/u-123/q3.pdf", "type": "file"})
|
|
163
|
+
file_url = client.sharing.resolve_link(link["token"])
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Automation
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
rule = client.automation.create(
|
|
170
|
+
bucket,
|
|
171
|
+
{
|
|
172
|
+
"prefix": "invoices/",
|
|
173
|
+
"pipelineType": "custom_workflow",
|
|
174
|
+
"workflowId": "wf_1",
|
|
175
|
+
"fileSuffixes": [".pdf"],
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
client.automation.jobs(bucket, config_id=rule["id"], limit=10)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### App files
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
stored = client.app_files.upload("invoicing", ("INV-0042.pdf", pdf_bytes), path="2026/invoices")
|
|
185
|
+
stored["permalink"] # store this (or stored["nodeId"]), not a storage URL
|
|
186
|
+
client.app_files.sweep("invoicing", {"prefix": "2026", "olderThanDays": 365})
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Triggers and webhooks
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
client.triggers.create(
|
|
193
|
+
{
|
|
194
|
+
"eventType": "create",
|
|
195
|
+
"prefix": "invoices/",
|
|
196
|
+
"suffix": ".pdf",
|
|
197
|
+
"workflowId": "wf_1",
|
|
198
|
+
"active": True,
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
client.webhooks.create(
|
|
202
|
+
{
|
|
203
|
+
"url": "https://hooks.example.com/docs",
|
|
204
|
+
"method": "POST",
|
|
205
|
+
"prefix": "uploads/",
|
|
206
|
+
"eventTypes": ["create", "delete"],
|
|
207
|
+
"active": True,
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
client.webhooks.list(page=1, limit=50)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Uploads
|
|
214
|
+
|
|
215
|
+
Upload methods take a file as an `UploadFile(name, data, content_type=None)` or a `(name, data)` / `(name, data, content_type)` tuple, where `data` is `bytes` or a binary file object (streamed). Without a content type, one is guessed from the name (else `application/octet-stream`). Form fields use the service's exact names:
|
|
216
|
+
|
|
217
|
+
| Method | Parts |
|
|
218
|
+
| ------------------- | ----- |
|
|
219
|
+
| `files.upload` | `file`, `ParentID` (from `parent_id`), `onConflict` |
|
|
220
|
+
| `folders.upload` | repeated `files`, repeated `relativePaths` (same order; one per file), `parentID`, `prefix` |
|
|
221
|
+
| `app_files.upload` | `file`, `path`, `onConflict` |
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
from lowcoai.docs import UploadFile
|
|
225
|
+
|
|
226
|
+
with open("photo.jpg", "rb") as fh:
|
|
227
|
+
client.folders.upload(
|
|
228
|
+
bucket,
|
|
229
|
+
[UploadFile("photo.jpg", fh), ("notes.txt", b"hello")],
|
|
230
|
+
relative_paths=["trip/photo.jpg", "trip/notes.txt"],
|
|
231
|
+
parent_id="projects",
|
|
232
|
+
)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Redirects and downloads
|
|
236
|
+
|
|
237
|
+
- `files.download_url`, `files.preview_url` and `sharing.resolve_link` answer with a 307 redirect. The SDK never follows it (even if your `httpx` client is configured to) and returns the `Location` URL as a string.
|
|
238
|
+
- `folders.download_zip` returns a `Binary(data, content_type, file_name)`; `file_name` comes from the `X-File-Name` header, else from `Content-Disposition`.
|
|
239
|
+
- `nodes.resolve` / `nodes.content` return a `NodeFileResult` with exactly one of `url`, `content` (`Binary`) or `restoring` (`ArchiveRestoring`) set, plus `retry_after` (seconds, from `Retry-After`) on a 202.
|
|
240
|
+
- `health()` returns the plain-text body (`"Working!"`); it is served at `/health`, outside `/v1/documents`.
|
|
241
|
+
|
|
242
|
+
### Endpoints the SDK does not wrap
|
|
243
|
+
|
|
244
|
+
`HttpClient` / `AsyncHttpClient` are the transports behind the resources and apply the same headers, envelope unwrapping and error mapping (`request`, `request_void`, `request_text`, `request_binary`, `request_redirect`, `request_node_file`, `request_multipart`):
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
from lowcoai.docs import HttpClient
|
|
248
|
+
|
|
249
|
+
with HttpClient("<token>", org_id="org_123") as http:
|
|
250
|
+
data = http.request("GET", "/v1/documents/my-bucket/objects", query={"prefix": "a"})
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Errors
|
|
254
|
+
|
|
255
|
+
Every non-2xx response raises `DocsError`:
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
from lowcoai.docs import DocsError
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
client.files.read(bucket, "missing.md")
|
|
262
|
+
except DocsError as err:
|
|
263
|
+
print(err.status, err.message, err.code, err.payload)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
- `message` — the best message in the error body. The service sends either `{"status": 0, "error": {"message", "code", "details"}}` or `{"message": "..."}`. When `error.message` is a platform code such as `AAS-00106`, `details` is the message and the code goes to `code`; otherwise `error.message` (or `message`) is used, falling back to `"Request failed with status <n>"`.
|
|
267
|
+
- `status` — the HTTP status; `0` when no response arrived (connection error, timeout — the `httpx` exception is chained as `__cause__`) or a `.` / `..` path segment was refused.
|
|
268
|
+
- `code` — the platform error code (e.g. `AAS-00106`) when the body carries one, else `None`.
|
|
269
|
+
- `payload` — the parsed JSON error body (a failed conditional save puts the file's current `currentEtag` / `updatedBy` under `payload["data"]`); the raw text when the body is not JSON; `None` when empty.
|
|
270
|
+
|
|
271
|
+
A 2xx response whose body is not JSON raises `DocsError` with that 2xx `status` and the raw text as `payload` (`triggers.delete` / `webhooks.delete` ignore the body). A redirect method that gets no 3xx `Location` raises `DocsError` too.
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# lowcoai-docs
|
|
2
|
+
|
|
3
|
+
Python client for the lowco **document service**: files, folders, sharing, triggers and webhooks (routes under `/v1/documents/*`). Sync and asyncio clients with the same surface, built on [httpx](https://www.python-httpx.org/). Python 3.10+.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install lowcoai-docs
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quick start
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from lowcoai.docs import DocsClient
|
|
13
|
+
|
|
14
|
+
with DocsClient("<token-or-api-key>", org_id="org_123") as client:
|
|
15
|
+
bucket = client.buckets.get()["name"] # the org's bucket, created on first use
|
|
16
|
+
|
|
17
|
+
client.folders.create(bucket, {"parentName": "", "folderName": "projects/2026"})
|
|
18
|
+
doc = client.files.upload(bucket, ("plan.md", b"# Plan"), parent_id="projects/2026")
|
|
19
|
+
|
|
20
|
+
for item in client.folders.list(bucket, prefix="projects/2026"):
|
|
21
|
+
print(item["type"], item["path"], item.get("size"))
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### asyncio
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from lowcoai.docs import AsyncDocsClient
|
|
28
|
+
|
|
29
|
+
async with AsyncDocsClient("<token-or-api-key>", org_id="org_123") as client:
|
|
30
|
+
bucket = (await client.buckets.get())["name"]
|
|
31
|
+
hits = await client.search(bucket, "quarterly report")
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`AsyncDocsClient` has the same resources and methods as `DocsClient`; each endpoint method is a coroutine. Close it with `await client.aclose()` if you don't use `async with`.
|
|
35
|
+
|
|
36
|
+
## Options
|
|
37
|
+
|
|
38
|
+
| Argument | Description |
|
|
39
|
+
| ------------- | ------------------------------------------------------------------------------------ |
|
|
40
|
+
| `token` | User token or API key, sent as `Authorization: Bearer <token>`. Required. |
|
|
41
|
+
| `org_id` | Organization id, sent as the `X-Org-Id` header. Required — the service rejects every request without it. |
|
|
42
|
+
| `timeout` | Per-request timeout in seconds (default `30`). `None` disables it. |
|
|
43
|
+
| `headers` | Extra headers added to every request. An `Authorization` or `X-Org-Id` entry here wins over `token` / `org_id`. |
|
|
44
|
+
| `http_client` | Your own `httpx.Client` / `httpx.AsyncClient` (proxies, retries, test transports). It is not closed by the SDK. |
|
|
45
|
+
|
|
46
|
+
All requests go to `https://api.lowco.ai`. An empty `token` or `org_id` raises `ValueError`. The caller's identity comes from the token; the SDK sends no `X-User-Id` (add one through `headers` if your setup needs it).
|
|
47
|
+
|
|
48
|
+
## Data shapes
|
|
49
|
+
|
|
50
|
+
Request and response bodies are plain dicts typed as `TypedDict`s generated from the service's OpenAPI spec (`Document`, `FileContent`, `NodeView`, `Share`, `ShareLink`, `FolderConfig`, `Trigger`, `Webhook`, …), with keys exactly as on the wire (camelCase). A value returned by one call can be sent back as the body of another. The service's `{"status": 1, "data": ...}` envelope is unwrapped for you; calls whose payload is a confirmation message (most deletes, `star`, `unstar`, …) return that string.
|
|
51
|
+
|
|
52
|
+
`bucket` is the bucket name (`client.buckets.get()["name"]`). Paths are bucket keys such as `projects/plan.md` or `.users/u-123/notes/todo.md`. In URLs, single-segment parameters (bucket, ids, keys, tokens, app keys) are fully percent-encoded, while file and folder paths keep their `/` and have each segment encoded (a leading `/` is dropped). Segments that are exactly `.` or `..` are rejected with `DocsError` before anything is sent. Optional query arguments left as `None` are omitted.
|
|
53
|
+
|
|
54
|
+
## Surface
|
|
55
|
+
|
|
56
|
+
| Resource | Methods |
|
|
57
|
+
| ------------------- | ------- |
|
|
58
|
+
| `client` | `health()` (plain text), `search(bucket, q)` |
|
|
59
|
+
| `client.buckets` | `get()`, `stats(bucket)` |
|
|
60
|
+
| `client.folders` | `list(bucket, prefix=, sizes=)`, `list_at(bucket, path, sizes=)`, `list_public(bucket, prefix=, sizes=)`, `list_user(bucket, user_id, prefix=, sizes=)`, `list_app(bucket, app_id, prefix=, sizes=)`, `get(bucket, key)`, `create(bucket, body)`, `delete(bucket, key)`, `delete_by_path(bucket, path)`, `upload(bucket, files, relative_paths=, parent_id=, prefix=)`, `download_zip(bucket, path)`, `duplicate(bucket, body)`, `rename(bucket, body)` |
|
|
61
|
+
| `client.files` | `get(bucket, path)`, `read(bucket, path, meta=)`, `create_blank(bucket, body)`, `update(bucket, body)`, `update_at(bucket, path, body)`, `rename(bucket, body)`, `delete(bucket, path)`, `delete_by_path(bucket, path)`, `upload(bucket, file, parent_id=, on_conflict=)`, `download_url(bucket, path)`, `preview_url(bucket, path)`, `list_archive(bucket, path)` |
|
|
62
|
+
| `client.nodes` | `get(node_id)`, `resolve(node_id, download=False)`, `content(node_id)` |
|
|
63
|
+
| `client.library` | `star(bucket, node_id)`, `unstar(bucket, node_id)`, `star_path(bucket, path, type=)`, `unstar_path(bucket, path)`, `starred(bucket)`, `recent(bucket, limit=)`, `trash(bucket)`, `restore(bucket, node_id)`, `purge(bucket, node_id)` |
|
|
64
|
+
| `client.sharing` | `create(bucket, body)`, `list(bucket, path, type=)`, `delete(bucket, id)`, `shared_with_me(bucket, app_key=)`, `create_link(bucket, body)`, `list_links(bucket, path, type=)`, `delete_link(bucket, id)`, `resolve_link(token)` |
|
|
65
|
+
| `client.automation` | `list(bucket, prefix=)`, `create(bucket, body)`, `get(bucket, id)`, `update(bucket, id, body)`, `delete(bucket, id)`, `jobs(bucket, config_id=, limit=)` |
|
|
66
|
+
| `client.app_files` | `upload(app_key, file, path=, on_conflict=)`, `list(app_key, prefix=)`, `delete(app_key, path)`, `sweep(app_key, body)` |
|
|
67
|
+
| `client.triggers` | `list()`, `create(body)`, `get(id)`, `update(id, body)`, `delete(id)` |
|
|
68
|
+
| `client.webhooks` | `list(page=, limit=)`, `create(body)`, `get(id)`, `update(id, body)`, `delete(id)` |
|
|
69
|
+
|
|
70
|
+
`triggers.delete` and `webhooks.delete` return `None`.
|
|
71
|
+
|
|
72
|
+
## Examples
|
|
73
|
+
|
|
74
|
+
### Folders
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
# sizes=False skips recursive folder sizes (folders then report size 0)
|
|
78
|
+
client.folders.list(bucket, prefix="projects", sizes=False)
|
|
79
|
+
client.folders.list_user(bucket, "u-123", prefix="notes") # a personal drive
|
|
80
|
+
client.folders.rename(
|
|
81
|
+
bucket, {"parentName": "projects", "oldName": "2025", "newName": "archive/2025"}
|
|
82
|
+
)
|
|
83
|
+
client.folders.delete_by_path(bucket, "projects/old/") # any depth; goes to the trash
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Files
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
current = client.files.read(bucket, "notes/todo.md") # content + etag + role
|
|
90
|
+
client.files.update(
|
|
91
|
+
bucket,
|
|
92
|
+
{
|
|
93
|
+
"parentName": "notes",
|
|
94
|
+
"fileName": "todo.md",
|
|
95
|
+
"content": "# Todo\n- ship",
|
|
96
|
+
"ifMatch": current["etag"],
|
|
97
|
+
},
|
|
98
|
+
) # DocsError(status=412) when someone saved in between
|
|
99
|
+
client.files.create_blank(bucket, {"parentName": "notes", "fileName": "ideas.md"})
|
|
100
|
+
url = client.files.download_url(bucket, "reports/q3.pdf") # short-lived signed URL
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Nodes (permalinks)
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
result = client.nodes.resolve("7489521000000000042", download=True)
|
|
107
|
+
if result.url: # 307: a fresh short-lived URL
|
|
108
|
+
print(result.url)
|
|
109
|
+
elif result.content: # 200: bytes of a file just restored from cold storage
|
|
110
|
+
open(result.content.file_name or "file", "wb").write(result.content.data)
|
|
111
|
+
elif result.restoring: # 202: restore in progress
|
|
112
|
+
print(f"retry in {result.retry_after}s:", result.restoring["message"])
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`nodes.content(node_id)` streams the bytes instead of redirecting (`result.content`, or `result.restoring` while a restore runs).
|
|
116
|
+
|
|
117
|
+
### Library
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
client.library.star_path(bucket, "projects/plan.md")
|
|
121
|
+
client.library.recent(bucket, limit=20)
|
|
122
|
+
trashed = client.library.trash(bucket)
|
|
123
|
+
client.library.restore(bucket, trashed[0]["id"])
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Sharing
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
client.sharing.create(
|
|
130
|
+
bucket,
|
|
131
|
+
{
|
|
132
|
+
"path": ".users/u-123/q3.pdf",
|
|
133
|
+
"type": "file",
|
|
134
|
+
"subjectType": "user",
|
|
135
|
+
"subjectId": "u-456",
|
|
136
|
+
"role": "viewer",
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
grants = client.sharing.list(bucket, ".users/u-123/q3.pdf")
|
|
140
|
+
link = client.sharing.create_link(bucket, {"path": ".users/u-123/q3.pdf", "type": "file"})
|
|
141
|
+
file_url = client.sharing.resolve_link(link["token"])
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Automation
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
rule = client.automation.create(
|
|
148
|
+
bucket,
|
|
149
|
+
{
|
|
150
|
+
"prefix": "invoices/",
|
|
151
|
+
"pipelineType": "custom_workflow",
|
|
152
|
+
"workflowId": "wf_1",
|
|
153
|
+
"fileSuffixes": [".pdf"],
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
client.automation.jobs(bucket, config_id=rule["id"], limit=10)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### App files
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
stored = client.app_files.upload("invoicing", ("INV-0042.pdf", pdf_bytes), path="2026/invoices")
|
|
163
|
+
stored["permalink"] # store this (or stored["nodeId"]), not a storage URL
|
|
164
|
+
client.app_files.sweep("invoicing", {"prefix": "2026", "olderThanDays": 365})
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Triggers and webhooks
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
client.triggers.create(
|
|
171
|
+
{
|
|
172
|
+
"eventType": "create",
|
|
173
|
+
"prefix": "invoices/",
|
|
174
|
+
"suffix": ".pdf",
|
|
175
|
+
"workflowId": "wf_1",
|
|
176
|
+
"active": True,
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
client.webhooks.create(
|
|
180
|
+
{
|
|
181
|
+
"url": "https://hooks.example.com/docs",
|
|
182
|
+
"method": "POST",
|
|
183
|
+
"prefix": "uploads/",
|
|
184
|
+
"eventTypes": ["create", "delete"],
|
|
185
|
+
"active": True,
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
client.webhooks.list(page=1, limit=50)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Uploads
|
|
192
|
+
|
|
193
|
+
Upload methods take a file as an `UploadFile(name, data, content_type=None)` or a `(name, data)` / `(name, data, content_type)` tuple, where `data` is `bytes` or a binary file object (streamed). Without a content type, one is guessed from the name (else `application/octet-stream`). Form fields use the service's exact names:
|
|
194
|
+
|
|
195
|
+
| Method | Parts |
|
|
196
|
+
| ------------------- | ----- |
|
|
197
|
+
| `files.upload` | `file`, `ParentID` (from `parent_id`), `onConflict` |
|
|
198
|
+
| `folders.upload` | repeated `files`, repeated `relativePaths` (same order; one per file), `parentID`, `prefix` |
|
|
199
|
+
| `app_files.upload` | `file`, `path`, `onConflict` |
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
from lowcoai.docs import UploadFile
|
|
203
|
+
|
|
204
|
+
with open("photo.jpg", "rb") as fh:
|
|
205
|
+
client.folders.upload(
|
|
206
|
+
bucket,
|
|
207
|
+
[UploadFile("photo.jpg", fh), ("notes.txt", b"hello")],
|
|
208
|
+
relative_paths=["trip/photo.jpg", "trip/notes.txt"],
|
|
209
|
+
parent_id="projects",
|
|
210
|
+
)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Redirects and downloads
|
|
214
|
+
|
|
215
|
+
- `files.download_url`, `files.preview_url` and `sharing.resolve_link` answer with a 307 redirect. The SDK never follows it (even if your `httpx` client is configured to) and returns the `Location` URL as a string.
|
|
216
|
+
- `folders.download_zip` returns a `Binary(data, content_type, file_name)`; `file_name` comes from the `X-File-Name` header, else from `Content-Disposition`.
|
|
217
|
+
- `nodes.resolve` / `nodes.content` return a `NodeFileResult` with exactly one of `url`, `content` (`Binary`) or `restoring` (`ArchiveRestoring`) set, plus `retry_after` (seconds, from `Retry-After`) on a 202.
|
|
218
|
+
- `health()` returns the plain-text body (`"Working!"`); it is served at `/health`, outside `/v1/documents`.
|
|
219
|
+
|
|
220
|
+
### Endpoints the SDK does not wrap
|
|
221
|
+
|
|
222
|
+
`HttpClient` / `AsyncHttpClient` are the transports behind the resources and apply the same headers, envelope unwrapping and error mapping (`request`, `request_void`, `request_text`, `request_binary`, `request_redirect`, `request_node_file`, `request_multipart`):
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from lowcoai.docs import HttpClient
|
|
226
|
+
|
|
227
|
+
with HttpClient("<token>", org_id="org_123") as http:
|
|
228
|
+
data = http.request("GET", "/v1/documents/my-bucket/objects", query={"prefix": "a"})
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Errors
|
|
232
|
+
|
|
233
|
+
Every non-2xx response raises `DocsError`:
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
from lowcoai.docs import DocsError
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
client.files.read(bucket, "missing.md")
|
|
240
|
+
except DocsError as err:
|
|
241
|
+
print(err.status, err.message, err.code, err.payload)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
- `message` — the best message in the error body. The service sends either `{"status": 0, "error": {"message", "code", "details"}}` or `{"message": "..."}`. When `error.message` is a platform code such as `AAS-00106`, `details` is the message and the code goes to `code`; otherwise `error.message` (or `message`) is used, falling back to `"Request failed with status <n>"`.
|
|
245
|
+
- `status` — the HTTP status; `0` when no response arrived (connection error, timeout — the `httpx` exception is chained as `__cause__`) or a `.` / `..` path segment was refused.
|
|
246
|
+
- `code` — the platform error code (e.g. `AAS-00106`) when the body carries one, else `None`.
|
|
247
|
+
- `payload` — the parsed JSON error body (a failed conditional save puts the file's current `currentEtag` / `updatedBy` under `payload["data"]`); the raw text when the body is not JSON; `None` when empty.
|
|
248
|
+
|
|
249
|
+
A 2xx response whose body is not JSON raises `DocsError` with that 2xx `status` and the raw text as `payload` (`triggers.delete` / `webhooks.delete` ignore the body). A redirect method that gets no 3xx `Location` raises `DocsError` too.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lowcoai-docs"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for the lowco document service (files, folders, sharing, triggers, webhooks)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.25,<1",
|
|
14
|
+
"typing-extensions>=4.7",
|
|
15
|
+
]
|
|
16
|
+
keywords = ["documents", "files", "storage", "sharing", "lowco", "sdk", "client"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
"Framework :: AsyncIO",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Repository = "https://github.com/lowcoai/lowco-sdk"
|
|
25
|
+
Source = "https://github.com/lowcoai/lowco-sdk/tree/main/python/docs"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "mypy>=1.10", "ruff>=0.5"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/lowcoai"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
pythonpath = ["src"]
|
|
35
|
+
asyncio_mode = "auto"
|
|
36
|
+
|
|
37
|
+
[tool.mypy]
|
|
38
|
+
strict = true
|
|
39
|
+
mypy_path = "src"
|
|
40
|
+
explicit_package_bases = true
|
|
41
|
+
# Responses are decoded JSON typed as the documented TypedDict (like `as T` in
|
|
42
|
+
# the TypeScript SDK), so returning `Any` from endpoint methods is intended.
|
|
43
|
+
warn_return_any = false
|
|
44
|
+
|
|
45
|
+
[tool.ruff]
|
|
46
|
+
line-length = 100
|
|
47
|
+
target-version = "py310"
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
select = ["E", "F", "W", "I", "UP", "B"]
|
|
@@ -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__
|