updo-sdk 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.
- updo_sdk-0.1.0/LICENSE +21 -0
- updo_sdk-0.1.0/PKG-INFO +618 -0
- updo_sdk-0.1.0/README.md +584 -0
- updo_sdk-0.1.0/pyproject.toml +99 -0
- updo_sdk-0.1.0/setup.cfg +4 -0
- updo_sdk-0.1.0/src/updo/__init__.py +92 -0
- updo_sdk-0.1.0/src/updo/_transport.py +196 -0
- updo_sdk-0.1.0/src/updo/_version.py +3 -0
- updo_sdk-0.1.0/src/updo/aio.py +235 -0
- updo_sdk-0.1.0/src/updo/cli.py +681 -0
- updo_sdk-0.1.0/src/updo/client.py +241 -0
- updo_sdk-0.1.0/src/updo/codegen.py +465 -0
- updo_sdk-0.1.0/src/updo/config.py +140 -0
- updo_sdk-0.1.0/src/updo/errors.py +305 -0
- updo_sdk-0.1.0/src/updo/models.py +678 -0
- updo_sdk-0.1.0/src/updo/py.typed +0 -0
- updo_sdk-0.1.0/src/updo/query.py +201 -0
- updo_sdk-0.1.0/src/updo/resources/__init__.py +14 -0
- updo_sdk-0.1.0/src/updo/resources/_params.py +261 -0
- updo_sdk-0.1.0/src/updo/resources/base.py +75 -0
- updo_sdk-0.1.0/src/updo/resources/entities.py +88 -0
- updo_sdk-0.1.0/src/updo/resources/records.py +521 -0
- updo_sdk-0.1.0/src/updo/resources/webhooks.py +275 -0
- updo_sdk-0.1.0/src/updo/webhooks.py +148 -0
- updo_sdk-0.1.0/src/updo_sdk.egg-info/PKG-INFO +618 -0
- updo_sdk-0.1.0/src/updo_sdk.egg-info/SOURCES.txt +36 -0
- updo_sdk-0.1.0/src/updo_sdk.egg-info/dependency_links.txt +1 -0
- updo_sdk-0.1.0/src/updo_sdk.egg-info/entry_points.txt +2 -0
- updo_sdk-0.1.0/src/updo_sdk.egg-info/requires.txt +17 -0
- updo_sdk-0.1.0/src/updo_sdk.egg-info/top_level.txt +1 -0
- updo_sdk-0.1.0/tests/test_async.py +170 -0
- updo_sdk-0.1.0/tests/test_cli.py +461 -0
- updo_sdk-0.1.0/tests/test_client.py +566 -0
- updo_sdk-0.1.0/tests/test_codegen.py +423 -0
- updo_sdk-0.1.0/tests/test_contract.py +200 -0
- updo_sdk-0.1.0/tests/test_errors.py +184 -0
- updo_sdk-0.1.0/tests/test_query.py +124 -0
- updo_sdk-0.1.0/tests/test_webhooks.py +235 -0
updo_sdk-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 UPDO Technologies Inc.
|
|
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.
|
updo_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: updo-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Updo360 (Qlaris) public ERP API
|
|
5
|
+
Author: UPDO Technologies Inc.
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://updo.pro
|
|
8
|
+
Project-URL: Documentation, https://api.updo.pro/api/public/v1/docs/
|
|
9
|
+
Keywords: updo,updo360,qlaris,erp,api,sdk,no-code
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: httpx<1,>=0.27
|
|
23
|
+
Provides-Extra: cli
|
|
24
|
+
Requires-Dist: tomli>=2.0; python_version < "3.11" and extra == "cli"
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest<9,>=8; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
29
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
31
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
32
|
+
Requires-Dist: tomli>=2.0; python_version < "3.11" and extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# updo-sdk
|
|
36
|
+
|
|
37
|
+
Python client for the **Updo360 public API** (Qlaris ERP) — synchronous and asynchronous.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install updo-sdk
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from updo import UpdoClient
|
|
45
|
+
|
|
46
|
+
with UpdoClient(token="sk_live_…") as client:
|
|
47
|
+
print(client.me().tenant_slug)
|
|
48
|
+
|
|
49
|
+
for product in client.entity("product").iterate(where={"status": "active"}):
|
|
50
|
+
print(product["sku"], product["sale_price"])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## What you need to know before you start
|
|
56
|
+
|
|
57
|
+
The public API lives under `https://api.updo.pro/api/public/v1/` and accepts
|
|
58
|
+
exactly one thing: a **personal access token** (`sk_live_…` in production,
|
|
59
|
+
`sk_test_…` for the sandbox twin of that workspace).
|
|
60
|
+
|
|
61
|
+
- **One token = one workspace.** The binding is inside the token, so there is
|
|
62
|
+
*no* tenant header to send (the SDK never sends one: an `X-Tenant-ID` can only
|
|
63
|
+
cause a 401).
|
|
64
|
+
- **The data model is defined per tenant.** Entities and their fields are
|
|
65
|
+
created in the Atelier (labelled **Workshop** in the English UI), so no static
|
|
66
|
+
class can describe them. You discover them at runtime (`client.entities`) or
|
|
67
|
+
generate typed models (`updo codegen`).
|
|
68
|
+
- **A token can only call `/api/public/v1/`.** The internal `/api/v1/` routes
|
|
69
|
+
track whatever the product's own UI needs and carry no stability contract; the
|
|
70
|
+
server deliberately rejects them when the caller is a token.
|
|
71
|
+
|
|
72
|
+
Create a token in Updo (*Settings → API tokens*). **The secret is shown only
|
|
73
|
+
once.**
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Installation
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install updo-sdk # library
|
|
81
|
+
pip install "updo-sdk[cli]" # + the `updo` command
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Python ≥ 3.10. Only dependency: `httpx`.
|
|
85
|
+
|
|
86
|
+
The `[cli]` extra is not needed to use the library, and is not needed for the
|
|
87
|
+
`updo` command either — it adds `tomli` so that `updo --profile` can read a TOML
|
|
88
|
+
config file on Python 3.10, where `tomllib` is not yet in the standard library.
|
|
89
|
+
Importing the package never touches the CLI: `import updo` pulls in `httpx` and
|
|
90
|
+
nothing else.
|
|
91
|
+
|
|
92
|
+
A runnable end-to-end example lives in [examples/quickstart.py](examples/quickstart.py).
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Configuration
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
client = UpdoClient(token="sk_live_…") # api.updo.pro
|
|
100
|
+
client = UpdoClient(token="sk_test_…", base_url="http://localhost:8000")
|
|
101
|
+
client = UpdoClient() # $UPDO_API_TOKEN / $UPDO_BASE_URL
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
| Parameter | Default | Role |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `token` | `$UPDO_API_TOKEN` | `sk_…` token |
|
|
107
|
+
| `base_url` | `$UPDO_BASE_URL`, otherwise `https://api.updo.pro` | API root |
|
|
108
|
+
| `timeout` | `30.0` | seconds (or an `httpx.Timeout`) |
|
|
109
|
+
| `max_retries` | `3` | number of retries |
|
|
110
|
+
| `on_approval` | `"raise"` | `"return"` to receive an object instead of an exception on a 202 |
|
|
111
|
+
| `http_client` | `None` | your own `httpx.Client` (pool, proxy, mTLS…) |
|
|
112
|
+
|
|
113
|
+
The base URL is normalised: `https://api.updo.pro`, `https://updo.pro/`,
|
|
114
|
+
`http://localhost:8000` and even `…/api/v1` (pasted from the frontend config)
|
|
115
|
+
all lead to `…/api/public/v1`.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Discovering the model
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
for entity in client.entities.list():
|
|
123
|
+
print(entity.slug, entity.display("fr")) # 'product', 'Produit'
|
|
124
|
+
|
|
125
|
+
schema = client.entities.schema("product") # cached
|
|
126
|
+
for field in schema.fields:
|
|
127
|
+
print(field.slug, field.field_type, "writable" if field.writable else "read-only")
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`schema.fields` contains **only** the fields this token is allowed to read, and
|
|
131
|
+
`writable` is read from the server's serialiser: a form built from it cannot
|
|
132
|
+
offer a field the server would refuse.
|
|
133
|
+
|
|
134
|
+
### Why the entity list can come back empty
|
|
135
|
+
|
|
136
|
+
Authorization is deny-by-default, and the index deliberately **omits** entities
|
|
137
|
+
the token cannot read rather than listing them as forbidden — so a token with no
|
|
138
|
+
grants sees a `200` and an empty workspace, not an error. That looks like a
|
|
139
|
+
broken API and is really a missing grant. What each case looks like:
|
|
140
|
+
|
|
141
|
+
| What you see | What it means | Fix |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| `200`, `results: []` | no grant on any entity | grant the token's role `read` on the entities, in the access manager |
|
|
144
|
+
| `403 ACCESS_DENIED` on `schema/` | same, for that entity | idem |
|
|
145
|
+
| index lists an entity, its `schema/` returns `404` | the entity belongs to a module whose subscription has lapsed — the index does not check subscriptions, the schema route does | reactivate the module, or pass `--entity` explicitly |
|
|
146
|
+
| `403` with `policy_id: token:scope_ceiling` | the token's own `scopes` are narrower than its roles | reissue the token with `scopes: []`, or add `data:<slug>:read` |
|
|
147
|
+
| fewer fields than expected | per-field read permissions | expected: two tokens legitimately see two different shapes |
|
|
148
|
+
|
|
149
|
+
`updo codegen` without `--entity` skips a lapsed-module entity and reports it on
|
|
150
|
+
stderr rather than aborting the run. Three curl calls settle where you stand:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
curl -H "Authorization: Bearer $UPDO_API_TOKEN" https://api.updo.pro/api/public/v1/me/
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
curl -H "Authorization: Bearer $UPDO_API_TOKEN" https://api.updo.pro/api/public/v1/data/
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Reading records
|
|
163
|
+
|
|
164
|
+
A record is **flat**: `{id, created_at, updated_at, <field>: value…}`. Business
|
|
165
|
+
fields are read like a dictionary; technical columns remain attributes (so an
|
|
166
|
+
entity that happens to define a field named `id` does not shadow the record's
|
|
167
|
+
own identifier).
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
products = client.entity("product")
|
|
171
|
+
|
|
172
|
+
page = products.list(page_size=50, ordering="-sale_price")
|
|
173
|
+
print(page.count, len(page.results))
|
|
174
|
+
|
|
175
|
+
product = products.get("0f4d2b5e-1c3a-4e6f-9a8b-7c6d5e4f3a2b")
|
|
176
|
+
product.id, product.created_at # metadata
|
|
177
|
+
product["sku"], product.get("name") # business fields
|
|
178
|
+
|
|
179
|
+
for p in products.iterate(where={"status": "active"}): # walks through every page
|
|
180
|
+
...
|
|
181
|
+
|
|
182
|
+
products.count(where={"status": "draft"})
|
|
183
|
+
products.first(where={"sku": "ABC-123"})
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Filters
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
products.list(
|
|
190
|
+
where={
|
|
191
|
+
"sku": "ABC-123", # equality
|
|
192
|
+
"name__icontains": "croquette", # substring, case-insensitive
|
|
193
|
+
"sale_price__gte": 10, # ≥
|
|
194
|
+
"status__in": ["active", "draft"], # list
|
|
195
|
+
"barcode__isnull": True, # empty field
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Suffixes: `gte` `lte` `gt` `lt` `contains` `icontains` `in` `isnull`. Conditions
|
|
201
|
+
are combined with **AND**. Expression variant:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from updo import F
|
|
205
|
+
|
|
206
|
+
products.list(where=F(status="active") & F(sale_price__gte=10))
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
> **Two server pitfalls worth knowing.**
|
|
210
|
+
> A filter on an **unknown** field returns **400**. A filter on a field the
|
|
211
|
+
> token is not allowed to read is **silently ignored** — this is intentional
|
|
212
|
+
> (otherwise `?data__salaire__gte=` would become an oracle for guessing a masked
|
|
213
|
+
> value), but it means a result set can be wider than expected with no error to
|
|
214
|
+
> signal it. When in doubt, check the field with
|
|
215
|
+
> `client.entities.schema(...)`.
|
|
216
|
+
|
|
217
|
+
Dates and times need an explicit offset. A naive `datetime` is **refused** by
|
|
218
|
+
the SDK rather than sent, because the server reads a naive value in the
|
|
219
|
+
*tenant's* timezone — the same code would then mean different instants in
|
|
220
|
+
different workspaces:
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
from datetime import datetime, timezone
|
|
224
|
+
|
|
225
|
+
products.list(where={"released_on__gte": datetime(2026, 1, 1, tzinfo=timezone.utc)})
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Search, ordering, relations
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
products.list(search="chien") # free text over text fields
|
|
232
|
+
products.list(ordering=["-sale_price", "name"])
|
|
233
|
+
products.list(expand="supplier") # populates record.expanded
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Automatic value typing
|
|
237
|
+
|
|
238
|
+
By default, values come back raw — the JSON exactly as sent. With the schema
|
|
239
|
+
loaded, dates and decimals are promoted to Python objects:
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
products = client.entity("product", coerce=True) # loads the schema once
|
|
243
|
+
p = products.get(record_id)
|
|
244
|
+
p["sale_price"] # Decimal('12.50') — exact, not a float
|
|
245
|
+
p["released_on"] # datetime.date(2026, 1, 15)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## Writing
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
p = products.create({"sku": "ABC-123", "name": "Croquettes", "sale_price": 12.50})
|
|
254
|
+
products.update(p.id, {"sale_price": 13.90}) # PATCH — recommended
|
|
255
|
+
products.replace(p.id, {...}) # PUT
|
|
256
|
+
products.delete(p.id)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
> Fields of type `password` read back masked (`••••••••`). Sending a
|
|
260
|
+
> freshly-read record back through `replace()` would therefore overwrite the
|
|
261
|
+
> real secret. `record.is_masked("field")` detects it; prefer `update()`.
|
|
262
|
+
|
|
263
|
+
### Writes subject to approval
|
|
264
|
+
|
|
265
|
+
A write can be put on hold for approval (HTTP 202) instead of being applied. By
|
|
266
|
+
default the SDK **raises** `ApprovalRequired` — returning an object with no `id`
|
|
267
|
+
would let the calling code carry on as if the write had happened.
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
from updo import ApprovalRequired
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
products.create({...})
|
|
274
|
+
except ApprovalRequired as pending:
|
|
275
|
+
print(pending.approval_request_id)
|
|
276
|
+
|
|
277
|
+
# or, to handle the case without an exception:
|
|
278
|
+
client = UpdoClient(token="sk_live_…", on_approval="return")
|
|
279
|
+
result = client.entity("invoice").create({...}) # Record or ApprovalPending
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## Analytics and export
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
agg = products.aggregate(group_by="status", metrics=["count", "sum:sale_price"])
|
|
288
|
+
for row in agg.results:
|
|
289
|
+
print(row["group"], row["count"])
|
|
290
|
+
|
|
291
|
+
agg = products.aggregate(group_by="created_at", bucket="month", metrics=["count"])
|
|
292
|
+
|
|
293
|
+
pv = products.pivot(rows="status", cols="category", metric="count")
|
|
294
|
+
pv.cell("active", "chien")
|
|
295
|
+
pv.to_rows() # ready for csv.DictWriter or pandas
|
|
296
|
+
|
|
297
|
+
od = products.query(
|
|
298
|
+
select=["sku", "sale_price"], orderby=[("sale_price", "desc")], top=100, count=True
|
|
299
|
+
)
|
|
300
|
+
od.value, od.count # $top is capped at 500 server-side
|
|
301
|
+
|
|
302
|
+
products.export("csv", dest="products.csv", where={"status": "active"})
|
|
303
|
+
products.export("xlsx", dest="products.xlsx")
|
|
304
|
+
data = products.export("csv") # without dest: the bytes
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The export honours filters, search and ordering, and ignores pagination: you get
|
|
308
|
+
the **entire** filtered collection.
|
|
309
|
+
|
|
310
|
+
---
|
|
311
|
+
|
|
312
|
+
## Webhooks
|
|
313
|
+
|
|
314
|
+
### Subscribing
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
hook = client.webhooks.create(
|
|
318
|
+
url="https://my-service.example.com/updo", # https required
|
|
319
|
+
event_pattern="invoice.*", # or "invoice.paid", "*.created"
|
|
320
|
+
slug="invoice-paid",
|
|
321
|
+
secret="whsec_…", # signs every delivery
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
client.webhooks.update(hook.id, is_active=False)
|
|
325
|
+
client.webhooks.delete(hook.id)
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Patterns accept a wildcard **per segment**: `invoice.*`, `*.created`. Events are
|
|
329
|
+
`<entity>.created` / `.updated` / `.deleted`, plus the platform events
|
|
330
|
+
(`document.signed`, `workflow.approval_requested`…).
|
|
331
|
+
|
|
332
|
+
### Verifying a received delivery
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
from updo.webhooks import parse_event, verify_signature
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@app.post("/updo")
|
|
339
|
+
def receive(request):
|
|
340
|
+
raw = request.body # the BYTES, before any JSON parsing
|
|
341
|
+
if not verify_signature(
|
|
342
|
+
SECRET,
|
|
343
|
+
raw,
|
|
344
|
+
request.headers.get("X-Qlaris-Signature"),
|
|
345
|
+
timestamp=request.headers.get("X-Qlaris-Timestamp"),
|
|
346
|
+
):
|
|
347
|
+
return 401
|
|
348
|
+
|
|
349
|
+
event = parse_event(raw, headers=request.headers)
|
|
350
|
+
if already_processed(event.delivery_id): # stable across retries
|
|
351
|
+
return 200
|
|
352
|
+
process(event.event, event.data)
|
|
353
|
+
return 200
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
> ⚠️ **Sign the raw bytes.** Updo signs exactly what it puts on the wire
|
|
357
|
+
> (`json.dumps(sort_keys=True, separators=(",",":"))`). Re-parsing then
|
|
358
|
+
> re-serialising the JSON changes key order and whitespace: the signature will
|
|
359
|
+
> no longer match — and you will reject a payload that was perfectly genuine.
|
|
360
|
+
|
|
361
|
+
Reply 2xx quickly: a response ≥ 400 is retried up to 3 times with an increasing
|
|
362
|
+
delay, and the codes 400/401/403/404/405/410/422 are treated as final (the
|
|
363
|
+
delivery is abandoned).
|
|
364
|
+
|
|
365
|
+
### Delivery log
|
|
366
|
+
|
|
367
|
+
```python
|
|
368
|
+
for delivery in client.webhooks.iterate_deliveries(status="failed"):
|
|
369
|
+
print(delivery.event_name, delivery.response_code, delivery.error)
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
374
|
+
## Errors
|
|
375
|
+
|
|
376
|
+
```python
|
|
377
|
+
from updo import (
|
|
378
|
+
UpdoError,
|
|
379
|
+
UpdoAPIError,
|
|
380
|
+
AuthenticationError,
|
|
381
|
+
PermissionDenied,
|
|
382
|
+
NotFoundError,
|
|
383
|
+
ValidationError,
|
|
384
|
+
ConflictError,
|
|
385
|
+
PlanLimitExceeded,
|
|
386
|
+
RateLimitError,
|
|
387
|
+
ServerError,
|
|
388
|
+
ApprovalRequired,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
products.create({"sku": ""})
|
|
393
|
+
except ValidationError as exc:
|
|
394
|
+
print(exc.code) # 'BUSINESS_RULE_VIOLATION'
|
|
395
|
+
print(exc.field_errors) # {'sku': ['This field is required.']}
|
|
396
|
+
except PermissionDenied as exc:
|
|
397
|
+
print(exc.policy_id) # e.g. 'token:scope_ceiling'
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
The server speaks **two error dialects** (the platform envelope
|
|
401
|
+
`{code, detail, field_errors}` and DRF's raw form); the SDK normalises them, so
|
|
402
|
+
`exc.code` / `exc.detail` / `exc.field_errors` are always readable.
|
|
403
|
+
|
|
404
|
+
| Status | Exception | Typical case |
|
|
405
|
+
|---|---|---|
|
|
406
|
+
| 401 | `AuthenticationError` | unknown or expired token, IP refused, call outside `/api/public/` |
|
|
407
|
+
| 402 | `PlanLimitExceeded` | subscription quota reached |
|
|
408
|
+
| 403 | `PermissionDenied` | ABAC refusal, token scope ceiling, module disabled |
|
|
409
|
+
| 404 | `NotFoundError` | entity or record does not exist |
|
|
410
|
+
| 400 / 422 | `ValidationError` | business rule, invalid value, unknown parameter |
|
|
411
|
+
| 409 | `ConflictError` | protected deletion, separation-of-duties conflict |
|
|
412
|
+
| 429 | `RateLimitError` | throttling — `exc.retry_after` |
|
|
413
|
+
| 5xx | `ServerError` | platform-side outage |
|
|
414
|
+
|
|
415
|
+
### Retries
|
|
416
|
+
|
|
417
|
+
The SDK retries automatically, with exponential backoff and *jitter*:
|
|
418
|
+
|
|
419
|
+
- **429** on every method (`Retry-After` honoured) — the request was rejected
|
|
420
|
+
*before* any side effect, so replaying it is safe;
|
|
421
|
+
- **5xx and network drops** only on idempotent methods (GET/PUT/DELETE). A 500
|
|
422
|
+
after a POST can mean the write succeeded and only the response was lost:
|
|
423
|
+
replaying it would create a duplicate.
|
|
424
|
+
|
|
425
|
+
Four throttle buckets apply to a token at once: 1000/h per token, 10000/h per
|
|
426
|
+
workspace, 3000/h per user, and a 120/min burst limit.
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
## Asynchronous client
|
|
431
|
+
|
|
432
|
+
Same surface, same guarantees:
|
|
433
|
+
|
|
434
|
+
```python
|
|
435
|
+
import asyncio
|
|
436
|
+
from updo import AsyncUpdoClient
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
async def main():
|
|
440
|
+
async with AsyncUpdoClient(token="sk_live_…") as client:
|
|
441
|
+
products = await client.entity("product")
|
|
442
|
+
async for p in products.iterate(where={"status": "active"}):
|
|
443
|
+
print(p["sku"])
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
asyncio.run(main())
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Only difference: `client.entity()` is a coroutine (it may have to load the
|
|
450
|
+
schema).
|
|
451
|
+
|
|
452
|
+
---
|
|
453
|
+
|
|
454
|
+
## Typed models (codegen)
|
|
455
|
+
|
|
456
|
+
The OpenAPI document cannot describe a record's fields — they are defined per
|
|
457
|
+
tenant. So they are generated from the real workspace:
|
|
458
|
+
|
|
459
|
+
```bash
|
|
460
|
+
updo codegen --entity product --entity invoice --out my_app/updo_models.py
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
```python
|
|
464
|
+
from my_app.updo_models import Product
|
|
465
|
+
|
|
466
|
+
p = Product.from_record(client.entity("product").get(id))
|
|
467
|
+
p.sale_price # Decimal | None, with IDE autocompletion
|
|
468
|
+
p.status # Literal["active", "draft", "archived"] | None
|
|
469
|
+
|
|
470
|
+
new_product = Product(sku="ABC-123", name="Croquettes")
|
|
471
|
+
client.entity("product").create(new_product.to_payload()) # writable fields only
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
Regenerate after any change to the model in the Atelier. `--no-timestamp` makes
|
|
475
|
+
the output stable byte for byte, useful if the file is version-controlled.
|
|
476
|
+
|
|
477
|
+
The codegen needs nothing but a token that can **read** the entity — no builder
|
|
478
|
+
rights, no Atelier licence. What it cannot do for you is the bootstrap: minting
|
|
479
|
+
the token and defining the entities are admin gestures on the internal surface,
|
|
480
|
+
so they happen in the UI. See [Why the entity list can come back
|
|
481
|
+
empty](#why-the-entity-list-can-come-back-empty).
|
|
482
|
+
|
|
483
|
+
### What the generated types promise, and what they don't
|
|
484
|
+
|
|
485
|
+
| Field type | Generated as | Note |
|
|
486
|
+
|---|---|---|
|
|
487
|
+
| `select` `radio` `segmented` `chips` | `Literal[...]` | narrowed to the declared choices |
|
|
488
|
+
| `multi_select` `checkboxes` | `list[Literal[...]]` | |
|
|
489
|
+
| `decimal` `currency` | `Decimal` | exact, never a float |
|
|
490
|
+
| `date` `datetime` | `date` / `datetime` | |
|
|
491
|
+
| `relation` `member` | `str` | the wire carries a string, so this is what you get |
|
|
492
|
+
| `computed` `json` | `Any` | the server does not publish a result type |
|
|
493
|
+
|
|
494
|
+
Two caveats the public schema itself cannot resolve, because it does not publish
|
|
495
|
+
the discriminant:
|
|
496
|
+
|
|
497
|
+
- **Relation cardinality.** A to-many relation carries a *list* of ids, but the
|
|
498
|
+
schema publishes no `cardinality`, so every relation is annotated `str` and
|
|
499
|
+
the field's docstring says so. Check the entity in the Atelier before writing
|
|
500
|
+
to a relation.
|
|
501
|
+
- **Translatable fields** read back as a locale map (`{"fr": ..., "en": ...}`)
|
|
502
|
+
rather than a string, and nothing in the schema marks them.
|
|
503
|
+
|
|
504
|
+
Use `FieldSpec.choice_labels(lang)` when you need the human labels behind a
|
|
505
|
+
choice field rather than its raw keys.
|
|
506
|
+
|
|
507
|
+
---
|
|
508
|
+
|
|
509
|
+
## Command line
|
|
510
|
+
|
|
511
|
+
The short version is below; [docs/cli.md](docs/cli.md) is the full guide, with
|
|
512
|
+
output for every command and a troubleshooting section.
|
|
513
|
+
|
|
514
|
+
```bash
|
|
515
|
+
export UPDO_API_TOKEN=sk_live_…
|
|
516
|
+
|
|
517
|
+
updo whoami
|
|
518
|
+
updo entities
|
|
519
|
+
updo schema product
|
|
520
|
+
updo get product --where status=active --where sale_price__gte=10 --limit 20
|
|
521
|
+
updo get product --table --columns sku,name,sale_price
|
|
522
|
+
updo count product --where status=draft
|
|
523
|
+
updo create product --data '{"sku":"ABC-123","name":"Croquettes"}'
|
|
524
|
+
updo update product <id> --file patch.json
|
|
525
|
+
updo delete product <id>
|
|
526
|
+
updo export product --format xlsx --out products.xlsx
|
|
527
|
+
updo aggregate product --group-by status --metrics count,sum:sale_price
|
|
528
|
+
updo pivot product --rows status --cols category
|
|
529
|
+
updo query product --select sku,sale_price --orderby 'sale_price desc' --top 50
|
|
530
|
+
updo webhooks list
|
|
531
|
+
updo webhooks create --url https://my-service.example.com/updo --event 'invoice.*'
|
|
532
|
+
updo webhooks deliveries --status failed
|
|
533
|
+
updo codegen --out models.py
|
|
534
|
+
updo openapi --out openapi.json
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
Output is JSON when stdout is redirected and a readable table when it is a
|
|
538
|
+
terminal; override with `--json` / `--table`. Exit codes: `0` success, `2` API
|
|
539
|
+
error, `3` write pending approval.
|
|
540
|
+
|
|
541
|
+
Profiles in `~/.config/updo/config.toml` (or `%APPDATA%\updo\config.toml`):
|
|
542
|
+
|
|
543
|
+
```toml
|
|
544
|
+
[default]
|
|
545
|
+
token = "sk_live_…"
|
|
546
|
+
|
|
547
|
+
[sandbox]
|
|
548
|
+
token = "sk_test_…"
|
|
549
|
+
base_url = "http://localhost:8000"
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
```bash
|
|
553
|
+
updo --profile sandbox entities
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
The token is never displayed again: every output passes it through a mask
|
|
557
|
+
(`sk_live_ab…yz`).
|
|
558
|
+
|
|
559
|
+
---
|
|
560
|
+
|
|
561
|
+
## Covered surface
|
|
562
|
+
|
|
563
|
+
All 18 public routes, in full — a contract test checks this against the OpenAPI
|
|
564
|
+
document served by the platform.
|
|
565
|
+
|
|
566
|
+
| Route | SDK method |
|
|
567
|
+
|---|---|
|
|
568
|
+
| `GET /me/` | `client.me()` |
|
|
569
|
+
| `GET /data/` | `client.entities.list()` |
|
|
570
|
+
| `GET /data/{slug}/schema/` | `client.entities.schema(slug)` |
|
|
571
|
+
| `GET · POST /data/{slug}/` | `.list()` `.iterate()` `.create()` |
|
|
572
|
+
| `GET · PUT · PATCH · DELETE /data/{slug}/{id}/` | `.get()` `.replace()` `.update()` `.delete()` |
|
|
573
|
+
| `GET /data/{slug}/aggregate/` | `.aggregate()` |
|
|
574
|
+
| `GET /data/{slug}/pivot/` | `.pivot()` |
|
|
575
|
+
| `GET /data/{slug}/query/` | `.query()` |
|
|
576
|
+
| `GET · POST /webhooks/` | `client.webhooks.list()` `.create()` |
|
|
577
|
+
| `GET · PUT · PATCH · DELETE /webhooks/{id}/` | `.get()` `.replace()` `.update()` `.delete()` |
|
|
578
|
+
| `GET /webhooks/deliveries/[{id}/]` | `.deliveries()` `.delivery()` |
|
|
579
|
+
| `GET /schema/` | `client.openapi()` |
|
|
580
|
+
|
|
581
|
+
For any route that is not modelled, the escape hatch keeps authentication and
|
|
582
|
+
retries:
|
|
583
|
+
|
|
584
|
+
```python
|
|
585
|
+
client.request("GET", "some/future/route", params={"x": 1})
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
---
|
|
589
|
+
|
|
590
|
+
## Development
|
|
591
|
+
|
|
592
|
+
```bash
|
|
593
|
+
python -m venv .venv && .venv/Scripts/pip install -e ".[dev,cli]"
|
|
594
|
+
pytest # offline, simulated transport
|
|
595
|
+
ruff check . && ruff format --check .
|
|
596
|
+
mypy
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
Integration tests against a real instance (optional):
|
|
600
|
+
|
|
601
|
+
```bash
|
|
602
|
+
UPDO_BASE_URL=http://localhost:8000 UPDO_API_TOKEN=sk_test_… pytest -m integration
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
Use a `mode: "test"` token: it is bound to a disposable twin of the workspace,
|
|
606
|
+
so write tests do not touch production.
|
|
607
|
+
|
|
608
|
+
Refresh the contract test fixture after an API change:
|
|
609
|
+
|
|
610
|
+
```bash
|
|
611
|
+
updo openapi --out tests/data/openapi.json
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
---
|
|
615
|
+
|
|
616
|
+
## License
|
|
617
|
+
|
|
618
|
+
MIT — see [LICENSE](LICENSE).
|