updo-sdk 0.6.1__tar.gz → 0.6.2__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 (60) hide show
  1. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/CHANGELOG.md +18 -0
  2. {updo_sdk-0.6.1/src/updo_sdk.egg-info → updo_sdk-0.6.2}/PKG-INFO +3 -1
  3. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/README.md +2 -0
  4. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/__init__.py +2 -0
  5. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/_version.py +1 -1
  6. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/errors.py +13 -0
  7. {updo_sdk-0.6.1 → updo_sdk-0.6.2/src/updo_sdk.egg-info}/PKG-INFO +3 -1
  8. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo_sdk.egg-info/SOURCES.txt +1 -0
  9. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/data/openapi.json +4 -4
  10. updo_sdk-0.6.2/tests/test_cascade_delete_refused.py +69 -0
  11. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/LICENSE +0 -0
  12. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/MANIFEST.in +0 -0
  13. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/docs/cli.md +0 -0
  14. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/examples/quickstart.py +0 -0
  15. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/pyproject.toml +0 -0
  16. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/setup.cfg +0 -0
  17. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/_transport.py +0 -0
  18. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/aio.py +0 -0
  19. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/cli.py +0 -0
  20. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/client.py +0 -0
  21. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/codegen.py +0 -0
  22. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/config.py +0 -0
  23. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/models.py +0 -0
  24. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/py.typed +0 -0
  25. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/query.py +0 -0
  26. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/resources/__init__.py +0 -0
  27. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/resources/_params.py +0 -0
  28. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/resources/base.py +0 -0
  29. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/resources/entities.py +0 -0
  30. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/resources/images.py +0 -0
  31. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/resources/payments.py +0 -0
  32. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/resources/records.py +0 -0
  33. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/resources/webhooks.py +0 -0
  34. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo/webhooks.py +0 -0
  35. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo_sdk.egg-info/dependency_links.txt +0 -0
  36. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo_sdk.egg-info/entry_points.txt +0 -0
  37. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo_sdk.egg-info/requires.txt +0 -0
  38. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/src/updo_sdk.egg-info/top_level.txt +0 -0
  39. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/__init__.py +0 -0
  40. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/conftest.py +0 -0
  41. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/integration/__init__.py +0 -0
  42. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/integration/test_live.py +0 -0
  43. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_approval_refusals.py +0 -0
  44. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_async.py +0 -0
  45. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_cli.py +0 -0
  46. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_client.py +0 -0
  47. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_codegen.py +0 -0
  48. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_contract.py +0 -0
  49. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_documents.py +0 -0
  50. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_downloads.py +0 -0
  51. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_entity_not_creatable.py +0 -0
  52. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_errors.py +0 -0
  53. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_images.py +0 -0
  54. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_list_fields.py +0 -0
  55. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_long_retry_after.py +0 -0
  56. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_query.py +0 -0
  57. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_schema_hints.py +0 -0
  58. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_site_refusal.py +0 -0
  59. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_wall_clock.py +0 -0
  60. {updo_sdk-0.6.1 → updo_sdk-0.6.2}/tests/test_webhooks.py +0 -0
@@ -4,6 +4,24 @@ 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.6.2] — 2026-10-18
8
+
9
+ Follows Qlaris delivery 14 (which carries the end of delivery 13), checked
10
+ against the OpenAPI document production serves on 2026-10-18: no new route and
11
+ no new shape, one new refusal code. The contract fixture is refreshed to it.
12
+
13
+ ### A delete refused for what it would reach
14
+
15
+ - `CascadeDeleteRefused` -- 403 `CASCADE_DELETE_REFUSED`: deleting the record
16
+ would remove (cascade) or rewrite (set to null) dependent records this token
17
+ may not delete or change, or only through an approval. The WHOLE delete is
18
+ refused, before anything is written. Not a refusal of the record itself:
19
+ deal with those records first, or ask someone who may; `exc.detail` says
20
+ which. A `PermissionDenied`, promoted only on a 403 with the code, so
21
+ `except PermissionDenied` keeps catching it. Sync and async.
22
+
23
+ ---
24
+
7
25
  ## [0.6.1] — 2026-10-18
8
26
 
9
27
  Fix only.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: updo-sdk
3
- Version: 0.6.1
3
+ Version: 0.6.2
4
4
  Summary: Python SDK for the Updo360 (Qlaris) public ERP API
5
5
  Author: UPDO Technologies Inc.
6
6
  License-Expression: MIT
@@ -815,6 +815,7 @@ from updo import (
815
815
  ApprovalNotPossible,
816
816
  SiteNotAllowed,
817
817
  EntityNotCreatable,
818
+ CascadeDeleteRefused,
818
819
  PlanLimitExceeded,
819
820
  DownloadInterrupted,
820
821
  RateLimitError,
@@ -841,6 +842,7 @@ The server speaks **two error dialects** (the platform envelope
841
842
  | 402 | `PlanLimitExceeded` | subscription quota reached |
842
843
  | 403 | `PermissionDenied` | ABAC refusal, token scope ceiling, module disabled; `exc.resources` / `exc.feature` on a PDF; `exc.code == "IP_NOT_ALLOWED"` when the source IP is outside **the organisation's** security allowlist |
843
844
  | 403 | `EntityNotCreatable` | the entity's records are made by the platform (e.g. a public holiday entitlement): nobody creates one, whatever their rights — `exc.entity` names it |
845
+ | 403 | `CascadeDeleteRefused` | a delete would remove or rewrite dependent records this token may not delete or change (or only through an approval): nothing is deleted — deal with those records first; `exc.detail` says which |
844
846
  | 403 | `SiteNotAllowed` | `X-Site-ID` names a site this token may not use — unknown, inactive, foreign or not assigned (see *Multi-site*) |
845
847
  | 403 | `ApprovalNotPossible` | the action needs an approval this request cannot wait for — `ServiceAccountCannotAwaitApproval` or `ApprovalRequiredElsewhere` (see *Writes subject to approval*) |
846
848
  | 404 | `NotFoundError` | entity or record does not exist |
@@ -779,6 +779,7 @@ from updo import (
779
779
  ApprovalNotPossible,
780
780
  SiteNotAllowed,
781
781
  EntityNotCreatable,
782
+ CascadeDeleteRefused,
782
783
  PlanLimitExceeded,
783
784
  DownloadInterrupted,
784
785
  RateLimitError,
@@ -805,6 +806,7 @@ The server speaks **two error dialects** (the platform envelope
805
806
  | 402 | `PlanLimitExceeded` | subscription quota reached |
806
807
  | 403 | `PermissionDenied` | ABAC refusal, token scope ceiling, module disabled; `exc.resources` / `exc.feature` on a PDF; `exc.code == "IP_NOT_ALLOWED"` when the source IP is outside **the organisation's** security allowlist |
807
808
  | 403 | `EntityNotCreatable` | the entity's records are made by the platform (e.g. a public holiday entitlement): nobody creates one, whatever their rights — `exc.entity` names it |
809
+ | 403 | `CascadeDeleteRefused` | a delete would remove or rewrite dependent records this token may not delete or change (or only through an approval): nothing is deleted — deal with those records first; `exc.detail` says which |
808
810
  | 403 | `SiteNotAllowed` | `X-Site-ID` names a site this token may not use — unknown, inactive, foreign or not assigned (see *Multi-site*) |
809
811
  | 403 | `ApprovalNotPossible` | the action needs an approval this request cannot wait for — `ServiceAccountCannotAwaitApproval` or `ApprovalRequiredElsewhere` (see *Writes subject to approval*) |
810
812
  | 404 | `NotFoundError` | entity or record does not exist |
@@ -21,6 +21,7 @@ from .errors import (
21
21
  ApprovalRequired,
22
22
  ApprovalRequiredElsewhere,
23
23
  AuthenticationError,
24
+ CascadeDeleteRefused,
24
25
  ConflictError,
25
26
  DownloadInterrupted,
26
27
  EntityNotCreatable,
@@ -75,6 +76,7 @@ __all__ = [
75
76
  "ApprovalRequired",
76
77
  "AsyncUpdoClient",
77
78
  "AuthenticationError",
79
+ "CascadeDeleteRefused",
78
80
  "ClientConfig",
79
81
  "ConflictError",
80
82
  "DocumentTemplate",
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the package version (read by ``pyproject.toml``)."""
2
2
 
3
- __version__ = "0.6.1"
3
+ __version__ = "0.6.2"
@@ -23,6 +23,7 @@ from typing import Any
23
23
  import httpx
24
24
 
25
25
  __all__ = [
26
+ "CascadeDeleteRefused",
26
27
  "EntityNotCreatable",
27
28
  "ListFieldEquality",
28
29
  "SiteNotAllowed",
@@ -267,6 +268,17 @@ class EntityNotCreatable(PermissionDenied):
267
268
  return None if value is None else str(value)
268
269
 
269
270
 
271
+ class CascadeDeleteRefused(PermissionDenied):
272
+ """403 ``CASCADE_DELETE_REFUSED`` -- the delete would reach records this token may not touch.
273
+
274
+ Deleting the record would remove (cascade) or rewrite (set to null) other
275
+ records that the caller may not delete or change, or only through an
276
+ approval. The WHOLE delete is refused before anything is written. Not a
277
+ refusal of the record itself: handle the dependent records first, each
278
+ through its own door, or ask someone who may. ``exc.detail`` says which.
279
+ """
280
+
281
+
270
282
  class SiteNotAllowed(PermissionDenied):
271
283
  """403 ``SITE_NOT_ALLOWED`` -- the ``X-Site-ID`` sent is not one this token may use.
272
284
 
@@ -579,6 +591,7 @@ _CODED_403: dict[str, type[PermissionDenied]] = {
579
591
  **_APPROVAL_CODES,
580
592
  "SITE_NOT_ALLOWED": SiteNotAllowed,
581
593
  "ENTITY_NOT_CREATABLE": EntityNotCreatable,
594
+ "CASCADE_DELETE_REFUSED": CascadeDeleteRefused,
582
595
  }
583
596
 
584
597
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: updo-sdk
3
- Version: 0.6.1
3
+ Version: 0.6.2
4
4
  Summary: Python SDK for the Updo360 (Qlaris) public ERP API
5
5
  Author: UPDO Technologies Inc.
6
6
  License-Expression: MIT
@@ -815,6 +815,7 @@ from updo import (
815
815
  ApprovalNotPossible,
816
816
  SiteNotAllowed,
817
817
  EntityNotCreatable,
818
+ CascadeDeleteRefused,
818
819
  PlanLimitExceeded,
819
820
  DownloadInterrupted,
820
821
  RateLimitError,
@@ -841,6 +842,7 @@ The server speaks **two error dialects** (the platform envelope
841
842
  | 402 | `PlanLimitExceeded` | subscription quota reached |
842
843
  | 403 | `PermissionDenied` | ABAC refusal, token scope ceiling, module disabled; `exc.resources` / `exc.feature` on a PDF; `exc.code == "IP_NOT_ALLOWED"` when the source IP is outside **the organisation's** security allowlist |
843
844
  | 403 | `EntityNotCreatable` | the entity's records are made by the platform (e.g. a public holiday entitlement): nobody creates one, whatever their rights — `exc.entity` names it |
845
+ | 403 | `CascadeDeleteRefused` | a delete would remove or rewrite dependent records this token may not delete or change (or only through an approval): nothing is deleted — deal with those records first; `exc.detail` says which |
844
846
  | 403 | `SiteNotAllowed` | `X-Site-ID` names a site this token may not use — unknown, inactive, foreign or not assigned (see *Multi-site*) |
845
847
  | 403 | `ApprovalNotPossible` | the action needs an approval this request cannot wait for — `ServiceAccountCannotAwaitApproval` or `ApprovalRequiredElsewhere` (see *Writes subject to approval*) |
846
848
  | 404 | `NotFoundError` | entity or record does not exist |
@@ -36,6 +36,7 @@ tests/__init__.py
36
36
  tests/conftest.py
37
37
  tests/test_approval_refusals.py
38
38
  tests/test_async.py
39
+ tests/test_cascade_delete_refused.py
39
40
  tests/test_cli.py
40
41
  tests/test_client.py
41
42
  tests/test_codegen.py
@@ -321,7 +321,7 @@
321
321
  "description": "A refusal of the public API.",
322
322
  "properties": {
323
323
  "code": {
324
- "description": "Stable machine code. Known codes: `ACCESS_DENIED`, `APPROVAL_REQUIRED_ELSEWHERE`, `SERVICE_ACCOUNT_CANNOT_AWAIT_APPROVAL`, `SITE_NOT_ALLOWED`, `IP_NOT_ALLOWED`, `ENTITY_NOT_CREATABLE`, `TENANT_MEMBERSHIP_REQUIRED`, `FEATURE_NOT_LICENSED`, `FEATURE_NOT_APPLICABLE`",
324
+ "description": "Stable machine code. Known codes: `ACCESS_DENIED`, `APPROVAL_REQUIRED_ELSEWHERE`, `CASCADE_DELETE_REFUSED`, `SERVICE_ACCOUNT_CANNOT_AWAIT_APPROVAL`, `SITE_NOT_ALLOWED`, `IP_NOT_ALLOWED`, `ENTITY_NOT_CREATABLE`, `TENANT_MEMBERSHIP_REQUIRED`, `FEATURE_NOT_LICENSED`, `FEATURE_NOT_APPLICABLE`",
325
325
  "type": "string"
326
326
  },
327
327
  "detail": {
@@ -1416,7 +1416,7 @@
1416
1416
  }
1417
1417
  }
1418
1418
  },
1419
- "description": "Refused. `code` is one of: `ACCESS_DENIED`, `SERVICE_ACCOUNT_CANNOT_AWAIT_APPROVAL`, `SITE_NOT_ALLOWED`, `IP_NOT_ALLOWED`."
1419
+ "description": "Refused. `code` is one of: `ACCESS_DENIED`, `SERVICE_ACCOUNT_CANNOT_AWAIT_APPROVAL`, `CASCADE_DELETE_REFUSED`, `SITE_NOT_ALLOWED`, `IP_NOT_ALLOWED`."
1420
1420
  }
1421
1421
  },
1422
1422
  "security": [
@@ -2017,7 +2017,7 @@
2017
2017
  },
2018
2018
  "/api/public/v1/me/": {
2019
2019
  "get": {
2020
- "description": "`GET /api/public/v1/me/` \u2014 what THIS token is, and what it can do.\n\n\u26a0\ufe0f This endpoint is the COUNTERPART of a rule: since the public API became\nmandatory, an API token no longer authenticates anywhere else. The only\nfrontend route integrations actually targeted was `/api/v1/users/context/`\n\u2014 blocking it without an equivalent would have REMOVED a capability\ninstead of relocating it, and \"mandatory\" only makes sense if the public\nsurface is sufficient.\n\nWe only return what a MACHINE client can act on: the organization the\ntoken is bound to, its scopes, and its expiry. Nothing from the screen\ncontext \u2014 no menus, preferences or builder capabilities: those are\ninterface needs, and they change with the interface.",
2020
+ "description": "`GET /api/public/v1/me/` \u2014 what THIS token is, and what it can do.\n\n\u26a0\ufe0f This endpoint is the COUNTERPART of a rule: since the public API became\nmandatory, an API token no longer authenticates anywhere else. The only\nfrontend route integrations actually targeted was `/api/v1/users/context/`\n\u2014 blocking it without an equivalent would have REMOVED a capability\ninstead of relocating it, and \"mandatory\" only makes sense if the public\nsurface is sufficient.\n\nWe only return what a MACHINE client can act on: the organization the\ntoken is bound to, its scopes, and its expiry. Nothing from the screen\ncontext \u2014 no menus, preferences or Studio capabilities: those are\ninterface needs, and they change with the interface.",
2021
2021
  "operationId": "me_retrieve",
2022
2022
  "parameters": [
2023
2023
  {
@@ -2412,7 +2412,7 @@
2412
2412
  },
2413
2413
  "/api/public/v1/webhooks/deliveries/{id}/": {
2414
2414
  "get": {
2415
- "description": "The delivery journal, read-only on the machine boundary.\n\nSame filters as the Atelier surface (``?binding=`` / ``?status=`` /\n``?event_id=``); rows arrive truncated + secret-masked from write time.",
2415
+ "description": "The delivery journal, read-only on the machine boundary.\n\nSame filters as the Studio surface (``?binding=`` / ``?status=`` /\n``?event_id=``); rows arrive truncated + secret-masked from write time.",
2416
2416
  "operationId": "webhooks_deliveries_retrieve",
2417
2417
  "parameters": [
2418
2418
  {
@@ -0,0 +1,69 @@
1
+ """403 CASCADE_DELETE_REFUSED -- a delete that would reach records the token may not touch.
2
+
3
+ Qlaris delivery 14 (security MR !70): the delete would remove or rewrite
4
+ dependent records the caller may not delete or change, or only through an
5
+ approval. The whole delete is refused before anything is written.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import httpx
11
+ import pytest
12
+
13
+ from updo import CascadeDeleteRefused
14
+ from updo.errors import ApprovalNotPossible, EntityNotCreatable, PermissionDenied, SiteNotAllowed
15
+
16
+ from .conftest import API_ROOT
17
+
18
+ RECORD = "0f8fad5b-d9cb-469f-a165-70867728950e"
19
+ URL = f"{API_ROOT}/data/customer/{RECORD}/"
20
+
21
+
22
+ def _refusal(status: int = 403) -> httpx.Response:
23
+ return httpx.Response(
24
+ status,
25
+ json={
26
+ "code": "CASCADE_DELETE_REFUSED",
27
+ "detail": "Deleting this record would also delete 3 invoices you may not delete.",
28
+ "field_errors": None,
29
+ },
30
+ )
31
+
32
+
33
+ def test_a_delete_is_refused_with_its_own_class(respx_mock, client) -> None:
34
+ route = respx_mock.delete(URL).mock(return_value=_refusal())
35
+ with pytest.raises(CascadeDeleteRefused) as excinfo:
36
+ client.entity("customer").delete(RECORD)
37
+ error = excinfo.value
38
+ assert error.code == "CASCADE_DELETE_REFUSED"
39
+ assert "invoices" in error.detail
40
+ # A 403 like the others, but none of the other remedies applies.
41
+ assert isinstance(error, PermissionDenied)
42
+ assert not isinstance(error, (ApprovalNotPossible, SiteNotAllowed, EntityNotCreatable))
43
+ # A refusal of the server's judgment, not a transient fault: never replayed.
44
+ assert route.call_count == 1
45
+
46
+
47
+ def test_the_code_under_another_status_is_not_promoted(respx_mock, client) -> None:
48
+ respx_mock.delete(URL).mock(return_value=_refusal(400))
49
+ with pytest.raises(Exception) as excinfo:
50
+ client.entity("customer").delete(RECORD)
51
+ assert not isinstance(excinfo.value, CascadeDeleteRefused)
52
+
53
+
54
+ def test_a_plain_access_denied_stays_a_plain_permission_denied(respx_mock, client) -> None:
55
+ respx_mock.delete(URL).mock(
56
+ return_value=httpx.Response(
57
+ 403, json={"code": "ACCESS_DENIED", "detail": "x", "field_errors": None}
58
+ )
59
+ )
60
+ with pytest.raises(PermissionDenied) as excinfo:
61
+ client.entity("customer").delete(RECORD)
62
+ assert type(excinfo.value) is PermissionDenied
63
+
64
+
65
+ async def test_async_promotes_it(respx_mock, async_client) -> None:
66
+ respx_mock.delete(URL).mock(return_value=_refusal())
67
+ resource = await async_client.entity("customer")
68
+ with pytest.raises(CascadeDeleteRefused):
69
+ await resource.delete(RECORD)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes