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.
Files changed (38) hide show
  1. updo_sdk-0.1.0/LICENSE +21 -0
  2. updo_sdk-0.1.0/PKG-INFO +618 -0
  3. updo_sdk-0.1.0/README.md +584 -0
  4. updo_sdk-0.1.0/pyproject.toml +99 -0
  5. updo_sdk-0.1.0/setup.cfg +4 -0
  6. updo_sdk-0.1.0/src/updo/__init__.py +92 -0
  7. updo_sdk-0.1.0/src/updo/_transport.py +196 -0
  8. updo_sdk-0.1.0/src/updo/_version.py +3 -0
  9. updo_sdk-0.1.0/src/updo/aio.py +235 -0
  10. updo_sdk-0.1.0/src/updo/cli.py +681 -0
  11. updo_sdk-0.1.0/src/updo/client.py +241 -0
  12. updo_sdk-0.1.0/src/updo/codegen.py +465 -0
  13. updo_sdk-0.1.0/src/updo/config.py +140 -0
  14. updo_sdk-0.1.0/src/updo/errors.py +305 -0
  15. updo_sdk-0.1.0/src/updo/models.py +678 -0
  16. updo_sdk-0.1.0/src/updo/py.typed +0 -0
  17. updo_sdk-0.1.0/src/updo/query.py +201 -0
  18. updo_sdk-0.1.0/src/updo/resources/__init__.py +14 -0
  19. updo_sdk-0.1.0/src/updo/resources/_params.py +261 -0
  20. updo_sdk-0.1.0/src/updo/resources/base.py +75 -0
  21. updo_sdk-0.1.0/src/updo/resources/entities.py +88 -0
  22. updo_sdk-0.1.0/src/updo/resources/records.py +521 -0
  23. updo_sdk-0.1.0/src/updo/resources/webhooks.py +275 -0
  24. updo_sdk-0.1.0/src/updo/webhooks.py +148 -0
  25. updo_sdk-0.1.0/src/updo_sdk.egg-info/PKG-INFO +618 -0
  26. updo_sdk-0.1.0/src/updo_sdk.egg-info/SOURCES.txt +36 -0
  27. updo_sdk-0.1.0/src/updo_sdk.egg-info/dependency_links.txt +1 -0
  28. updo_sdk-0.1.0/src/updo_sdk.egg-info/entry_points.txt +2 -0
  29. updo_sdk-0.1.0/src/updo_sdk.egg-info/requires.txt +17 -0
  30. updo_sdk-0.1.0/src/updo_sdk.egg-info/top_level.txt +1 -0
  31. updo_sdk-0.1.0/tests/test_async.py +170 -0
  32. updo_sdk-0.1.0/tests/test_cli.py +461 -0
  33. updo_sdk-0.1.0/tests/test_client.py +566 -0
  34. updo_sdk-0.1.0/tests/test_codegen.py +423 -0
  35. updo_sdk-0.1.0/tests/test_contract.py +200 -0
  36. updo_sdk-0.1.0/tests/test_errors.py +184 -0
  37. updo_sdk-0.1.0/tests/test_query.py +124 -0
  38. 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.
@@ -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).