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.
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/CHANGELOG.md +141 -0
- {updo_sdk-0.2.0/src/updo_sdk.egg-info → updo_sdk-0.4.0}/PKG-INFO +200 -4
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/README.md +199 -3
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/__init__.py +8 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/_transport.py +25 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/_version.py +1 -1
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/aio.py +40 -3
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/client.py +38 -4
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/codegen.py +3 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/errors.py +119 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/models.py +99 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/resources/__init__.py +3 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/resources/_params.py +66 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/resources/base.py +4 -0
- updo_sdk-0.4.0/src/updo/resources/images.py +421 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/resources/records.py +132 -2
- {updo_sdk-0.2.0 → updo_sdk-0.4.0/src/updo_sdk.egg-info}/PKG-INFO +200 -4
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/SOURCES.txt +3 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/data/openapi.json +272 -11
- updo_sdk-0.4.0/tests/test_async.py +344 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/test_codegen.py +8 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/test_contract.py +20 -7
- updo_sdk-0.4.0/tests/test_documents.py +256 -0
- updo_sdk-0.4.0/tests/test_images.py +293 -0
- updo_sdk-0.2.0/tests/test_async.py +0 -170
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/LICENSE +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/MANIFEST.in +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/docs/cli.md +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/examples/quickstart.py +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/pyproject.toml +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/setup.cfg +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/cli.py +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/config.py +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/py.typed +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/query.py +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/resources/entities.py +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/resources/payments.py +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/resources/webhooks.py +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo/webhooks.py +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/dependency_links.txt +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/entry_points.txt +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/requires.txt +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/top_level.txt +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/__init__.py +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/conftest.py +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/integration/__init__.py +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/integration/test_live.py +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/test_cli.py +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/test_client.py +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/test_errors.py +0 -0
- {updo_sdk-0.2.0 → updo_sdk-0.4.0}/tests/test_query.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|