updo-sdk 0.3.0__tar.gz → 0.4.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.3.0 → updo_sdk-0.4.0}/CHANGELOG.md +55 -0
- {updo_sdk-0.3.0/src/updo_sdk.egg-info → updo_sdk-0.4.0}/PKG-INFO +90 -3
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/README.md +89 -2
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/__init__.py +4 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/_transport.py +25 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/_version.py +1 -1
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/aio.py +20 -2
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/client.py +18 -3
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/codegen.py +3 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/errors.py +47 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/models.py +34 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/resources/_params.py +66 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/resources/images.py +6 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/resources/records.py +118 -2
- {updo_sdk-0.3.0 → updo_sdk-0.4.0/src/updo_sdk.egg-info}/PKG-INFO +90 -3
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/SOURCES.txt +1 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/data/openapi.json +272 -11
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/test_async.py +62 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/test_codegen.py +8 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/test_contract.py +5 -0
- updo_sdk-0.4.0/tests/test_documents.py +256 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/LICENSE +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/MANIFEST.in +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/docs/cli.md +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/examples/quickstart.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/pyproject.toml +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/setup.cfg +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/cli.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/config.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/py.typed +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/query.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/resources/__init__.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/resources/base.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/resources/entities.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/resources/payments.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/resources/webhooks.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/webhooks.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/dependency_links.txt +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/entry_points.txt +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/requires.txt +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/top_level.txt +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/__init__.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/conftest.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/integration/__init__.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/integration/test_live.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/test_cli.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/test_client.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/test_errors.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/test_images.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/test_query.py +0 -0
- {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/test_webhooks.py +0 -0
|
@@ -4,6 +4,61 @@ Format: [Keep a Changelog](https://keepachangelog.com/1.1.0/).
|
|
|
4
4
|
Versioning: [SemVer](https://semver.org/) — on the SDK's own surface, not on the
|
|
5
5
|
platform's. A backwards-compatible server change never forces a major bump here.
|
|
6
6
|
|
|
7
|
+
## [0.4.0] — 2026-10-02
|
|
8
|
+
|
|
9
|
+
### Field type `jurisdiction`
|
|
10
|
+
|
|
11
|
+
- `updo codegen` maps the new `jurisdiction` field type to `str`. It arrived
|
|
12
|
+
server-side on 2026-10-01 and no contract check could see it: field types
|
|
13
|
+
live in tenant data, not in the OpenAPI document. Until mapped, such a field
|
|
14
|
+
generated `Any` -- silently, since the coverage test kept its own copy of the
|
|
15
|
+
server's type list and stayed green.
|
|
16
|
+
- Documented: exact filters are case-sensitive, and a jurisdiction is stored as
|
|
17
|
+
an UPPERCASE ISO code (`CA`, `CA-QC`). `where={"work_jurisdiction": "ca-qc"}`
|
|
18
|
+
returns zero rows and no error.
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Record PDFs
|
|
22
|
+
|
|
23
|
+
- `client.entity(slug).pdf(record_id, template=..., language=..., dest=...)`
|
|
24
|
+
and `.documents()`, sync and async -- the two routes the platform added so an
|
|
25
|
+
external system can fetch an invoice (or any record) as the PDF the tenant
|
|
26
|
+
designed. Each call is a fresh render; nothing is archived server-side.
|
|
27
|
+
- `dest` may be an existing DIRECTORY: the file takes the name the server
|
|
28
|
+
suggests (the record's reference, e.g. an invoice number). The name is
|
|
29
|
+
re-sanitised to `[A-Za-z0-9._-]`, last path component only -- a
|
|
30
|
+
`Content-Disposition` header never chooses where on the caller's disk the
|
|
31
|
+
SDK writes. With no usable suggestion the call is refused rather than
|
|
32
|
+
inventing a name the next call would overwrite. This applies to `export()`
|
|
33
|
+
too, whose CSV and XLSX responses carry a name as well.
|
|
34
|
+
- `language` is validated against the renderer's own pattern before sending.
|
|
35
|
+
The server IGNORES a malformed value instead of refusing it, so
|
|
36
|
+
`language="fr_CA"` would have produced a PDF in the default language with a
|
|
37
|
+
200 -- the kind of mistake a customer finds before the integrator does.
|
|
38
|
+
- The record id is validated as a UUID: the route is `<uuid:pk>`, and anything
|
|
39
|
+
else is Django's HTML 404, which carries none of the route's error codes.
|
|
40
|
+
- `TemplateRequired` (a `ValidationError`) for `400 TEMPLATE_REQUIRED`, with
|
|
41
|
+
`.templates` listing the slugs to choose from.
|
|
42
|
+
- `PermissionDenied.resources` -- the `data:<slug>` entities a template READS
|
|
43
|
+
and the token may not (an invoice prints its lines and its customer; one
|
|
44
|
+
missing right refuses the whole document) -- and `PermissionDenied.feature`
|
|
45
|
+
for `FEATURE_NOT_LICENSED`.
|
|
46
|
+
|
|
47
|
+
### Export
|
|
48
|
+
|
|
49
|
+
- `export(..., export_fields=["sku", "name"])` picks the columns and their
|
|
50
|
+
order. Refused locally: an EMPTY list, which the server reads as "not given"
|
|
51
|
+
and answers with every column, and a slug containing a comma, which it would
|
|
52
|
+
split in two. A plain string is refused too, since `"sku,name"` would iterate
|
|
53
|
+
character by character.
|
|
54
|
+
|
|
55
|
+
### Contract
|
|
56
|
+
|
|
57
|
+
- The OpenAPI fixture is refreshed: 25 -> 27 operations. `NOT_YET_EXPOSED`
|
|
58
|
+
stays empty -- the SDK reaches every documented operation.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
7
62
|
## [0.3.0] — 2026-08-25
|
|
8
63
|
|
|
9
64
|
### Record images
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: updo-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Python SDK for the Updo360 (Qlaris) public ERP API
|
|
5
5
|
Author: UPDO Technologies Inc.
|
|
6
6
|
License-Expression: MIT
|
|
@@ -270,6 +270,13 @@ products.list(where=F(status="active") & F(sale_price__gte=10))
|
|
|
270
270
|
> list-valued field: `tags__contains="promo"` finds records whose
|
|
271
271
|
> `multi_select` includes that choice.
|
|
272
272
|
|
|
273
|
+
**Exact matches are case-sensitive.** The server compares the stored JSON value
|
|
274
|
+
as-is, without normalising it. That matters most for codes the platform
|
|
275
|
+
normalises *on write*: a `jurisdiction` field stores ISO codes in uppercase
|
|
276
|
+
(`CA` for a country, `CA-QC` for a subdivision — never a bare `QC`, which would
|
|
277
|
+
be ambiguous with California's `US-CA`), so `where={"work_jurisdiction":
|
|
278
|
+
"ca-qc"}` returns zero rows and no error. Pass `"CA-QC"`.
|
|
279
|
+
|
|
273
280
|
An `__in` list cannot carry a value containing a comma: the server splits on
|
|
274
281
|
commas and strips each part, so `["Dupont, Jean", "Tremblay"]` would arrive as
|
|
275
282
|
three wrong terms. The SDK refuses such a value rather than sending it wrong.
|
|
@@ -372,6 +379,18 @@ data = products.export("csv") # without dest: the bytes
|
|
|
372
379
|
|
|
373
380
|
The export honours filters, search and ordering, and ignores pagination.
|
|
374
381
|
|
|
382
|
+
```python
|
|
383
|
+
products.export("csv", dest="short.csv", export_fields=["sku", "name", "sale_price"])
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
`export_fields` picks the columns **and their order**. Omitted, every readable
|
|
387
|
+
column comes out in the entity's own order — relation ids included, which is
|
|
388
|
+
what keeps the file re-importable. An unknown slug and one this token may not
|
|
389
|
+
read get the *same* 400, on purpose. Two values are refused locally because the
|
|
390
|
+
server would turn them into a silent surprise: an **empty list**, which reaches
|
|
391
|
+
it as "not given" and exports every column, and a slug containing a comma,
|
|
392
|
+
which it would split into two.
|
|
393
|
+
|
|
375
394
|
> **The server truncates an export at 10 000 rows** — no header, no marker, no
|
|
376
395
|
> error: a 40 000-record entity yields a perfectly well-formed CSV holding the
|
|
377
396
|
> first 10 000. The SDK therefore counts first and **raises** rather than hand
|
|
@@ -381,6 +400,63 @@ The export honours filters, search and ordering, and ignores pagination.
|
|
|
381
400
|
|
|
382
401
|
---
|
|
383
402
|
|
|
403
|
+
## Record PDFs (invoices, quotes, labels…)
|
|
404
|
+
|
|
405
|
+
Render any record with one of the PDF templates designed for its entity — an
|
|
406
|
+
invoice first of all.
|
|
407
|
+
|
|
408
|
+
```python
|
|
409
|
+
invoices = client.entity("invoice")
|
|
410
|
+
|
|
411
|
+
for template in invoices.documents():
|
|
412
|
+
print(template.slug, template.display("fr"))
|
|
413
|
+
|
|
414
|
+
pdf = invoices.pdf(invoice_id) # the bytes
|
|
415
|
+
invoices.pdf(invoice_id, template="invoice", dest="out/") # -> out/INV-00042.pdf
|
|
416
|
+
invoices.pdf(invoice_id, language="en", dest="invoice-en.pdf")
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
`dest` takes a file path, a binary handle, or an **existing directory** — the
|
|
420
|
+
file is then named after the record's reference as the server suggests it (the
|
|
421
|
+
invoice number). That name is re-sanitised before it touches your disk: a
|
|
422
|
+
header never chooses the directory.
|
|
423
|
+
|
|
424
|
+
**A fresh render on every call.** Nothing is archived server-side — no issued
|
|
425
|
+
document, no stored file — so polling this is a read, and two calls can differ
|
|
426
|
+
if the record changed in between.
|
|
427
|
+
|
|
428
|
+
**`template`** may be omitted when the entity has exactly one usable PDF
|
|
429
|
+
template. With several, the server refuses to guess, and the exception tells
|
|
430
|
+
you which exist:
|
|
431
|
+
|
|
432
|
+
```python
|
|
433
|
+
from updo import TemplateRequired
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
pdf = invoices.pdf(invoice_id)
|
|
437
|
+
except TemplateRequired as exc:
|
|
438
|
+
pdf = invoices.pdf(invoice_id, template=exc.templates[0])
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
**`language`** (`"en"`, `"fr-CA"`) overrides the render language; omitted, the
|
|
442
|
+
template's own language field (the customer's, typically) decides, then the
|
|
443
|
+
workspace default. The SDK checks it before sending, because the server
|
|
444
|
+
**ignores** a malformed value instead of refusing it — `language="fr_CA"`
|
|
445
|
+
would quietly produce a PDF in the default language.
|
|
446
|
+
|
|
447
|
+
> ⚠️ **The template reads more than the record.** An invoice prints its lines
|
|
448
|
+
> and its customer, and each of those entities needs the same right as
|
|
449
|
+
> `GET data/<slug>/`. When one is missing, the *whole* document is refused —
|
|
450
|
+
> `PermissionDenied` with `exc.resources` naming what to grant
|
|
451
|
+
> (`["data:invoice_line"]`). Rows a row-level policy hides are left out of the
|
|
452
|
+
> document without an error.
|
|
453
|
+
|
|
454
|
+
A record that does not exist and one this token may not read both answer
|
|
455
|
+
`404 RECORD_NOT_FOUND`, by design. A `503 PDF_RENDER_FAILED` is retried like any
|
|
456
|
+
503 on a GET — safe here, since the route writes nothing.
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
384
460
|
## Payment links
|
|
385
461
|
|
|
386
462
|
Turn an invoice into a payer-facing URL, server to server. The payer needs no
|
|
@@ -512,12 +588,19 @@ values the server documents for branching:
|
|
|
512
588
|
Every refusal is decided **before anything is stored**, so a `409` has written no
|
|
513
589
|
blob and attached no row. Retrying after fixing the cause needs no cleanup.
|
|
514
590
|
|
|
515
|
-
###
|
|
591
|
+
### Three warnings
|
|
516
592
|
|
|
517
593
|
**Uploading is not idempotent.** Unlike a payment link, nothing de-duplicates
|
|
518
594
|
here: two calls with the same file attach two images. Only the transport's 429
|
|
519
595
|
replay is safe, because a throttle refuses before the server has done anything.
|
|
520
596
|
|
|
597
|
+
**Publishing stores the bytes twice, and you are billed for both.** A published
|
|
598
|
+
image is a *copy* into the public container; the private original stays where it
|
|
599
|
+
was. The workspace's metered storage counts the total, so a bulk import of
|
|
600
|
+
product photos costs twice what the files weigh. That is the platform's real
|
|
601
|
+
cost, not a rounding artefact — worth knowing before looping `upload()` over a
|
|
602
|
+
catalogue.
|
|
603
|
+
|
|
521
604
|
**Un-publishing is not retroactive.** `delete()` stops new public reads, but a
|
|
522
605
|
CDN or a browser that already fetched the blob may keep serving it. Treat a
|
|
523
606
|
published image as published for good — which is exactly why the server refuses
|
|
@@ -601,6 +684,7 @@ from updo import (
|
|
|
601
684
|
PermissionDenied,
|
|
602
685
|
NotFoundError,
|
|
603
686
|
ValidationError,
|
|
687
|
+
TemplateRequired,
|
|
604
688
|
ConflictError,
|
|
605
689
|
PublicationRefused,
|
|
606
690
|
PlanLimitExceeded,
|
|
@@ -626,9 +710,10 @@ The server speaks **two error dialects** (the platform envelope
|
|
|
626
710
|
|---|---|---|
|
|
627
711
|
| 401 | `AuthenticationError` | unknown or expired token, IP refused, call outside `/api/public/` |
|
|
628
712
|
| 402 | `PlanLimitExceeded` | subscription quota reached |
|
|
629
|
-
| 403 | `PermissionDenied` | ABAC refusal, token scope ceiling, module disabled |
|
|
713
|
+
| 403 | `PermissionDenied` | ABAC refusal, token scope ceiling, module disabled; `exc.resources` / `exc.feature` on a PDF |
|
|
630
714
|
| 404 | `NotFoundError` | entity or record does not exist |
|
|
631
715
|
| 400 / 422 | `ValidationError` | business rule, invalid value, unknown parameter |
|
|
716
|
+
| 400 | `TemplateRequired` | several PDF templates fit — `exc.templates` lists them |
|
|
632
717
|
| 409 | `ConflictError` | protected deletion, separation-of-duties conflict |
|
|
633
718
|
| 409 | `PublicationRefused` | a `public_media` declaration forbids publishing an image — a `ConflictError` subclass carrying a stable `.code` |
|
|
634
719
|
| 429 | `RateLimitError` | throttling — `exc.retry_after` |
|
|
@@ -803,6 +888,8 @@ does not reach.
|
|
|
803
888
|
| `POST · DELETE /payment-links/` | `client.payments.create_link()` `.revoke_links()` |
|
|
804
889
|
| `GET · POST /data/{slug}/{id}/images/` | `.images(id).list()` `.upload()` |
|
|
805
890
|
| `DELETE /data/{slug}/{id}/images/{image_id}/` | `.images(id).delete()` |
|
|
891
|
+
| `GET /data/{slug}/{id}/pdf/` | `.pdf(id)` |
|
|
892
|
+
| `GET /data/{slug}/documents/` | `.documents()` |
|
|
806
893
|
| `GET /schema/` | `client.openapi()` |
|
|
807
894
|
|
|
808
895
|
For any route that is not modelled, the escape hatch keeps authentication and
|
|
@@ -234,6 +234,13 @@ products.list(where=F(status="active") & F(sale_price__gte=10))
|
|
|
234
234
|
> list-valued field: `tags__contains="promo"` finds records whose
|
|
235
235
|
> `multi_select` includes that choice.
|
|
236
236
|
|
|
237
|
+
**Exact matches are case-sensitive.** The server compares the stored JSON value
|
|
238
|
+
as-is, without normalising it. That matters most for codes the platform
|
|
239
|
+
normalises *on write*: a `jurisdiction` field stores ISO codes in uppercase
|
|
240
|
+
(`CA` for a country, `CA-QC` for a subdivision — never a bare `QC`, which would
|
|
241
|
+
be ambiguous with California's `US-CA`), so `where={"work_jurisdiction":
|
|
242
|
+
"ca-qc"}` returns zero rows and no error. Pass `"CA-QC"`.
|
|
243
|
+
|
|
237
244
|
An `__in` list cannot carry a value containing a comma: the server splits on
|
|
238
245
|
commas and strips each part, so `["Dupont, Jean", "Tremblay"]` would arrive as
|
|
239
246
|
three wrong terms. The SDK refuses such a value rather than sending it wrong.
|
|
@@ -336,6 +343,18 @@ data = products.export("csv") # without dest: the bytes
|
|
|
336
343
|
|
|
337
344
|
The export honours filters, search and ordering, and ignores pagination.
|
|
338
345
|
|
|
346
|
+
```python
|
|
347
|
+
products.export("csv", dest="short.csv", export_fields=["sku", "name", "sale_price"])
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
`export_fields` picks the columns **and their order**. Omitted, every readable
|
|
351
|
+
column comes out in the entity's own order — relation ids included, which is
|
|
352
|
+
what keeps the file re-importable. An unknown slug and one this token may not
|
|
353
|
+
read get the *same* 400, on purpose. Two values are refused locally because the
|
|
354
|
+
server would turn them into a silent surprise: an **empty list**, which reaches
|
|
355
|
+
it as "not given" and exports every column, and a slug containing a comma,
|
|
356
|
+
which it would split into two.
|
|
357
|
+
|
|
339
358
|
> **The server truncates an export at 10 000 rows** — no header, no marker, no
|
|
340
359
|
> error: a 40 000-record entity yields a perfectly well-formed CSV holding the
|
|
341
360
|
> first 10 000. The SDK therefore counts first and **raises** rather than hand
|
|
@@ -345,6 +364,63 @@ The export honours filters, search and ordering, and ignores pagination.
|
|
|
345
364
|
|
|
346
365
|
---
|
|
347
366
|
|
|
367
|
+
## Record PDFs (invoices, quotes, labels…)
|
|
368
|
+
|
|
369
|
+
Render any record with one of the PDF templates designed for its entity — an
|
|
370
|
+
invoice first of all.
|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
invoices = client.entity("invoice")
|
|
374
|
+
|
|
375
|
+
for template in invoices.documents():
|
|
376
|
+
print(template.slug, template.display("fr"))
|
|
377
|
+
|
|
378
|
+
pdf = invoices.pdf(invoice_id) # the bytes
|
|
379
|
+
invoices.pdf(invoice_id, template="invoice", dest="out/") # -> out/INV-00042.pdf
|
|
380
|
+
invoices.pdf(invoice_id, language="en", dest="invoice-en.pdf")
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
`dest` takes a file path, a binary handle, or an **existing directory** — the
|
|
384
|
+
file is then named after the record's reference as the server suggests it (the
|
|
385
|
+
invoice number). That name is re-sanitised before it touches your disk: a
|
|
386
|
+
header never chooses the directory.
|
|
387
|
+
|
|
388
|
+
**A fresh render on every call.** Nothing is archived server-side — no issued
|
|
389
|
+
document, no stored file — so polling this is a read, and two calls can differ
|
|
390
|
+
if the record changed in between.
|
|
391
|
+
|
|
392
|
+
**`template`** may be omitted when the entity has exactly one usable PDF
|
|
393
|
+
template. With several, the server refuses to guess, and the exception tells
|
|
394
|
+
you which exist:
|
|
395
|
+
|
|
396
|
+
```python
|
|
397
|
+
from updo import TemplateRequired
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
pdf = invoices.pdf(invoice_id)
|
|
401
|
+
except TemplateRequired as exc:
|
|
402
|
+
pdf = invoices.pdf(invoice_id, template=exc.templates[0])
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
**`language`** (`"en"`, `"fr-CA"`) overrides the render language; omitted, the
|
|
406
|
+
template's own language field (the customer's, typically) decides, then the
|
|
407
|
+
workspace default. The SDK checks it before sending, because the server
|
|
408
|
+
**ignores** a malformed value instead of refusing it — `language="fr_CA"`
|
|
409
|
+
would quietly produce a PDF in the default language.
|
|
410
|
+
|
|
411
|
+
> ⚠️ **The template reads more than the record.** An invoice prints its lines
|
|
412
|
+
> and its customer, and each of those entities needs the same right as
|
|
413
|
+
> `GET data/<slug>/`. When one is missing, the *whole* document is refused —
|
|
414
|
+
> `PermissionDenied` with `exc.resources` naming what to grant
|
|
415
|
+
> (`["data:invoice_line"]`). Rows a row-level policy hides are left out of the
|
|
416
|
+
> document without an error.
|
|
417
|
+
|
|
418
|
+
A record that does not exist and one this token may not read both answer
|
|
419
|
+
`404 RECORD_NOT_FOUND`, by design. A `503 PDF_RENDER_FAILED` is retried like any
|
|
420
|
+
503 on a GET — safe here, since the route writes nothing.
|
|
421
|
+
|
|
422
|
+
---
|
|
423
|
+
|
|
348
424
|
## Payment links
|
|
349
425
|
|
|
350
426
|
Turn an invoice into a payer-facing URL, server to server. The payer needs no
|
|
@@ -476,12 +552,19 @@ values the server documents for branching:
|
|
|
476
552
|
Every refusal is decided **before anything is stored**, so a `409` has written no
|
|
477
553
|
blob and attached no row. Retrying after fixing the cause needs no cleanup.
|
|
478
554
|
|
|
479
|
-
###
|
|
555
|
+
### Three warnings
|
|
480
556
|
|
|
481
557
|
**Uploading is not idempotent.** Unlike a payment link, nothing de-duplicates
|
|
482
558
|
here: two calls with the same file attach two images. Only the transport's 429
|
|
483
559
|
replay is safe, because a throttle refuses before the server has done anything.
|
|
484
560
|
|
|
561
|
+
**Publishing stores the bytes twice, and you are billed for both.** A published
|
|
562
|
+
image is a *copy* into the public container; the private original stays where it
|
|
563
|
+
was. The workspace's metered storage counts the total, so a bulk import of
|
|
564
|
+
product photos costs twice what the files weigh. That is the platform's real
|
|
565
|
+
cost, not a rounding artefact — worth knowing before looping `upload()` over a
|
|
566
|
+
catalogue.
|
|
567
|
+
|
|
485
568
|
**Un-publishing is not retroactive.** `delete()` stops new public reads, but a
|
|
486
569
|
CDN or a browser that already fetched the blob may keep serving it. Treat a
|
|
487
570
|
published image as published for good — which is exactly why the server refuses
|
|
@@ -565,6 +648,7 @@ from updo import (
|
|
|
565
648
|
PermissionDenied,
|
|
566
649
|
NotFoundError,
|
|
567
650
|
ValidationError,
|
|
651
|
+
TemplateRequired,
|
|
568
652
|
ConflictError,
|
|
569
653
|
PublicationRefused,
|
|
570
654
|
PlanLimitExceeded,
|
|
@@ -590,9 +674,10 @@ The server speaks **two error dialects** (the platform envelope
|
|
|
590
674
|
|---|---|---|
|
|
591
675
|
| 401 | `AuthenticationError` | unknown or expired token, IP refused, call outside `/api/public/` |
|
|
592
676
|
| 402 | `PlanLimitExceeded` | subscription quota reached |
|
|
593
|
-
| 403 | `PermissionDenied` | ABAC refusal, token scope ceiling, module disabled |
|
|
677
|
+
| 403 | `PermissionDenied` | ABAC refusal, token scope ceiling, module disabled; `exc.resources` / `exc.feature` on a PDF |
|
|
594
678
|
| 404 | `NotFoundError` | entity or record does not exist |
|
|
595
679
|
| 400 / 422 | `ValidationError` | business rule, invalid value, unknown parameter |
|
|
680
|
+
| 400 | `TemplateRequired` | several PDF templates fit — `exc.templates` lists them |
|
|
596
681
|
| 409 | `ConflictError` | protected deletion, separation-of-duties conflict |
|
|
597
682
|
| 409 | `PublicationRefused` | a `public_media` declaration forbids publishing an image — a `ConflictError` subclass carrying a stable `.code` |
|
|
598
683
|
| 429 | `RateLimitError` | throttling — `exc.retry_after` |
|
|
@@ -767,6 +852,8 @@ does not reach.
|
|
|
767
852
|
| `POST · DELETE /payment-links/` | `client.payments.create_link()` `.revoke_links()` |
|
|
768
853
|
| `GET · POST /data/{slug}/{id}/images/` | `.images(id).list()` `.upload()` |
|
|
769
854
|
| `DELETE /data/{slug}/{id}/images/{image_id}/` | `.images(id).delete()` |
|
|
855
|
+
| `GET /data/{slug}/{id}/pdf/` | `.pdf(id)` |
|
|
856
|
+
| `GET /data/{slug}/documents/` | `.documents()` |
|
|
770
857
|
| `GET /schema/` | `client.openapi()` |
|
|
771
858
|
|
|
772
859
|
For any route that is not modelled, the escape hatch keeps authentication and
|
|
@@ -26,6 +26,7 @@ from .errors import (
|
|
|
26
26
|
PublicationRefused,
|
|
27
27
|
RateLimitError,
|
|
28
28
|
ServerError,
|
|
29
|
+
TemplateRequired,
|
|
29
30
|
UpdoAPIError,
|
|
30
31
|
UpdoConfigError,
|
|
31
32
|
UpdoConnectionError,
|
|
@@ -37,6 +38,7 @@ from .errors import (
|
|
|
37
38
|
from .models import (
|
|
38
39
|
AggregateResult,
|
|
39
40
|
ApprovalPending,
|
|
41
|
+
DocumentTemplate,
|
|
40
42
|
EntitySchema,
|
|
41
43
|
EntitySummary,
|
|
42
44
|
FieldSpec,
|
|
@@ -64,6 +66,7 @@ __all__ = [
|
|
|
64
66
|
"AuthenticationError",
|
|
65
67
|
"ClientConfig",
|
|
66
68
|
"ConflictError",
|
|
69
|
+
"DocumentTemplate",
|
|
67
70
|
"EntitySchema",
|
|
68
71
|
"EntitySummary",
|
|
69
72
|
"F",
|
|
@@ -80,6 +83,7 @@ __all__ = [
|
|
|
80
83
|
"Record",
|
|
81
84
|
"RecordImage",
|
|
82
85
|
"ServerError",
|
|
86
|
+
"TemplateRequired",
|
|
83
87
|
"TokenIdentity",
|
|
84
88
|
"UpdoAPIError",
|
|
85
89
|
"UpdoClient",
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
11
|
import random
|
|
12
|
+
import re
|
|
12
13
|
from collections.abc import Iterable, Mapping
|
|
13
14
|
from typing import Any
|
|
14
15
|
|
|
@@ -30,6 +31,7 @@ __all__ = [
|
|
|
30
31
|
"IDEMPOTENT_METHODS",
|
|
31
32
|
"RETRY_STATUSES",
|
|
32
33
|
"UpdoJSONEncoder",
|
|
34
|
+
"attachment_filename",
|
|
33
35
|
"build_headers",
|
|
34
36
|
"encode_json_body",
|
|
35
37
|
"join_path",
|
|
@@ -186,6 +188,29 @@ def retry_delay(attempt: int, response: httpx.Response | None = None) -> float:
|
|
|
186
188
|
return random.uniform(0.0, window) # noqa: S311 - jitter, not cryptography
|
|
187
189
|
|
|
188
190
|
|
|
191
|
+
_FILENAME_PARAM = re.compile(r'filename\s*=\s*"?([^";]+)"?', re.IGNORECASE)
|
|
192
|
+
_UNSAFE_NAME_CHARS = re.compile(r"[^A-Za-z0-9._-]+")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def attachment_filename(response: httpx.Response) -> str | None:
|
|
196
|
+
"""The file name a ``Content-Disposition: attachment`` response suggests.
|
|
197
|
+
|
|
198
|
+
Re-sanitised here even though the server already sanitises it: this name
|
|
199
|
+
becomes a path on the CALLER's disk, and a client that trusts a header to
|
|
200
|
+
choose where it writes is one bad proxy away from ``../../.bashrc``. Only
|
|
201
|
+
the last path component survives, reduced to ``[A-Za-z0-9._-]``; a name
|
|
202
|
+
that sanitises to nothing, or to a dot-name, yields ``None``.
|
|
203
|
+
"""
|
|
204
|
+
match = _FILENAME_PARAM.search(response.headers.get("Content-Disposition", ""))
|
|
205
|
+
if not match:
|
|
206
|
+
return None
|
|
207
|
+
base = match.group(1).strip().replace("\\", "/").rsplit("/", 1)[-1]
|
|
208
|
+
name = _UNSAFE_NAME_CHARS.sub("-", base).strip("-")
|
|
209
|
+
if not name or name.strip(".") == "":
|
|
210
|
+
return None
|
|
211
|
+
return name.lstrip(".") or None
|
|
212
|
+
|
|
213
|
+
|
|
189
214
|
def wrap_transport_error(exc: Exception) -> UpdoTransportError:
|
|
190
215
|
"""Translate an httpx transport failure into this SDK's vocabulary."""
|
|
191
216
|
if isinstance(exc, httpx.TimeoutException):
|
|
@@ -18,6 +18,7 @@ from typing import Any
|
|
|
18
18
|
import httpx
|
|
19
19
|
|
|
20
20
|
from ._transport import (
|
|
21
|
+
attachment_filename,
|
|
21
22
|
build_headers,
|
|
22
23
|
encode_json_body,
|
|
23
24
|
join_path,
|
|
@@ -273,7 +274,9 @@ class AsyncUpdoClient:
|
|
|
273
274
|
if response.status_code >= 400:
|
|
274
275
|
await response.aread()
|
|
275
276
|
raise error_from_response(response)
|
|
276
|
-
return await _consume(
|
|
277
|
+
return await _consume(
|
|
278
|
+
response.aiter_bytes(), dest, attachment_filename(response)
|
|
279
|
+
)
|
|
277
280
|
except httpx.HTTPError as exc:
|
|
278
281
|
if should_retry_exception("GET", exc, attempt, self.config.max_retries):
|
|
279
282
|
await asyncio.sleep(retry_delay(attempt))
|
|
@@ -303,7 +306,15 @@ class AsyncUpdoClient:
|
|
|
303
306
|
return True
|
|
304
307
|
|
|
305
308
|
|
|
306
|
-
async def _consume(chunks: Any, dest: Any) -> Any:
|
|
309
|
+
async def _consume(chunks: Any, dest: Any, suggested: str | None = None) -> Any:
|
|
310
|
+
"""Write a streamed body to ``dest``, or collect it into bytes.
|
|
311
|
+
|
|
312
|
+
``dest`` may also be an EXISTING DIRECTORY: the file then takes the name the
|
|
313
|
+
server suggested (an invoice number, say), already re-sanitised by
|
|
314
|
+
:func:`attachment_filename`. Without a usable suggestion it is refused
|
|
315
|
+
rather than invented -- a made-up name would overwrite the previous one on
|
|
316
|
+
the next call.
|
|
317
|
+
"""
|
|
307
318
|
if dest is None:
|
|
308
319
|
buffer = bytearray()
|
|
309
320
|
async for chunk in chunks:
|
|
@@ -314,6 +325,13 @@ async def _consume(chunks: Any, dest: Any) -> Any:
|
|
|
314
325
|
dest.write(chunk)
|
|
315
326
|
return dest
|
|
316
327
|
target = Path(dest)
|
|
328
|
+
if target.is_dir():
|
|
329
|
+
if not suggested:
|
|
330
|
+
raise ValueError(
|
|
331
|
+
f"{target} is a directory and the server suggested no file name; "
|
|
332
|
+
"pass a full file path instead."
|
|
333
|
+
)
|
|
334
|
+
target = target / suggested
|
|
317
335
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
318
336
|
with target.open("wb") as handle:
|
|
319
337
|
async for chunk in chunks:
|
|
@@ -13,6 +13,7 @@ from typing import Any
|
|
|
13
13
|
import httpx
|
|
14
14
|
|
|
15
15
|
from ._transport import (
|
|
16
|
+
attachment_filename,
|
|
16
17
|
build_headers,
|
|
17
18
|
encode_json_body,
|
|
18
19
|
join_path,
|
|
@@ -278,7 +279,7 @@ class UpdoClient:
|
|
|
278
279
|
if response.status_code >= 400:
|
|
279
280
|
response.read()
|
|
280
281
|
raise error_from_response(response)
|
|
281
|
-
return _consume(response.iter_bytes(), dest)
|
|
282
|
+
return _consume(response.iter_bytes(), dest, attachment_filename(response))
|
|
282
283
|
except httpx.HTTPError as exc:
|
|
283
284
|
if should_retry_exception("GET", exc, attempt, self.config.max_retries):
|
|
284
285
|
time.sleep(retry_delay(attempt))
|
|
@@ -311,8 +312,15 @@ class UpdoClient:
|
|
|
311
312
|
return True
|
|
312
313
|
|
|
313
314
|
|
|
314
|
-
def _consume(chunks: Any, dest: Any) -> Any:
|
|
315
|
-
"""Write a streamed body to ``dest``, or collect it into bytes.
|
|
315
|
+
def _consume(chunks: Any, dest: Any, suggested: str | None = None) -> Any:
|
|
316
|
+
"""Write a streamed body to ``dest``, or collect it into bytes.
|
|
317
|
+
|
|
318
|
+
``dest`` may also be an EXISTING DIRECTORY: the file then takes the name the
|
|
319
|
+
server suggested (an invoice number, say), already re-sanitised by
|
|
320
|
+
:func:`attachment_filename`. Without a usable suggestion it is refused
|
|
321
|
+
rather than invented -- a made-up name would overwrite the previous one on
|
|
322
|
+
the next call.
|
|
323
|
+
"""
|
|
316
324
|
if dest is None:
|
|
317
325
|
return b"".join(chunks)
|
|
318
326
|
if hasattr(dest, "write"):
|
|
@@ -320,6 +328,13 @@ def _consume(chunks: Any, dest: Any) -> Any:
|
|
|
320
328
|
dest.write(chunk)
|
|
321
329
|
return dest
|
|
322
330
|
target = Path(dest)
|
|
331
|
+
if target.is_dir():
|
|
332
|
+
if not suggested:
|
|
333
|
+
raise ValueError(
|
|
334
|
+
f"{target} is a directory and the server suggested no file name; "
|
|
335
|
+
"pass a full file path instead."
|
|
336
|
+
)
|
|
337
|
+
target = target / suggested
|
|
323
338
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
324
339
|
with target.open("wb") as handle:
|
|
325
340
|
for chunk in chunks:
|
|
@@ -48,6 +48,9 @@ TYPE_MAP: dict[str, str] = {
|
|
|
48
48
|
"email": "str",
|
|
49
49
|
"url": "str",
|
|
50
50
|
"country": "str",
|
|
51
|
+
# An ISO code, stored UPPERCASE: "CA" (a country) or "CA-QC" (one of its
|
|
52
|
+
# subdivisions). Filters compare it exactly, so "ca-qc" matches nothing.
|
|
53
|
+
"jurisdiction": "str",
|
|
51
54
|
"file": "str",
|
|
52
55
|
"password": "str",
|
|
53
56
|
"autonumber": "str",
|
|
@@ -32,6 +32,7 @@ __all__ = [
|
|
|
32
32
|
"PublicationRefused",
|
|
33
33
|
"RateLimitError",
|
|
34
34
|
"ServerError",
|
|
35
|
+
"TemplateRequired",
|
|
35
36
|
"UpdoAPIError",
|
|
36
37
|
"UpdoConfigError",
|
|
37
38
|
"UpdoConnectionError",
|
|
@@ -141,6 +142,24 @@ class PermissionDenied(UpdoAPIError):
|
|
|
141
142
|
value = self.extra.get("module_code")
|
|
142
143
|
return None if value is None else str(value)
|
|
143
144
|
|
|
145
|
+
@property
|
|
146
|
+
def resources(self) -> list[str]:
|
|
147
|
+
"""The ``data:<slug>`` resources a document READS and this token may not.
|
|
148
|
+
|
|
149
|
+
Set by the PDF route when the refusal comes from the document's CONTENT
|
|
150
|
+
rather than its own entity: an invoice template also prints the
|
|
151
|
+
invoice lines and the customer, and each of those needs the same right
|
|
152
|
+
as ``GET data/<slug>/``. This list is what to grant.
|
|
153
|
+
"""
|
|
154
|
+
value = self.extra.get("resources")
|
|
155
|
+
return [str(item) for item in value] if isinstance(value, list) else []
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def feature(self) -> str | None:
|
|
159
|
+
"""``FEATURE_NOT_LICENSED`` only: the capability the workspace lacks."""
|
|
160
|
+
value = self.extra.get("feature")
|
|
161
|
+
return None if value is None else str(value)
|
|
162
|
+
|
|
144
163
|
|
|
145
164
|
class NotFoundError(UpdoAPIError):
|
|
146
165
|
"""404 -- unknown entity/record, or a cross-tenant reference shown as not-found."""
|
|
@@ -160,6 +179,23 @@ class ValidationError(UpdoAPIError):
|
|
|
160
179
|
"""400 / 422 -- business rule, invalid value object, tenant script, bad parameter."""
|
|
161
180
|
|
|
162
181
|
|
|
182
|
+
class TemplateRequired(ValidationError):
|
|
183
|
+
"""400 ``TEMPLATE_REQUIRED`` -- the entity has several PDF templates.
|
|
184
|
+
|
|
185
|
+
The server refuses to guess which document you meant, and says which ones
|
|
186
|
+
exist: :attr:`templates` lists the slugs ``pdf(template=...)`` accepts. A
|
|
187
|
+
caller can therefore recover without a second request::
|
|
188
|
+
|
|
189
|
+
except TemplateRequired as exc:
|
|
190
|
+
pdf = invoices.pdf(record_id, template=exc.templates[0])
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def templates(self) -> list[str]:
|
|
195
|
+
value = self.extra.get("templates")
|
|
196
|
+
return [str(item) for item in value] if isinstance(value, list) else []
|
|
197
|
+
|
|
198
|
+
|
|
163
199
|
class ConflictError(UpdoAPIError):
|
|
164
200
|
"""409 -- protected relation on delete, or a separation-of-duties refusal."""
|
|
165
201
|
|
|
@@ -356,6 +392,17 @@ def error_from_response(response: httpx.Response) -> UpdoAPIError:
|
|
|
356
392
|
# send. Promoting it here rather than in the images resource keeps one
|
|
357
393
|
# translation of the wire format, and PublicationRefused subclasses
|
|
358
394
|
# ConflictError, so an existing `except ConflictError` still catches it.
|
|
395
|
+
if code == "TEMPLATE_REQUIRED":
|
|
396
|
+
return TemplateRequired(
|
|
397
|
+
detail,
|
|
398
|
+
status_code=status,
|
|
399
|
+
code=code,
|
|
400
|
+
field_errors=field_errors,
|
|
401
|
+
extra=extra,
|
|
402
|
+
body=body,
|
|
403
|
+
response=response,
|
|
404
|
+
)
|
|
405
|
+
|
|
359
406
|
if status == 409 and detail in PublicationRefused.REASONS:
|
|
360
407
|
return PublicationRefused(
|
|
361
408
|
f"cannot publish: {PublicationRefused.REASONS[detail]}.",
|
|
@@ -20,6 +20,7 @@ from typing import Any, Generic, TypeVar
|
|
|
20
20
|
|
|
21
21
|
__all__ = [
|
|
22
22
|
"AggregateResult",
|
|
23
|
+
"DocumentTemplate",
|
|
23
24
|
"PaymentLink",
|
|
24
25
|
"ApprovalPending",
|
|
25
26
|
"EntitySchema",
|
|
@@ -712,6 +713,39 @@ class RecordImage:
|
|
|
712
713
|
)
|
|
713
714
|
|
|
714
715
|
|
|
716
|
+
@dataclass(frozen=True)
|
|
717
|
+
class DocumentTemplate:
|
|
718
|
+
"""A PDF template an entity's records can be rendered with.
|
|
719
|
+
|
|
720
|
+
``slug`` is what :meth:`~updo.resources.records.RecordsResource.pdf` takes
|
|
721
|
+
as ``template``. The list is already narrowed server-side to what this
|
|
722
|
+
token may render here: templates of this entity, visible on the current
|
|
723
|
+
site, and not behind a capability the workspace has not bought -- those are
|
|
724
|
+
ABSENT, not padlocked.
|
|
725
|
+
"""
|
|
726
|
+
|
|
727
|
+
slug: str
|
|
728
|
+
label: dict[str, str] = field(default_factory=dict)
|
|
729
|
+
kind: str = "pdf"
|
|
730
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
731
|
+
|
|
732
|
+
def display(self, lang: str = "fr") -> str:
|
|
733
|
+
return localize(self.label, lang, self.slug)
|
|
734
|
+
|
|
735
|
+
@classmethod
|
|
736
|
+
def from_dict(cls, payload: Mapping[str, Any]) -> DocumentTemplate:
|
|
737
|
+
raw_label = payload.get("label")
|
|
738
|
+
label = (
|
|
739
|
+
{str(k): str(v) for k, v in raw_label.items()} if isinstance(raw_label, Mapping) else {}
|
|
740
|
+
)
|
|
741
|
+
return cls(
|
|
742
|
+
slug=str(payload.get("slug") or ""),
|
|
743
|
+
label=label,
|
|
744
|
+
kind=str(payload.get("kind") or "pdf"),
|
|
745
|
+
raw=dict(payload),
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
|
|
715
749
|
@dataclass(frozen=True)
|
|
716
750
|
class Webhook:
|
|
717
751
|
"""A webhook subscription (``action_type`` is always ``webhook`` here)."""
|