updo-sdk 0.2.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.
Files changed (52) hide show
  1. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/CHANGELOG.md +141 -0
  2. {updo_sdk-0.2.0/src/updo_sdk.egg-info → updo_sdk-0.4.0}/PKG-INFO +200 -4
  3. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/README.md +199 -3
  4. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/__init__.py +8 -0
  5. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/_transport.py +25 -0
  6. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/_version.py +1 -1
  7. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/aio.py +40 -3
  8. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/client.py +38 -4
  9. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/codegen.py +3 -0
  10. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/errors.py +119 -0
  11. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/models.py +99 -0
  12. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/resources/__init__.py +3 -0
  13. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/resources/_params.py +66 -0
  14. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/resources/base.py +4 -0
  15. updo_sdk-0.4.0/src/updo/resources/images.py +421 -0
  16. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/resources/records.py +132 -2
  17. {updo_sdk-0.2.0 → updo_sdk-0.4.0/src/updo_sdk.egg-info}/PKG-INFO +200 -4
  18. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/SOURCES.txt +3 -0
  19. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/data/openapi.json +272 -11
  20. updo_sdk-0.4.0/tests/test_async.py +344 -0
  21. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/test_codegen.py +8 -0
  22. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/test_contract.py +20 -7
  23. updo_sdk-0.4.0/tests/test_documents.py +256 -0
  24. updo_sdk-0.4.0/tests/test_images.py +293 -0
  25. updo_sdk-0.2.0/tests/test_async.py +0 -170
  26. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/LICENSE +0 -0
  27. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/MANIFEST.in +0 -0
  28. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/docs/cli.md +0 -0
  29. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/examples/quickstart.py +0 -0
  30. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/pyproject.toml +0 -0
  31. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/setup.cfg +0 -0
  32. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/cli.py +0 -0
  33. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/config.py +0 -0
  34. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/py.typed +0 -0
  35. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/query.py +0 -0
  36. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/resources/entities.py +0 -0
  37. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/resources/payments.py +0 -0
  38. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/resources/webhooks.py +0 -0
  39. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/webhooks.py +0 -0
  40. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/dependency_links.txt +0 -0
  41. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/entry_points.txt +0 -0
  42. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/requires.txt +0 -0
  43. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/top_level.txt +0 -0
  44. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/__init__.py +0 -0
  45. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/conftest.py +0 -0
  46. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/integration/__init__.py +0 -0
  47. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/integration/test_live.py +0 -0
  48. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/test_cli.py +0 -0
  49. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/test_client.py +0 -0
  50. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/test_errors.py +0 -0
  51. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/test_query.py +0 -0
  52. {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/test_webhooks.py +0 -0
@@ -4,6 +4,147 @@ 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
+
62
+ ## [0.3.0] — 2026-08-25
63
+
64
+ ### Record images
65
+
66
+ - `client.entity(slug).images(record_id)` with `.list()`, `.upload()` and
67
+ `.delete()`, sync and async. Upload and publish are one call: a `201` means
68
+ the image is attached AND its `public_url` is live, and a refusal stores
69
+ nothing, so there is no half-done state to clean up.
70
+ - `upload()` accepts a path, `bytes`, or a binary handle, and always reads the
71
+ file into memory. The transport replays a 429 even for POST -- a throttle
72
+ refuses before any side effect -- and a live handle would be at EOF on the
73
+ replay, uploading nothing and drawing "no file provided" for a file that was
74
+ plainly there.
75
+ - `category` is a REQUIRED keyword although the server's schema marks it
76
+ optional. Publication checks it against the entity's `public_media` whitelist,
77
+ and the normaliser that builds that whitelist strips blank entries, so `""`
78
+ can never be a member: omitting the category is a guaranteed 409, not a
79
+ default. A parameter whose only optional value always fails is not optional.
80
+ - Checked before sending, because each of these otherwise costs a full upload
81
+ and comes back as a code that names the lock rather than the fix: the file
82
+ signature (only PNG, JPEG, GIF and WebP publish -- SVG is excluded because it
83
+ executes script when served inline), the 10 MiB ceiling, `position` against
84
+ the `smallint` it lands in, and the alt-text bounds (125 characters per
85
+ language, 24 languages).
86
+ - `PublicationRefused`, a `ConflictError` subclass carrying the server's stable
87
+ refusal `.code` plus a sentence saying what to do about it. `.is_configuration`
88
+ separates the four refusals only a Workshop or operator change can lift from
89
+ the one the caller fixes by sending different bytes -- the distinction that
90
+ stops a queue worker replaying a job forever against a lock nothing in its
91
+ reach can open.
92
+ - `RecordImage.public_url` is deliberately NOT masked, unlike `PaymentLink.url`.
93
+ It carries no authority beyond "show these bytes" and exists to be pasted into
94
+ an `<img src>`; masking every URL because one URL is a secret is cargo cult.
95
+
96
+ ### Transport
97
+
98
+ - `request()` accepts `files=` and `data=` for multipart bodies, sync and async.
99
+ No `Content-Type` is set in that case: httpx generates the boundary and puts
100
+ it in that header, and a boundary-less `multipart/form-data` is refused by
101
+ every parser. Passing both `json=` and `files=` raises `TypeError`.
102
+
103
+ ### Contract
104
+
105
+ - `NOT_YET_EXPOSED` in the contract test is now EMPTY: the SDK reaches every
106
+ documented operation of the public surface. The set stays in place as the
107
+ declared home for the next gap, so "not covered yet" remains a written
108
+ decision rather than a silence.
109
+
110
+ ### CI
111
+
112
+ The release jobs could not actually control which credential they used, and the
113
+ guard that was supposed to catch that reported success. Three repairs, in the
114
+ order they matter:
115
+
116
+ - **The token is resolved in the shell, not in the job's `variables:`.** A
117
+ project-level CI/CD variable OUTRANKS a `variables:` entry of the same name,
118
+ so a project variable called `TWINE_PASSWORD` silently replaced whatever
119
+ `TWINE_PASSWORD: "$TESTPYPI_TOKEN"` resolved to. The rehearsal then presented
120
+ a pypi.org token to test.pypi.org and got a bare `403 Forbidden`. The
121
+ `TWINE_PASSWORD:` keys are gone from both jobs -- writing them there did not
122
+ decide what twine received, it only made the file look as though it did.
123
+ - **The guard checks the value, not the name.** It used to assert that
124
+ `$TWINE_PASSWORD` was non-empty, which the overriding project variable
125
+ satisfied; it reported "credentials present" for a credential the job had not
126
+ chosen. It now derives which service ISSUED the token and refuses a mismatch
127
+ with the target index. A Warehouse token is `pypi-` plus base64url of a v2
128
+ macaroon whose first field is the issuing host, so every token from a given
129
+ service opens with the same literal -- `pypi-AgEIcHlwaS5v` for pypi.org,
130
+ `pypi-AgENdGVzdC5weXBpLm9y` for test.pypi.org. Twelve and twenty characters,
131
+ no more: base64 quantises three bytes at a time and only those are fixed by
132
+ the location alone. Nothing is decoded, no subprocess runs, and `unknown`
133
+ never blocks a release -- a check that cannot tell must not be why a publish
134
+ fails.
135
+ - **`TWINE_USERNAME` is printed and asserted.** Its value is the literal
136
+ `__token__` and is not a secret. A CI/CD variable of that name outranks the
137
+ job's, and twine then authenticates as a real account with a token for a
138
+ password -- which PyPI also answers with a bare `403 Forbidden`, making the
139
+ two causes indistinguishable from the log. Printing it separates them at a
140
+ glance.
141
+
142
+ `set +x` is now the first statement of the block rather than sitting below the
143
+ resolution: measured, `sh -x` printed the token twice before reaching it. GitLab
144
+ masks the exact value of a masked variable, never a fragment of one.
145
+
146
+ ---
147
+
7
148
  ## [0.2.0] — 2026-08-25
8
149
 
9
150
  ### Payment links
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: updo-sdk
3
- Version: 0.2.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
@@ -426,6 +502,116 @@ invoice, or one with a zero balance, raises `ValidationError`.
426
502
 
427
503
  ---
428
504
 
505
+ ## Record images
506
+
507
+ The one surface here that publishes to the open internet. Upload and publish are
508
+ a single call: a `201` means the image is attached *and* its URL is live.
509
+
510
+ ```python
511
+ images = client.entity("product").images(product_id)
512
+
513
+ image = images.upload(
514
+ "photos/moulin.png",
515
+ category="photo",
516
+ alt_text={"fr": "Le moulin en hiver", "en": "The mill in winter"},
517
+ )
518
+ print(image.public_url) # paste straight into <img src="...">
519
+ ```
520
+
521
+ ```python
522
+ for image in images.list():
523
+ print(image.position, image.category, image.public_url)
524
+
525
+ images.delete(image.id)
526
+ ```
527
+
528
+ `upload()` takes a path, raw `bytes`, or a handle opened in **binary** mode. The
529
+ file is read into memory — the server caps uploads at 10 MiB — so that a
530
+ throttled request can be replayed without uploading an empty body.
531
+
532
+ ### `category` is required, whatever the schema says
533
+
534
+ The server's OpenAPI marks it optional. It is not: publication checks the
535
+ category against the entity's `public_media` whitelist, and that whitelist is
536
+ built by a normaliser that strips blank entries — so `""` can never be a member,
537
+ and omitting the category is a guaranteed `409`, not a default. The SDK requires
538
+ it so the refusal happens at the call site instead of a round trip later.
539
+
540
+ > **The whitelist is not readable through the public API.** The schema endpoint
541
+ > returns *fields*, and this declaration is a property of the entity. Today the
542
+ > only ways to learn which categories publish are to open the entity in the
543
+ > Workshop, or to try and catch `PublicationRefused`.
544
+
545
+ ### What the SDK refuses before sending
546
+
547
+ Each of these also comes back from the server — but only after the whole file
548
+ has gone up, and only as a bare code that names the lock rather than the fix:
549
+
550
+ | Refused locally | Because |
551
+ |---|---|
552
+ | an SVG, PDF, BMP, TIFF, ICO, HEIC or ZIP | the server publishes only PNG, JPEG, GIF and WebP, and it *decodes* the bytes rather than trusting the file name |
553
+ | over 10 MiB | the server's ceiling |
554
+ | `position` outside 0–32767 | it lands in a `smallint` |
555
+ | alt text over 125 characters, or over 24 languages | the domain's bounds |
556
+ | a non-UUID record id | the server's 404 would not say which id was wrong |
557
+
558
+ SVG is excluded deliberately, not by oversight: it is a document format that
559
+ executes script when served inline, and a published blob is served with no
560
+ origin of its own. Rasterise it first.
561
+
562
+ ### Publication refusals
563
+
564
+ ```python
565
+ from updo import PublicationRefused
566
+
567
+ try:
568
+ images.upload(photo, category="contract")
569
+ except PublicationRefused as exc:
570
+ if exc.is_configuration:
571
+ alert_ops(exc.code) # nothing the caller can resend will fix it
572
+ else:
573
+ convert_and_retry()
574
+ ```
575
+
576
+ `PublicationRefused` subclasses `ConflictError`, so an existing `except
577
+ ConflictError` keeps working. Its `.code` is one of five stable, machine-readable
578
+ values the server documents for branching:
579
+
580
+ | `exc.code` | What to do |
581
+ |---|---|
582
+ | `public_media_not_declared` | the entity publishes nothing at all; add a `public_media` declaration in the Workshop |
583
+ | `public_media_category_not_publishable` | name the category in that whitelist |
584
+ | `public_media_not_an_image` | send different bytes — the only refusal the caller can fix |
585
+ | `public_media_unavailable` | the deployment has no public container; an operator matter |
586
+ | `public_media_sandbox_refused` | a sandbox twin may not publish — its URLs would go dead on a real site |
587
+
588
+ Every refusal is decided **before anything is stored**, so a `409` has written no
589
+ blob and attached no row. Retrying after fixing the cause needs no cleanup.
590
+
591
+ ### Three warnings
592
+
593
+ **Uploading is not idempotent.** Unlike a payment link, nothing de-duplicates
594
+ here: two calls with the same file attach two images. Only the transport's 429
595
+ replay is safe, because a throttle refuses before the server has done anything.
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
+
604
+ **Un-publishing is not retroactive.** `delete()` stops new public reads, but a
605
+ CDN or a browser that already fetched the blob may keep serving it. Treat a
606
+ published image as published for good — which is exactly why the server refuses
607
+ to publish anything but decoded raster images.
608
+
609
+ `image.public_url` is **not** masked in `repr()`, unlike `PaymentLink.url`. It
610
+ carries no authority beyond "show these bytes" and exists to be embedded
611
+ publicly; hiding it would obstruct the only thing anyone does with it.
612
+
613
+ ---
614
+
429
615
  ## Webhooks
430
616
 
431
617
  ### Subscribing
@@ -498,7 +684,9 @@ from updo import (
498
684
  PermissionDenied,
499
685
  NotFoundError,
500
686
  ValidationError,
687
+ TemplateRequired,
501
688
  ConflictError,
689
+ PublicationRefused,
502
690
  PlanLimitExceeded,
503
691
  RateLimitError,
504
692
  ServerError,
@@ -522,10 +710,12 @@ The server speaks **two error dialects** (the platform envelope
522
710
  |---|---|---|
523
711
  | 401 | `AuthenticationError` | unknown or expired token, IP refused, call outside `/api/public/` |
524
712
  | 402 | `PlanLimitExceeded` | subscription quota reached |
525
- | 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 |
526
714
  | 404 | `NotFoundError` | entity or record does not exist |
527
715
  | 400 / 422 | `ValidationError` | business rule, invalid value, unknown parameter |
716
+ | 400 | `TemplateRequired` | several PDF templates fit — `exc.templates` lists them |
528
717
  | 409 | `ConflictError` | protected deletion, separation-of-duties conflict |
718
+ | 409 | `PublicationRefused` | a `public_media` declaration forbids publishing an image — a `ConflictError` subclass carrying a stable `.code` |
529
719
  | 429 | `RateLimitError` | throttling — `exc.retry_after` |
530
720
  | 5xx | `ServerError` | platform-side outage |
531
721
 
@@ -678,8 +868,9 @@ The token is never displayed again: every output passes it through a mask
678
868
 
679
869
  ## Covered surface
680
870
 
681
- All 18 public routes, in full — a contract test checks this against the OpenAPI
682
- document served by the platform.
871
+ Every documented public route — a contract test checks this against the OpenAPI
872
+ document served by the platform, and fails the build on any operation the SDK
873
+ does not reach.
683
874
 
684
875
  | Route | SDK method |
685
876
  |---|---|
@@ -694,6 +885,11 @@ document served by the platform.
694
885
  | `GET · POST /webhooks/` | `client.webhooks.list()` `.create()` |
695
886
  | `GET · PUT · PATCH · DELETE /webhooks/{id}/` | `.get()` `.replace()` `.update()` `.delete()` |
696
887
  | `GET /webhooks/deliveries/[{id}/]` | `.deliveries()` `.delivery()` |
888
+ | `POST · DELETE /payment-links/` | `client.payments.create_link()` `.revoke_links()` |
889
+ | `GET · POST /data/{slug}/{id}/images/` | `.images(id).list()` `.upload()` |
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()` |
697
893
  | `GET /schema/` | `client.openapi()` |
698
894
 
699
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
@@ -390,6 +466,116 @@ invoice, or one with a zero balance, raises `ValidationError`.
390
466
 
391
467
  ---
392
468
 
469
+ ## Record images
470
+
471
+ The one surface here that publishes to the open internet. Upload and publish are
472
+ a single call: a `201` means the image is attached *and* its URL is live.
473
+
474
+ ```python
475
+ images = client.entity("product").images(product_id)
476
+
477
+ image = images.upload(
478
+ "photos/moulin.png",
479
+ category="photo",
480
+ alt_text={"fr": "Le moulin en hiver", "en": "The mill in winter"},
481
+ )
482
+ print(image.public_url) # paste straight into <img src="...">
483
+ ```
484
+
485
+ ```python
486
+ for image in images.list():
487
+ print(image.position, image.category, image.public_url)
488
+
489
+ images.delete(image.id)
490
+ ```
491
+
492
+ `upload()` takes a path, raw `bytes`, or a handle opened in **binary** mode. The
493
+ file is read into memory — the server caps uploads at 10 MiB — so that a
494
+ throttled request can be replayed without uploading an empty body.
495
+
496
+ ### `category` is required, whatever the schema says
497
+
498
+ The server's OpenAPI marks it optional. It is not: publication checks the
499
+ category against the entity's `public_media` whitelist, and that whitelist is
500
+ built by a normaliser that strips blank entries — so `""` can never be a member,
501
+ and omitting the category is a guaranteed `409`, not a default. The SDK requires
502
+ it so the refusal happens at the call site instead of a round trip later.
503
+
504
+ > **The whitelist is not readable through the public API.** The schema endpoint
505
+ > returns *fields*, and this declaration is a property of the entity. Today the
506
+ > only ways to learn which categories publish are to open the entity in the
507
+ > Workshop, or to try and catch `PublicationRefused`.
508
+
509
+ ### What the SDK refuses before sending
510
+
511
+ Each of these also comes back from the server — but only after the whole file
512
+ has gone up, and only as a bare code that names the lock rather than the fix:
513
+
514
+ | Refused locally | Because |
515
+ |---|---|
516
+ | an SVG, PDF, BMP, TIFF, ICO, HEIC or ZIP | the server publishes only PNG, JPEG, GIF and WebP, and it *decodes* the bytes rather than trusting the file name |
517
+ | over 10 MiB | the server's ceiling |
518
+ | `position` outside 0–32767 | it lands in a `smallint` |
519
+ | alt text over 125 characters, or over 24 languages | the domain's bounds |
520
+ | a non-UUID record id | the server's 404 would not say which id was wrong |
521
+
522
+ SVG is excluded deliberately, not by oversight: it is a document format that
523
+ executes script when served inline, and a published blob is served with no
524
+ origin of its own. Rasterise it first.
525
+
526
+ ### Publication refusals
527
+
528
+ ```python
529
+ from updo import PublicationRefused
530
+
531
+ try:
532
+ images.upload(photo, category="contract")
533
+ except PublicationRefused as exc:
534
+ if exc.is_configuration:
535
+ alert_ops(exc.code) # nothing the caller can resend will fix it
536
+ else:
537
+ convert_and_retry()
538
+ ```
539
+
540
+ `PublicationRefused` subclasses `ConflictError`, so an existing `except
541
+ ConflictError` keeps working. Its `.code` is one of five stable, machine-readable
542
+ values the server documents for branching:
543
+
544
+ | `exc.code` | What to do |
545
+ |---|---|
546
+ | `public_media_not_declared` | the entity publishes nothing at all; add a `public_media` declaration in the Workshop |
547
+ | `public_media_category_not_publishable` | name the category in that whitelist |
548
+ | `public_media_not_an_image` | send different bytes — the only refusal the caller can fix |
549
+ | `public_media_unavailable` | the deployment has no public container; an operator matter |
550
+ | `public_media_sandbox_refused` | a sandbox twin may not publish — its URLs would go dead on a real site |
551
+
552
+ Every refusal is decided **before anything is stored**, so a `409` has written no
553
+ blob and attached no row. Retrying after fixing the cause needs no cleanup.
554
+
555
+ ### Three warnings
556
+
557
+ **Uploading is not idempotent.** Unlike a payment link, nothing de-duplicates
558
+ here: two calls with the same file attach two images. Only the transport's 429
559
+ replay is safe, because a throttle refuses before the server has done anything.
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
+
568
+ **Un-publishing is not retroactive.** `delete()` stops new public reads, but a
569
+ CDN or a browser that already fetched the blob may keep serving it. Treat a
570
+ published image as published for good — which is exactly why the server refuses
571
+ to publish anything but decoded raster images.
572
+
573
+ `image.public_url` is **not** masked in `repr()`, unlike `PaymentLink.url`. It
574
+ carries no authority beyond "show these bytes" and exists to be embedded
575
+ publicly; hiding it would obstruct the only thing anyone does with it.
576
+
577
+ ---
578
+
393
579
  ## Webhooks
394
580
 
395
581
  ### Subscribing
@@ -462,7 +648,9 @@ from updo import (
462
648
  PermissionDenied,
463
649
  NotFoundError,
464
650
  ValidationError,
651
+ TemplateRequired,
465
652
  ConflictError,
653
+ PublicationRefused,
466
654
  PlanLimitExceeded,
467
655
  RateLimitError,
468
656
  ServerError,
@@ -486,10 +674,12 @@ The server speaks **two error dialects** (the platform envelope
486
674
  |---|---|---|
487
675
  | 401 | `AuthenticationError` | unknown or expired token, IP refused, call outside `/api/public/` |
488
676
  | 402 | `PlanLimitExceeded` | subscription quota reached |
489
- | 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 |
490
678
  | 404 | `NotFoundError` | entity or record does not exist |
491
679
  | 400 / 422 | `ValidationError` | business rule, invalid value, unknown parameter |
680
+ | 400 | `TemplateRequired` | several PDF templates fit — `exc.templates` lists them |
492
681
  | 409 | `ConflictError` | protected deletion, separation-of-duties conflict |
682
+ | 409 | `PublicationRefused` | a `public_media` declaration forbids publishing an image — a `ConflictError` subclass carrying a stable `.code` |
493
683
  | 429 | `RateLimitError` | throttling — `exc.retry_after` |
494
684
  | 5xx | `ServerError` | platform-side outage |
495
685
 
@@ -642,8 +832,9 @@ The token is never displayed again: every output passes it through a mask
642
832
 
643
833
  ## Covered surface
644
834
 
645
- All 18 public routes, in full — a contract test checks this against the OpenAPI
646
- document served by the platform.
835
+ Every documented public route — a contract test checks this against the OpenAPI
836
+ document served by the platform, and fails the build on any operation the SDK
837
+ does not reach.
647
838
 
648
839
  | Route | SDK method |
649
840
  |---|---|
@@ -658,6 +849,11 @@ document served by the platform.
658
849
  | `GET · POST /webhooks/` | `client.webhooks.list()` `.create()` |
659
850
  | `GET · PUT · PATCH · DELETE /webhooks/{id}/` | `.get()` `.replace()` `.update()` `.delete()` |
660
851
  | `GET /webhooks/deliveries/[{id}/]` | `.deliveries()` `.delivery()` |
852
+ | `POST · DELETE /payment-links/` | `client.payments.create_link()` `.revoke_links()` |
853
+ | `GET · POST /data/{slug}/{id}/images/` | `.images(id).list()` `.upload()` |
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()` |
661
857
  | `GET /schema/` | `client.openapi()` |
662
858
 
663
859
  For any route that is not modelled, the escape hatch keeps authentication and