Metadata-Version: 2.4
Name: updo-sdk
Version: 0.1.1
Summary: Python SDK for the Updo360 (Qlaris) public ERP API
Author: UPDO Technologies Inc.
License-Expression: MIT
Project-URL: Homepage, https://updo.pro
Project-URL: Documentation, https://api.updo.pro/api/public/v1/docs/
Project-URL: Issues, https://updo.pro/en/contact
Keywords: updo,updo360,qlaris,erp,api,sdk,no-code
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx<1,>=0.27
Provides-Extra: cli
Requires-Dist: tomli>=2.0; python_version < "3.11" and extra == "cli"
Provides-Extra: dev
Requires-Dist: pytest<9,>=8; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: pytest-cov>=5; extra == "dev"
Requires-Dist: respx>=0.21; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: mypy>=1.11; extra == "dev"
Requires-Dist: tomli>=2.0; python_version < "3.11" and extra == "dev"
Dynamic: license-file

# updo-sdk

Python client for the **Updo360 public API** (Qlaris ERP) — synchronous and asynchronous.

```bash
pip install updo-sdk
```

```python
from updo import UpdoClient

with UpdoClient(token="sk_live_…") as client:
    print(client.me().tenant_slug)

    for product in client.entity("product").iterate(where={"status": "active"}):
        print(product["sku"], product["sale_price"])
```

---

## What you need to know before you start

The public API lives under `https://api.updo.pro/api/public/v1/` and accepts
exactly one thing: a **personal access token** (`sk_live_…` in production,
`sk_test_…` for the sandbox twin of that workspace).

- **One token = one workspace.** The binding is inside the token, so there is
  *no* tenant header to send (the SDK never sends one: an `X-Tenant-ID` can only
  cause a 401).
- **The data model is defined per tenant.** Entities and their fields are
  created in the Atelier (labelled **Workshop** in the English UI), so no static
  class can describe them. You discover them at runtime (`client.entities`) or
  generate typed models (`updo codegen`).
- **A token can only call `/api/public/v1/`.** The internal `/api/v1/` routes
  track whatever the product's own UI needs and carry no stability contract; the
  server deliberately rejects them when the caller is a token.

Create a token in Updo (*Settings → API tokens*). **The secret is shown only
once.**

---

## Installation

```bash
pip install updo-sdk          # library
pip install "updo-sdk[cli]"   # + a TOML parser for `updo --profile` on 3.10
```

Python ≥ 3.10. Only dependency: `httpx`.

The `updo` command is installed either way — the `[cli]` extra only adds
`tomli`, so that `updo --profile` can read a TOML config file on Python 3.10
where `tomllib` is not yet in the standard library. Importing the package never
touches the CLI: `import updo` pulls in `httpx` and nothing else.

A runnable end-to-end example ships in the source distribution as
`examples/quickstart.py`.

---

## Configuration

```python
client = UpdoClient(token="sk_live_…")  # api.updo.pro
client = UpdoClient(token="sk_test_…", base_url="http://localhost:8000")
client = UpdoClient()  # $UPDO_API_TOKEN / $UPDO_BASE_URL
```

| Parameter | Default | Role |
|---|---|---|
| `token` | `$UPDO_API_TOKEN` | `sk_…` token |
| `base_url` | `$UPDO_BASE_URL`, otherwise `https://api.updo.pro` | API root |
| `timeout` | `30.0` | seconds (or an `httpx.Timeout`) |
| `max_retries` | `3` | number of retries |
| `on_approval` | `"raise"` | `"return"` to receive an object instead of an exception on a 202 |
| `site_id` | `$UPDO_SITE_ID` | the site every request is scoped to — see [Multi-site](#multi-site) |
| `http_client` | `None` | your own `httpx.Client` (pool, proxy, mTLS…) |

The base URL is normalised: `https://api.updo.pro`, `https://updo.pro/`,
`http://localhost:8000` and even `…/api/v1` (pasted from the frontend config)
all lead to `…/api/public/v1`.

---

## Discovering the model

```python
for entity in client.entities.list():
    print(entity.slug, entity.display("fr"))  # 'product', 'Produit'

schema = client.entities.schema("product")  # cached
for field in schema.fields:
    print(field.slug, field.field_type, "writable" if field.writable else "read-only")
```

`schema.fields` contains **only** the fields this token is allowed to read, and
`writable` is read from the server's serialiser: a form built from it cannot
offer a field the server would refuse.

### Why the entity list can come back empty

Authorization is deny-by-default, and the index deliberately **omits** entities
the token cannot read rather than listing them as forbidden — so a token with no
grants sees a `200` and an empty workspace, not an error. That looks like a
broken API and is really a missing grant. What each case looks like:

| What you see | What it means | Fix |
|---|---|---|
| `200`, `results: []` | no grant on any entity | grant the token's role `read` on the entities, in the access manager |
| `403 ACCESS_DENIED` on `schema/` | same, for that entity | idem |
| 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 |
| `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` |
| fewer fields than expected | per-field read permissions | expected: two tokens legitimately see two different shapes |

`updo codegen` without `--entity` skips a lapsed-module entity and reports it on
stderr rather than aborting the run. Three curl calls settle where you stand:

```bash
curl -H "Authorization: Bearer $UPDO_API_TOKEN" https://api.updo.pro/api/public/v1/me/
```

```bash
curl -H "Authorization: Bearer $UPDO_API_TOKEN" https://api.updo.pro/api/public/v1/data/
```

---

## Multi-site

An Updo organisation can hold several sites, and a request resolves to exactly
one — that is what decides which rows you see and, on a write, where the record
lands. The server picks in this order: the `X-Site-ID` header, then the member's
default site, then the organisation's main site.

```python
client = UpdoClient(token="sk_live_…", site_id="0f4d2b5e-1c3a-4e6f-9a8b-7c6d5e4f3a2b")
```

```python
# One credential, several sites, one connection pool:
for site in (montreal, quebec):
    for row in client.with_site(site).entity("stock").iterate():
        ...
```

`site_id` also reads `$UPDO_SITE_ID`. `with_site(None)` clears the site rather
than falling back to that variable.

> ⚠️ **A service-account token ignores the site completely.** Measured against a
> real Qlaris: for a token whose `user` is `None` — the canonical machine mode —
> the server never populates its site context, so the site filter is not applied
> at all. Such a token reads **every site's rows**, as though `all_sites=True`
> were permanently on, and records it creates are stamped with no site, making
> them visible from every site. The header is honoured only by a token bound to
> a **user**, and then only if the *token's* own roles allow that site — the
> token's roles override the membership's for that check. Until the server
> changes, use a user-bound token for anything site-scoped.

> **A wrong site id does not fail.** An unknown, malformed, foreign, inactive or
> forbidden site makes the server fall back to the main site and answer `200`.
> Nothing in the status, the headers or the record envelope reveals it — the
> envelope never carries `site_id`. The SDK therefore validates the UUID before
> sending, which is the only moment the mistake can still be an error.

There is **no way to list sites through the public API**: the whole `sites`
surface is internal, and an API token is refused there. Take the site UUIDs from
the web interface.

`all_sites=True` on `list()` / `iterate()` **wins over** a configured site — the
server short-circuits its site filter before it looks at the active site.

---

## Reading records

A record is **flat**: `{id, created_at, updated_at, <field>: value…}`. Business
fields are read like a dictionary; technical columns remain attributes (so an
entity that happens to define a field named `id` does not shadow the record's
own identifier).

```python
products = client.entity("product")

page = products.list(page_size=50, ordering="-sale_price")
print(page.count, len(page.results))

product = products.get("0f4d2b5e-1c3a-4e6f-9a8b-7c6d5e4f3a2b")
product.id, product.created_at  # metadata
product["sku"], product.get("name")  # business fields

for p in products.iterate(where={"status": "active"}):  # walks every page, ordered
    ...

products.count(where={"status": "draft"})
products.first(where={"sku": "ABC-123"})
```

### Filters

```python
products.list(
    where={
        "sku": "ABC-123",  # equality
        "name__icontains": "croquette",  # substring, case-insensitive
        "sale_price__gte": 10,  # ≥
        "status__in": ["active", "draft"],  # list
        "barcode__isnull": True,  # empty field
    }
)
```

Suffixes: `gte` `lte` `gt` `lt` `contains` `icontains` `in` `isnull`. Conditions
are combined with **AND**. Expression variant:

```python
from updo import F

products.list(where=F(status="active") & F(sale_price__gte=10))
```

> **Two server pitfalls worth knowing.**
> A filter on an **unknown** field returns **400**. A filter on a field the
> token is not allowed to read is **silently ignored** — this is intentional
> (otherwise `?data__salaire__gte=` would become an oracle for guessing a masked
> value), but it means a result set can be wider than expected with no error to
> signal it. When in doubt, check the field with
> `client.entities.schema(...)`.

> **`contains` is not a substring test.** On the server it is PostgreSQL's JSONB
> containment operator `@>`, so `name__contains="crok"` matches nothing at all
> against `"croquette"` — and returns a cheerful 200 while doing it. Use
> `icontains` for substrings. What `contains` IS good for is membership in a
> list-valued field: `tags__contains="promo"` finds records whose
> `multi_select` includes that choice.

An `__in` list cannot carry a value containing a comma: the server splits on
commas and strips each part, so `["Dupont, Jean", "Tremblay"]` would arrive as
three wrong terms. The SDK refuses such a value rather than sending it wrong.

Dates and times need an explicit offset. A naive `datetime` is **refused** by
the SDK rather than sent, because the server reads a naive value in the
*tenant's* timezone — the same code would then mean different instants in
different workspaces:

```python
from datetime import datetime, timezone

products.list(where={"released_on__gte": datetime(2026, 1, 1, tzinfo=timezone.utc)})
```

### Search, ordering, relations

```python
products.list(search="chien")  # free text over text fields
products.list(ordering=["-sale_price", "name"])
products.list(expand="supplier")  # populates record.expanded
```

### Automatic value typing

By default, values come back raw — the JSON exactly as sent. With the schema
loaded, dates and decimals are promoted to Python objects:

```python
products = client.entity("product", coerce=True)  # loads the schema once
p = products.get(record_id)
p["sale_price"]  # Decimal('12.50') — exact, not a float
p["released_on"]  # datetime.date(2026, 1, 15)
```

---

## Writing

Values this SDK hands you can be sent straight back: `Decimal` and `date` are
rendered the way the server reads them (money as a string, so a cent cannot be
lost to a float). `record.to_json_dict()` gives a payload ready for `update()`,
with the server-owned `id` / `created_at` / `updated_at` left out.


```python
p = products.create({"sku": "ABC-123", "name": "Croquettes", "sale_price": 12.50})
products.update(p.id, {"sale_price": 13.90})  # PATCH — recommended
products.replace(p.id, {...})  # PUT
products.delete(p.id)
```

> Fields of type `password` read back masked (`••••••••`). Sending a
> freshly-read record back through `replace()` would therefore overwrite the
> real secret. `record.is_masked("field")` detects it; prefer `update()`.

### Writes subject to approval

A write can be put on hold for approval (HTTP 202) instead of being applied. By
default the SDK **raises** `ApprovalRequired` — returning an object with no `id`
would let the calling code carry on as if the write had happened.

```python
from updo import ApprovalRequired

try:
    products.create({...})
except ApprovalRequired as pending:
    print(pending.approval_request_id)

# or, to handle the case without an exception:
client = UpdoClient(token="sk_live_…", on_approval="return")
result = client.entity("invoice").create({...})  # Record or ApprovalPending
```

---

## Analytics and export

```python
agg = products.aggregate(group_by="status", metrics=["count", "sum:sale_price"])
for row in agg.results:
    print(row["group"], row["count"])

agg = products.aggregate(group_by="created_at", bucket="month", metrics=["count"])

pv = products.pivot(rows="status", cols="category", metric="count")
pv.cell("active", "chien")
pv.to_rows()  # ready for csv.DictWriter or pandas

od = products.query(
    select=["sku", "sale_price"], orderby=[("sale_price", "desc")], top=100, count=True
)
od.value, od.count  # $top is capped at 500 server-side

products.export("csv", dest="products.csv", where={"status": "active"})
products.export("xlsx", dest="products.xlsx")
data = products.export("csv")  # without dest: the bytes
```

The export honours filters, search and ordering, and ignores pagination.

> **The server truncates an export at 10 000 rows** — no header, no marker, no
> error: a 40 000-record entity yields a perfectly well-formed CSV holding the
> first 10 000. The SDK therefore counts first and **raises** rather than hand
> you a file that is quietly a quarter of your data. Pass
> `check_complete=False` to skip the count, and use `iterate()` — which has no
> cap — for anything larger.

---

## Webhooks

### Subscribing

```python
hook = client.webhooks.create(
    url="https://my-service.example.com/updo",  # https required
    event_pattern="invoice.*",  # or "invoice.paid", "*.created"
    slug="invoice-paid",
    secret="whsec_…",  # signs every delivery
)

client.webhooks.update(hook.id, is_active=False)
client.webhooks.delete(hook.id)
```

Patterns accept a wildcard **per segment**: `invoice.*`, `*.created`. Events are
`<entity>.created` / `.updated` / `.deleted`, plus the platform events
(`document.signed`, `workflow.approval_requested`…).

### Verifying a received delivery

```python
from updo.webhooks import parse_event, verify_signature


@app.post("/updo")
def receive(request):
    raw = request.body  # the BYTES, before any JSON parsing
    if not verify_signature(
        SECRET,
        raw,
        request.headers.get("X-Qlaris-Signature"),
        timestamp=request.headers.get("X-Qlaris-Timestamp"),
    ):
        return 401

    event = parse_event(raw, headers=request.headers)
    if already_processed(event.delivery_id):  # stable across retries
        return 200
    process(event.event, event.data)
    return 200
```

> ⚠️ **Sign the raw bytes.** Updo signs exactly what it puts on the wire
> (`json.dumps(sort_keys=True, separators=(",",":"))`). Re-parsing then
> re-serialising the JSON changes key order and whitespace: the signature will
> no longer match — and you will reject a payload that was perfectly genuine.

Reply 2xx quickly: a response ≥ 400 is retried up to 3 times with an increasing
delay, and the codes 400/401/403/404/405/410/422 are treated as final (the
delivery is abandoned).

### Delivery log

```python
for delivery in client.webhooks.iterate_deliveries(status="failed"):
    print(delivery.event_name, delivery.response_code, delivery.error)
```

---

## Errors

```python
from updo import (
    UpdoError,
    UpdoAPIError,
    AuthenticationError,
    PermissionDenied,
    NotFoundError,
    ValidationError,
    ConflictError,
    PlanLimitExceeded,
    RateLimitError,
    ServerError,
    ApprovalRequired,
)

try:
    products.create({"sku": ""})
except ValidationError as exc:
    print(exc.code)  # 'BUSINESS_RULE_VIOLATION'
    print(exc.field_errors)  # {'sku': ['This field is required.']}
except PermissionDenied as exc:
    print(exc.policy_id)  # e.g. 'token:scope_ceiling'
```

The server speaks **two error dialects** (the platform envelope
`{code, detail, field_errors}` and DRF's raw form); the SDK normalises them, so
`exc.code` / `exc.detail` / `exc.field_errors` are always readable.

| Status | Exception | Typical case |
|---|---|---|
| 401 | `AuthenticationError` | unknown or expired token, IP refused, call outside `/api/public/` |
| 402 | `PlanLimitExceeded` | subscription quota reached |
| 403 | `PermissionDenied` | ABAC refusal, token scope ceiling, module disabled |
| 404 | `NotFoundError` | entity or record does not exist |
| 400 / 422 | `ValidationError` | business rule, invalid value, unknown parameter |
| 409 | `ConflictError` | protected deletion, separation-of-duties conflict |
| 429 | `RateLimitError` | throttling — `exc.retry_after` |
| 5xx | `ServerError` | platform-side outage |

### Retries

The SDK retries automatically, with exponential backoff and *jitter*:

- **429** on every method (`Retry-After` honoured) — the request was rejected
  *before* any side effect, so replaying it is safe;
- **5xx and network drops** only on idempotent methods (GET/PUT/DELETE). A 500
  after a POST can mean the write succeeded and only the response was lost:
  replaying it would create a duplicate.

Four throttle buckets apply to a token at once: 1000/h per token, 10000/h per
workspace, 3000/h per user, and a 120/min burst limit.

---

## Asynchronous client

Same surface, same guarantees:

```python
import asyncio
from updo import AsyncUpdoClient


async def main():
    async with AsyncUpdoClient(token="sk_live_…") as client:
        products = await client.entity("product")
        async for p in products.iterate(where={"status": "active"}):
            print(p["sku"])


asyncio.run(main())
```

Only difference: `client.entity()` is a coroutine (it may have to load the
schema).

---

## Typed models (codegen)

The OpenAPI document cannot describe a record's fields — they are defined per
tenant. So they are generated from the real workspace:

```bash
updo codegen --entity product --entity invoice --out my_app/updo_models.py
```

```python
from my_app.updo_models import Product

p = Product.from_record(client.entity("product").get(id))
p.sale_price  # Decimal | None, with IDE autocompletion
p.status  # Literal["active", "draft", "archived"] | None

new_product = Product(sku="ABC-123", name="Croquettes")
client.entity("product").create(new_product.to_payload())  # writable fields only
```

Regenerate after any change to the model in the Atelier. `--no-timestamp` makes
the output stable byte for byte, useful if the file is version-controlled.

The codegen needs nothing but a token that can **read** the entity — no builder
rights, no Atelier licence. What it cannot do for you is the bootstrap: minting
the token and defining the entities are admin gestures on the internal surface,
so they happen in the UI. See [Why the entity list can come back
empty](#why-the-entity-list-can-come-back-empty).

### What the generated types promise, and what they don't

| Field type | Generated as | Note |
|---|---|---|
| `select` `radio` `segmented` `chips` | `Literal[...]` | narrowed to the declared choices |
| `multi_select` `checkboxes` | `list[Literal[...]]` | |
| `decimal` `currency` | `Decimal` | exact, never a float |
| `date` `datetime` | `date` / `datetime` | |
| `relation` `member` | `str` | the wire carries a string, so this is what you get |
| `computed` `json` | `Any` | the server does not publish a result type |

Two caveats the public schema itself cannot resolve, because it does not publish
the discriminant:

- **Relation cardinality.** A to-many relation carries a *list* of ids, but the
  schema publishes no `cardinality`, so every relation is annotated `str` and
  the field's docstring says so. Check the entity in the Atelier before writing
  to a relation.
- **Translatable fields** read back as a locale map (`{"fr": ..., "en": ...}`)
  rather than a string, and nothing in the schema marks them.

Use `FieldSpec.choice_labels(lang)` when you need the human labels behind a
choice field rather than its raw keys.

---

## Command line

The short version is below; `docs/cli.md` in the source distribution is the
full guide, with
output for every command and a troubleshooting section.

```bash
export UPDO_API_TOKEN=sk_live_…

updo whoami
updo entities
updo schema product
updo get product --where status=active --where sale_price__gte=10 --limit 20
updo get product --table --columns sku,name,sale_price
updo count product --where status=draft
updo create product --data '{"sku":"ABC-123","name":"Croquettes"}'
updo update product <id> --file patch.json
updo delete product <id>
updo export product --format xlsx --out products.xlsx
updo aggregate product --group-by status --metrics count,sum:sale_price
updo pivot product --rows status --cols category
updo query product --select sku,sale_price --orderby 'sale_price desc' --top 50
updo webhooks list
updo webhooks create --url https://my-service.example.com/updo --event 'invoice.*'
updo webhooks deliveries --status failed
updo codegen --out models.py
updo openapi --out openapi.json
```

Output is JSON when stdout is redirected and a readable table when it is a
terminal; override with `--json` / `--table`. Exit codes: `0` success, `2` API
error, `3` write pending approval.

Profiles in `~/.config/updo/config.toml` (or `%APPDATA%\updo\config.toml`):

```toml
[default]
token = "sk_live_…"

[sandbox]
token = "sk_test_…"
base_url = "http://localhost:8000"
```

```bash
updo --profile sandbox entities
```

The token is never displayed again: every output passes it through a mask
(`sk_live_ab…yz`).

---

## Covered surface

All 18 public routes, in full — a contract test checks this against the OpenAPI
document served by the platform.

| Route | SDK method |
|---|---|
| `GET /me/` | `client.me()` |
| `GET /data/` | `client.entities.list()` |
| `GET /data/{slug}/schema/` | `client.entities.schema(slug)` |
| `GET · POST /data/{slug}/` | `.list()` `.iterate()` `.create()` |
| `GET · PUT · PATCH · DELETE /data/{slug}/{id}/` | `.get()` `.replace()` `.update()` `.delete()` |
| `GET /data/{slug}/aggregate/` | `.aggregate()` |
| `GET /data/{slug}/pivot/` | `.pivot()` |
| `GET /data/{slug}/query/` | `.query()` |
| `GET · POST /webhooks/` | `client.webhooks.list()` `.create()` |
| `GET · PUT · PATCH · DELETE /webhooks/{id}/` | `.get()` `.replace()` `.update()` `.delete()` |
| `GET /webhooks/deliveries/[{id}/]` | `.deliveries()` `.delivery()` |
| `GET /schema/` | `client.openapi()` |

For any route that is not modelled, the escape hatch keeps authentication and
retries:

```python
client.request("GET", "some/future/route", params={"x": 1})
```

---

## Development

```bash
python -m venv .venv && .venv/Scripts/pip install -e ".[dev,cli]"
pytest                       # offline, simulated transport
ruff check . && ruff format --check .
mypy
```

Integration tests against a real instance (optional):

```bash
UPDO_BASE_URL=http://localhost:8000 UPDO_API_TOKEN=sk_test_… pytest -m integration
```

Use a `mode: "test"` token: it is bound to a disposable twin of the workspace,
so write tests do not touch production.

Refresh the contract test fixture after an API change:

```bash
updo openapi --out tests/data/openapi.json
```

---

## License

MIT. The full text ships as `LICENSE` in the source distribution.
