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.
Files changed (51) hide show
  1. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/CHANGELOG.md +55 -0
  2. {updo_sdk-0.3.0/src/updo_sdk.egg-info → updo_sdk-0.4.0}/PKG-INFO +90 -3
  3. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/README.md +89 -2
  4. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/__init__.py +4 -0
  5. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/_transport.py +25 -0
  6. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/_version.py +1 -1
  7. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/aio.py +20 -2
  8. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/client.py +18 -3
  9. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/codegen.py +3 -0
  10. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/errors.py +47 -0
  11. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/models.py +34 -0
  12. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/resources/_params.py +66 -0
  13. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/resources/images.py +6 -0
  14. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/resources/records.py +118 -2
  15. {updo_sdk-0.3.0 → updo_sdk-0.4.0/src/updo_sdk.egg-info}/PKG-INFO +90 -3
  16. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/SOURCES.txt +1 -0
  17. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/data/openapi.json +272 -11
  18. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/test_async.py +62 -0
  19. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/test_codegen.py +8 -0
  20. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/test_contract.py +5 -0
  21. updo_sdk-0.4.0/tests/test_documents.py +256 -0
  22. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/LICENSE +0 -0
  23. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/MANIFEST.in +0 -0
  24. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/docs/cli.md +0 -0
  25. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/examples/quickstart.py +0 -0
  26. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/pyproject.toml +0 -0
  27. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/setup.cfg +0 -0
  28. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/cli.py +0 -0
  29. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/config.py +0 -0
  30. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/py.typed +0 -0
  31. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/query.py +0 -0
  32. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/resources/__init__.py +0 -0
  33. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/resources/base.py +0 -0
  34. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/resources/entities.py +0 -0
  35. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/resources/payments.py +0 -0
  36. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/resources/webhooks.py +0 -0
  37. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo/webhooks.py +0 -0
  38. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/dependency_links.txt +0 -0
  39. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/entry_points.txt +0 -0
  40. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/requires.txt +0 -0
  41. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/src/updo_sdk.egg-info/top_level.txt +0 -0
  42. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/__init__.py +0 -0
  43. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/conftest.py +0 -0
  44. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/integration/__init__.py +0 -0
  45. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/integration/test_live.py +0 -0
  46. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/test_cli.py +0 -0
  47. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/test_client.py +0 -0
  48. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/test_errors.py +0 -0
  49. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/test_images.py +0 -0
  50. {updo_sdk-0.3.0 → updo_sdk-0.4.0}/tests/test_query.py +0 -0
  51. {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.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
- ### Two warnings
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
- ### Two warnings
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):
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the package version (read by ``pyproject.toml``)."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -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(response.aiter_bytes(), dest)
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)."""