checkbox-site 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Checkbox
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: checkbox-site
3
+ Version: 0.1.0
4
+ Summary: Checkbox: automatic-renewal-law compliance (ROSCA, California ARL, New York and other state laws) for subscription businesses. Official Python SDK.
5
+ Author-email: Checkbox <support@checkbox.site>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://checkbox.site
8
+ Project-URL: Documentation, https://checkbox.site/docs
9
+ Project-URL: Repository, https://github.com/checkbox-site/checkbox
10
+ Project-URL: Source, https://github.com/checkbox-site/checkbox/tree/main/python
11
+ Project-URL: Issues, https://github.com/checkbox-site/checkbox/issues
12
+ Keywords: subscription,auto-renewal,automatic renewal,compliance,ROSCA,click to cancel,stripe,checkout
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Typing :: Typed
17
+ Classifier: Operating System :: OS Independent
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # Checkbox Python SDK
24
+
25
+ Automatic-renewal-law compliance (ROSCA, California Bus. & Prof. Code § 17600, New York GBL § 527-a and ~26 other state
26
+ laws) for subscription businesses, as an API. Standard library only (`urllib`), Python 3.9+. Free to use.
27
+
28
+ > **Package name.** `pip install checkbox-site` (the name in `pyproject.toml`; never install the unrelated `checkbox` or
29
+ > `checkbox-sdk` packages). The import name is `checkbox`.
30
+
31
+ ## Quickstart
32
+
33
+ ```python
34
+ from checkbox import Checkbox
35
+ cb = Checkbox() # reads CHECKBOX_API_KEY, then CHECKBOX_SECRET_KEY (ck_test_… written by `checkbox init`)
36
+ check = cb.checks.stripe_checkout_session(params=session_params, merchant={"merchant_name": "Acme", "price": 19.99, "interval": "month"})
37
+ print(check["verdict"], check["next_action"]["message"])
38
+ params = check["correction"]["corrected_params"] if check["verdict"] == "fail" else session_params # engine-verified fix
39
+ ```
40
+
41
+ No key yet? Connect the MCP server (`setup_checkbox`) or call `POST /v1/device/authorize`: one browser approval creates the account and test keys. Test keys
42
+ (`ck_test_…`) act on an isolated sandbox and never need activation.
43
+
44
+ ## What you get
45
+
46
+ - **Every `/v1` operation** as `cb.<namespace>.<method>()`: `sites`, `checks`, `plans`, `tasks`, `verifications`,
47
+ `findings`, `fixes`, `scans`, `evaluations`, `evidence`, `certificates`, `notice_settings`, `law_updates`,
48
+ `test_runs`, `test_credentials`, `stripe_checkout_configs`, `disclosures`, `consents`, `elements`,
49
+ `domain_verifications`, `setup_sessions`, `device`, `webhook_endpoints`, `events`, `account`. Ids are positional;
50
+ parameters are keyword arguments or a dict: `cb.sites.update("site_…", name="Acme")` or
51
+ `cb.sites.update("site_…", {"name": "Acme"})`.
52
+ - **Plain dict responses** with `TypedDict` hints for every object, request and webhook event (`checkbox.types`,
53
+ generated from the OpenAPI spec). `obj.last_response` holds `status_code`, `request_id`, `idempotent_replayed`,
54
+ `headers`.
55
+ - **Pagination:** `for f in cb.findings.list(site=site_id).auto_paging_iter(): ...`
56
+ - **Retries** on network errors, 409 `idempotency_key_in_use`, 429 (honours `Retry-After`) and 5xx with exponential
57
+ backoff (`max_network_retries`, default 2). **Idempotency keys** are generated for every POST that changes something
58
+ and reused across retries; pass your own with `request_options={"idempotency_key": "…"}`.
59
+ - **Versioning:** sends `Checkbox-Version: 2026-10-30`; override with `Checkbox(api_version=…)` or per request.
60
+ - **Mode:** `cb.mode` is `"test"` or `"live"` from the key prefix.
61
+ - **Typed errors:** `InvalidRequestError`, `AuthenticationError`, `PermissionError`, `RateLimitError`,
62
+ `IdempotencyError`, `APIError`, `APIConnectionError` (all `CheckboxError`) with `code`, `param`, `status_code`,
63
+ `request_id`, `doc_url` and `next_action` (what an agent should do next).
64
+ - **Webhooks:** `from checkbox import webhook; webhook.construct_event(raw_body, signature_header, secret)` (as in the docs), or the Stripe-style alias `checkbox.Webhook.construct_event(...)`.
65
+ - **Agents / MCP:** `cb.call("verifyTask", {"task": "task_…", "wait": 30})` calls any operation by its operationId with
66
+ flat arguments.
67
+
68
+ ## Usage
69
+
70
+ ```python
71
+ from checkbox import Checkbox, CheckboxError
72
+
73
+ cb = Checkbox(max_network_retries=2, timeout=80, app_info={"name": "my-app", "version": "1.0.0"})
74
+
75
+ site = cb.sites.create(url="https://acme.example", request_options={"idempotency_key": "site:acme.example"})
76
+ plan = cb.plans.retrieve(site["id"])
77
+ for task in plan["tasks"]:
78
+ print(task["id"], task["title"], task["next_action"]["description"])
79
+
80
+ for finding in cb.findings.list(site=site["id"], status="fail").auto_paging_iter():
81
+ print(finding["check_id"], finding["requirements"][0]["citation"] if finding["requirements"] else "")
82
+
83
+ # Errors carry a stable code and, for agents, next_action.
84
+ try:
85
+ cb.certificates.create(site["id"])
86
+ except CheckboxError as err:
87
+ # resource_conflict: not eligible yet (verify the domain, keep the site compliant for 7 days);
88
+ # account_not_activated: a live key before any domain is verified.
89
+ print(err.code, err.message, err.next_action)
90
+ ```
91
+
92
+ ## Webhooks
93
+
94
+ ```python
95
+ # Flask
96
+ @app.post("/webhooks/checkbox")
97
+ def checkbox_webhook():
98
+ try:
99
+ event = checkbox.Webhook.construct_event(request.get_data(), request.headers.get("Checkbox-Signature"), os.environ["CHECKBOX_WEBHOOK_SECRET"])
100
+ except checkbox.SignatureVerificationError as err:
101
+ return {"error": err.message}, 400
102
+ print(event["type"], event["data"]["object"]["id"])
103
+ return {"received": True}
104
+ ```
105
+
106
+ Pass the **raw** body (Django `request.body`, FastAPI `await request.body()`). Signatures older than 5 minutes are
107
+ rejected (`tolerance=`).
108
+
109
+ ## Examples
110
+
111
+ [`examples/`](examples/): [preflight a Stripe Checkout Session](examples/compliant_checkout.py) in a
112
+ [Flask app](examples/flask_app.py) (with the webhook receiver), and [register a site](examples/register_site.py). The
113
+ tests run them against the real API.
114
+
115
+ ## Development
116
+
117
+ - `checkbox/types.py` and `checkbox/_resources.py` are generated from
118
+ [`openapi/checkbox.v1.json`](../openapi/checkbox.v1.json) in Checkbox's main codebase and mirrored here with each
119
+ release; see [CONTRIBUTING.md](../CONTRIBUTING.md).
120
+ - Tests: `cd python && python -m pytest` (a local fake HTTP server; no network). Build: `python -m build`.
121
+ - Releases: [RELEASING.md](../RELEASING.md).
@@ -0,0 +1,99 @@
1
+ # Checkbox Python SDK
2
+
3
+ Automatic-renewal-law compliance (ROSCA, California Bus. & Prof. Code § 17600, New York GBL § 527-a and ~26 other state
4
+ laws) for subscription businesses, as an API. Standard library only (`urllib`), Python 3.9+. Free to use.
5
+
6
+ > **Package name.** `pip install checkbox-site` (the name in `pyproject.toml`; never install the unrelated `checkbox` or
7
+ > `checkbox-sdk` packages). The import name is `checkbox`.
8
+
9
+ ## Quickstart
10
+
11
+ ```python
12
+ from checkbox import Checkbox
13
+ cb = Checkbox() # reads CHECKBOX_API_KEY, then CHECKBOX_SECRET_KEY (ck_test_… written by `checkbox init`)
14
+ check = cb.checks.stripe_checkout_session(params=session_params, merchant={"merchant_name": "Acme", "price": 19.99, "interval": "month"})
15
+ print(check["verdict"], check["next_action"]["message"])
16
+ params = check["correction"]["corrected_params"] if check["verdict"] == "fail" else session_params # engine-verified fix
17
+ ```
18
+
19
+ No key yet? Connect the MCP server (`setup_checkbox`) or call `POST /v1/device/authorize`: one browser approval creates the account and test keys. Test keys
20
+ (`ck_test_…`) act on an isolated sandbox and never need activation.
21
+
22
+ ## What you get
23
+
24
+ - **Every `/v1` operation** as `cb.<namespace>.<method>()`: `sites`, `checks`, `plans`, `tasks`, `verifications`,
25
+ `findings`, `fixes`, `scans`, `evaluations`, `evidence`, `certificates`, `notice_settings`, `law_updates`,
26
+ `test_runs`, `test_credentials`, `stripe_checkout_configs`, `disclosures`, `consents`, `elements`,
27
+ `domain_verifications`, `setup_sessions`, `device`, `webhook_endpoints`, `events`, `account`. Ids are positional;
28
+ parameters are keyword arguments or a dict: `cb.sites.update("site_…", name="Acme")` or
29
+ `cb.sites.update("site_…", {"name": "Acme"})`.
30
+ - **Plain dict responses** with `TypedDict` hints for every object, request and webhook event (`checkbox.types`,
31
+ generated from the OpenAPI spec). `obj.last_response` holds `status_code`, `request_id`, `idempotent_replayed`,
32
+ `headers`.
33
+ - **Pagination:** `for f in cb.findings.list(site=site_id).auto_paging_iter(): ...`
34
+ - **Retries** on network errors, 409 `idempotency_key_in_use`, 429 (honours `Retry-After`) and 5xx with exponential
35
+ backoff (`max_network_retries`, default 2). **Idempotency keys** are generated for every POST that changes something
36
+ and reused across retries; pass your own with `request_options={"idempotency_key": "…"}`.
37
+ - **Versioning:** sends `Checkbox-Version: 2026-10-30`; override with `Checkbox(api_version=…)` or per request.
38
+ - **Mode:** `cb.mode` is `"test"` or `"live"` from the key prefix.
39
+ - **Typed errors:** `InvalidRequestError`, `AuthenticationError`, `PermissionError`, `RateLimitError`,
40
+ `IdempotencyError`, `APIError`, `APIConnectionError` (all `CheckboxError`) with `code`, `param`, `status_code`,
41
+ `request_id`, `doc_url` and `next_action` (what an agent should do next).
42
+ - **Webhooks:** `from checkbox import webhook; webhook.construct_event(raw_body, signature_header, secret)` (as in the docs), or the Stripe-style alias `checkbox.Webhook.construct_event(...)`.
43
+ - **Agents / MCP:** `cb.call("verifyTask", {"task": "task_…", "wait": 30})` calls any operation by its operationId with
44
+ flat arguments.
45
+
46
+ ## Usage
47
+
48
+ ```python
49
+ from checkbox import Checkbox, CheckboxError
50
+
51
+ cb = Checkbox(max_network_retries=2, timeout=80, app_info={"name": "my-app", "version": "1.0.0"})
52
+
53
+ site = cb.sites.create(url="https://acme.example", request_options={"idempotency_key": "site:acme.example"})
54
+ plan = cb.plans.retrieve(site["id"])
55
+ for task in plan["tasks"]:
56
+ print(task["id"], task["title"], task["next_action"]["description"])
57
+
58
+ for finding in cb.findings.list(site=site["id"], status="fail").auto_paging_iter():
59
+ print(finding["check_id"], finding["requirements"][0]["citation"] if finding["requirements"] else "")
60
+
61
+ # Errors carry a stable code and, for agents, next_action.
62
+ try:
63
+ cb.certificates.create(site["id"])
64
+ except CheckboxError as err:
65
+ # resource_conflict: not eligible yet (verify the domain, keep the site compliant for 7 days);
66
+ # account_not_activated: a live key before any domain is verified.
67
+ print(err.code, err.message, err.next_action)
68
+ ```
69
+
70
+ ## Webhooks
71
+
72
+ ```python
73
+ # Flask
74
+ @app.post("/webhooks/checkbox")
75
+ def checkbox_webhook():
76
+ try:
77
+ event = checkbox.Webhook.construct_event(request.get_data(), request.headers.get("Checkbox-Signature"), os.environ["CHECKBOX_WEBHOOK_SECRET"])
78
+ except checkbox.SignatureVerificationError as err:
79
+ return {"error": err.message}, 400
80
+ print(event["type"], event["data"]["object"]["id"])
81
+ return {"received": True}
82
+ ```
83
+
84
+ Pass the **raw** body (Django `request.body`, FastAPI `await request.body()`). Signatures older than 5 minutes are
85
+ rejected (`tolerance=`).
86
+
87
+ ## Examples
88
+
89
+ [`examples/`](examples/): [preflight a Stripe Checkout Session](examples/compliant_checkout.py) in a
90
+ [Flask app](examples/flask_app.py) (with the webhook receiver), and [register a site](examples/register_site.py). The
91
+ tests run them against the real API.
92
+
93
+ ## Development
94
+
95
+ - `checkbox/types.py` and `checkbox/_resources.py` are generated from
96
+ [`openapi/checkbox.v1.json`](../openapi/checkbox.v1.json) in Checkbox's main codebase and mirrored here with each
97
+ release; see [CONTRIBUTING.md](../CONTRIBUTING.md).
98
+ - Tests: `cd python && python -m pytest` (a local fake HTTP server; no network). Build: `python -m build`.
99
+ - Releases: [RELEASING.md](../RELEASING.md).
@@ -0,0 +1,61 @@
1
+ """Checkbox Python SDK: automatic-renewal-law compliance for subscription businesses.
2
+
3
+ from checkbox import Checkbox
4
+
5
+ checkbox = Checkbox() # reads CHECKBOX_API_KEY, then CHECKBOX_SECRET_KEY (written by `checkbox init`)
6
+ result = checkbox.checks.stripe_checkout_session(params=session_params)
7
+ print(result["verdict"], result["next_action"])
8
+
9
+ Stdlib only. Responses are plain dicts (see ``checkbox.types`` for their shape).
10
+ """
11
+ from . import types, webhook
12
+ from ._client import DEFAULT_BASE_URL, DEFAULT_MAX_NETWORK_RETRIES, DEFAULT_TIMEOUT, Checkbox, mode_of_key
13
+ from ._resource import CheckboxFile, CheckboxObject, ListObject, RequestOptions, ResponseMeta
14
+ from ._resources import OPERATIONS
15
+ from ._version import VERSION
16
+ from .errors import (
17
+ APIConnectionError,
18
+ APIError,
19
+ AuthenticationError,
20
+ CheckboxError,
21
+ IdempotencyError,
22
+ InvalidRequestError,
23
+ PermissionError,
24
+ RateLimitError,
25
+ SignatureVerificationError,
26
+ )
27
+ from .types import API_VERSION, ERROR_CODES, WEBHOOK_EVENT_TYPES
28
+ from .webhook import Webhook, construct_event
29
+
30
+ __version__ = VERSION
31
+
32
+ __all__ = [
33
+ "Checkbox",
34
+ "mode_of_key",
35
+ "types",
36
+ "webhook",
37
+ "Webhook",
38
+ "construct_event",
39
+ "OPERATIONS",
40
+ "API_VERSION",
41
+ "ERROR_CODES",
42
+ "WEBHOOK_EVENT_TYPES",
43
+ "VERSION",
44
+ "DEFAULT_BASE_URL",
45
+ "DEFAULT_MAX_NETWORK_RETRIES",
46
+ "DEFAULT_TIMEOUT",
47
+ "CheckboxFile",
48
+ "CheckboxObject",
49
+ "ListObject",
50
+ "RequestOptions",
51
+ "ResponseMeta",
52
+ "CheckboxError",
53
+ "InvalidRequestError",
54
+ "AuthenticationError",
55
+ "PermissionError",
56
+ "RateLimitError",
57
+ "IdempotencyError",
58
+ "APIError",
59
+ "APIConnectionError",
60
+ "SignatureVerificationError",
61
+ ]
@@ -0,0 +1,301 @@
1
+ """The Checkbox client (stdlib only: urllib)."""
2
+ import json
3
+ import os
4
+ import platform
5
+ import random
6
+ import re
7
+ import socket
8
+ import time
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ import uuid
13
+ from typing import Any, Callable, Dict, Mapping, Optional, Tuple
14
+
15
+ from . import webhook as _webhook
16
+ from ._resource import CheckboxFile, CheckboxObject, ListObject, RequestOptions, ResponseMeta
17
+ from ._resources import OPERATIONS, RESOURCES, ResourceNamespaces
18
+ from ._version import VERSION
19
+ from .errors import APIConnectionError, AuthenticationError, CheckboxError, InvalidRequestError, error_from_response
20
+ from .types import API_VERSION
21
+
22
+ DEFAULT_BASE_URL = "https://app.checkbox.site"
23
+ DEFAULT_TIMEOUT = 80.0
24
+ DEFAULT_MAX_NETWORK_RETRIES = 2
25
+ MAX_RETRY_DELAY = 20.0
26
+
27
+ _KEY_MODE = re.compile(r"^(?:ck|rk|sk|pk)_(test|live)_")
28
+
29
+ #: (method, url, headers, body, timeout) -> (status, headers, body bytes). Raise OSError/URLError on network failure.
30
+ Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], Tuple[int, Dict[str, str], bytes]]
31
+
32
+
33
+ def mode_of_key(key: Optional[str]) -> Optional[str]:
34
+ """``"test"`` for ck_test_/rk_test_/pk_test_ keys, ``"live"`` for live keys, None otherwise."""
35
+ m = _KEY_MODE.match(key or "")
36
+ return m.group(1) if m else None
37
+
38
+
39
+ def _urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float) -> Tuple[int, Dict[str, str], bytes]:
40
+ req = urllib.request.Request(url, data=body, method=method, headers=headers)
41
+ try:
42
+ with urllib.request.urlopen(req, timeout=timeout) as res: # noqa: S310 - URL is built from the configured base
43
+ return res.status, {k.lower(): v for k, v in res.headers.items()}, res.read()
44
+ except urllib.error.HTTPError as e:
45
+ return e.code, {k.lower(): v for k, v in (e.headers or {}).items()}, e.read() or b""
46
+
47
+
48
+ class _Webhooks:
49
+ construct_event = staticmethod(_webhook.construct_event)
50
+ verify_header = staticmethod(_webhook.verify_header)
51
+ generate_test_header = staticmethod(_webhook.generate_test_header)
52
+
53
+
54
+ class Checkbox(ResourceNamespaces):
55
+ """Checkbox API client.
56
+
57
+ >>> from checkbox import Checkbox
58
+ >>> checkbox = Checkbox() # reads CHECKBOX_API_KEY
59
+ >>> site = checkbox.sites.create(url="https://acme.example")
60
+ >>> for finding in checkbox.findings.list(site=site["id"]).auto_paging_iter():
61
+ ... print(finding["check_id"], finding["status"])
62
+ """
63
+
64
+ VERSION = VERSION
65
+ API_VERSION = API_VERSION
66
+ webhooks = _Webhooks()
67
+
68
+ def __init__(
69
+ self,
70
+ api_key: Optional[str] = None,
71
+ *,
72
+ base_url: Optional[str] = None,
73
+ api_version: Optional[str] = None,
74
+ max_network_retries: int = DEFAULT_MAX_NETWORK_RETRIES,
75
+ timeout: float = DEFAULT_TIMEOUT,
76
+ app_info: Optional[Mapping[str, str]] = None,
77
+ headers: Optional[Mapping[str, str]] = None,
78
+ transport: Optional[Transport] = None,
79
+ ) -> None:
80
+ """
81
+ :param api_key: secret (``ck_test_…`` / ``ck_live_…``), restricted (``rk_…``) or legacy (``sk_live_…``) key.
82
+ Defaults to the ``CHECKBOX_API_KEY`` environment variable, then ``CHECKBOX_SECRET_KEY`` (written by ``checkbox init``).
83
+ :param base_url: API origin (default ``CHECKBOX_API_BASE`` or https://app.checkbox.site).
84
+ :param api_version: ``Checkbox-Version`` to pin (default: the version this SDK was generated for).
85
+ :param max_network_retries: retries on network errors, 409 idempotency_key_in_use, 429 and 5xx (default 2).
86
+ :param timeout: per-attempt timeout in seconds (default 80).
87
+ :param app_info: ``{"name": ..., "version": ..., "url": ...}`` added to the User-Agent.
88
+ :param transport: replaces urllib (tests, custom proxies).
89
+ """
90
+ key = api_key if api_key is not None else (os.environ.get("CHECKBOX_API_KEY") or os.environ.get("CHECKBOX_SECRET_KEY"))
91
+ self._api_key: Optional[str] = key.strip() if key and key.strip() else None
92
+ self.base_url = (base_url or os.environ.get("CHECKBOX_API_BASE") or DEFAULT_BASE_URL).rstrip("/")
93
+ self.api_version = api_version or API_VERSION
94
+ self.max_network_retries = max(0, int(max_network_retries))
95
+ self.timeout = float(timeout)
96
+ self._headers = dict(headers or {})
97
+ self._transport: Transport = transport or _urllib_transport
98
+ #: ``"test"`` or ``"live"`` from the key prefix (None without a recognised key).
99
+ self.mode = mode_of_key(self._api_key)
100
+ ua = f"checkbox-python/{VERSION} python/{platform.python_version()}"
101
+ if app_info and app_info.get("name"):
102
+ ua += f" {app_info['name']}" + (f"/{app_info['version']}" if app_info.get("version") else "")
103
+ if app_info.get("url"):
104
+ ua += f" ({app_info['url']})"
105
+ self._user_agent = ua
106
+ for namespace, cls in RESOURCES.items():
107
+ setattr(self, namespace, cls(self))
108
+
109
+ def __repr__(self) -> str:
110
+ return f"Checkbox(mode={self.mode!r}, base_url={self.base_url!r}, api_version={self.api_version!r})"
111
+
112
+ @property
113
+ def livemode(self) -> Optional[bool]:
114
+ return None if self.mode is None else self.mode == "live"
115
+
116
+ # ---- generic calls ---------------------------------------------------------------------------------------------
117
+
118
+ def call(self, operation_id: str, args: Optional[Mapping[str, Any]] = None, *, request_options: Optional[RequestOptions] = None, **kwargs: Any) -> Any:
119
+ """Calls any operation by its operationId with flat arguments (path parameters and params together), the
120
+ shape MCP tools and agents use: ``checkbox.call("retrieveSite", {"site": "site_…"})``."""
121
+ op = OPERATIONS.get(operation_id)
122
+ if op is None:
123
+ raise InvalidRequestError(f'Unknown operation "{operation_id}".', type="invalid_request_error", code="route_not_found", param="operation_id")
124
+ rest: Dict[str, Any] = dict(args or {})
125
+ rest.update(kwargs)
126
+ path_args = tuple(rest.pop(name, None) for name in op["path_params"])
127
+ return self._send(operation_id, path_args, rest, request_options) # type: ignore[arg-type]
128
+
129
+ def request(self, method: str, path: str, params: Optional[Mapping[str, Any]] = None, *, request_options: Optional[RequestOptions] = None) -> Any:
130
+ """Raw request to any API path (escape hatch for endpoints newer than this SDK). ``params`` go to the query
131
+ string for GET/DELETE and to the JSON body for POST."""
132
+ op = {
133
+ "operation_id": "request",
134
+ "http_method": method.upper(),
135
+ "path": path if path.startswith("/") else "/" + path,
136
+ "path_params": (),
137
+ "query_params": (),
138
+ "body": method.upper() == "POST",
139
+ "idempotent": method.upper() == "POST",
140
+ "auth": True,
141
+ "response": "json",
142
+ "list": False,
143
+ "paginated": False,
144
+ }
145
+ return self._send_op(op, (), dict(params or {}), request_options)
146
+
147
+ # ---- transport -------------------------------------------------------------------------------------------------
148
+
149
+ def _send(self, operation_id: str, path_args: Tuple[Any, ...], params: Dict[str, Any], request_options: Optional[RequestOptions]) -> Any:
150
+ return self._send_op(OPERATIONS[operation_id], path_args, params, request_options)
151
+
152
+ def _send_op(self, op: Mapping[str, Any], path_args: Tuple[Any, ...], params: Dict[str, Any], request_options: Optional[RequestOptions]) -> Any:
153
+ options: Dict[str, Any] = dict(request_options or {})
154
+ api_key = options.get("api_key") or self._api_key
155
+ if op["auth"] and not api_key:
156
+ raise AuthenticationError(
157
+ "No API key provided. Pass it to Checkbox(api_key) or set CHECKBOX_API_KEY (or CHECKBOX_SECRET_KEY, which `checkbox init` writes).",
158
+ type="authentication_error",
159
+ code="api_key_missing",
160
+ next_action={
161
+ "type": "get_api_key",
162
+ "message": "Get a free test key: POST /v1/device/authorize (one browser approval creates an account and test keys), the remote MCP server https://app.checkbox.site/mcp (setup_checkbox), "
163
+ "or create a key at https://app.checkbox.site/settings/api-keys.",
164
+ "url": "https://app.checkbox.site/settings/api-keys",
165
+ },
166
+ )
167
+ path = op["path"]
168
+ for name, value in zip(op["path_params"], path_args + (None,) * (len(op["path_params"]) - len(path_args))):
169
+ if not isinstance(value, str) or not value.strip():
170
+ raise InvalidRequestError(
171
+ f'Missing required path parameter "{name}" for {op["operation_id"]}.',
172
+ type="invalid_request_error",
173
+ code="parameter_missing",
174
+ param=name,
175
+ )
176
+ path = path.replace("{" + name + "}", urllib.parse.quote(value, safe=""))
177
+
178
+ query: list = [] # type: ignore[type-arg]
179
+ body: Dict[str, Any] = {}
180
+ query_names = set(op["query_params"])
181
+ for k, v in params.items():
182
+ if v is None:
183
+ continue
184
+ in_query = op["http_method"] != "POST" or k in query_names
185
+ if not in_query:
186
+ body[k] = v
187
+ continue
188
+ if k in ("expand", "expand[]"):
189
+ for e in v if isinstance(v, (list, tuple)) else [v]:
190
+ query.append(("expand[]", str(e)))
191
+ elif isinstance(v, (list, tuple)):
192
+ for e in v:
193
+ query.append((f"{k}[]", _q(e)))
194
+ elif isinstance(v, dict):
195
+ query.append((k, json.dumps(v)))
196
+ else:
197
+ query.append((k, _q(v)))
198
+ url = self.base_url + path + ("?" + urllib.parse.urlencode(query) if query else "")
199
+
200
+ headers: Dict[str, str] = {
201
+ "Accept": "application/json" if op["response"] == "json" else "*/*",
202
+ "Checkbox-Version": options.get("api_version") or self.api_version,
203
+ "User-Agent": self._user_agent,
204
+ }
205
+ headers.update(self._headers)
206
+ headers.update(options.get("headers") or {})
207
+ if api_key:
208
+ headers["Authorization"] = f"Bearer {api_key}"
209
+ data: Optional[bytes] = None
210
+ idempotency_key: Optional[str] = None
211
+ if op["http_method"] == "POST":
212
+ data = json.dumps(body).encode("utf-8")
213
+ headers["Content-Type"] = "application/json"
214
+ if op["idempotent"] or options.get("idempotency_key"):
215
+ idempotency_key = options.get("idempotency_key") or f"sdk_{uuid.uuid4()}"
216
+ headers["Idempotency-Key"] = idempotency_key
217
+
218
+ max_retries = max(0, int(options.get("max_network_retries", self.max_network_retries)))
219
+ timeout = float(options.get("timeout", self.timeout))
220
+ attempt = 0
221
+ while True:
222
+ try:
223
+ status, res_headers, raw = self._transport(op["http_method"], url, headers, data, timeout)
224
+ except (urllib.error.URLError, OSError, socket.timeout) as e:
225
+ if attempt < max_retries:
226
+ time.sleep(_backoff(attempt))
227
+ attempt += 1
228
+ continue
229
+ timed_out = isinstance(e, socket.timeout) or "timed out" in str(e)
230
+ raise APIConnectionError(
231
+ f"Request to {self.base_url} timed out after {timeout:g} s ({attempt + 1} attempts)."
232
+ if timed_out
233
+ else f"Could not reach {self.base_url}: {e} ({attempt + 1} attempts).",
234
+ type="connection_error",
235
+ code="timeout" if timed_out else "connection_failed",
236
+ next_action={"type": "retry", "message": "Check the network and CHECKBOX_API_BASE, then retry; POSTs are safe to retry with the same idempotency_key."},
237
+ ) from e
238
+ res_headers = {k.lower(): v for k, v in res_headers.items()}
239
+ if status >= 400:
240
+ text = raw.decode("utf-8", "replace")
241
+ try:
242
+ parsed: Any = json.loads(text) if text else None
243
+ except ValueError:
244
+ parsed = {"error": text[:300]} if text else None
245
+ err = error_from_response(status, parsed, res_headers)
246
+ if attempt < max_retries and _should_retry(status, err.code):
247
+ time.sleep(_retry_delay(attempt, res_headers.get("retry-after")))
248
+ attempt += 1
249
+ continue
250
+ raise err
251
+ meta = ResponseMeta(status, res_headers, idempotency_key)
252
+ if op["response"] == "binary":
253
+ disposition = res_headers.get("content-disposition", "")
254
+ m = re.search(r'filename="?([^";]+)"?', disposition, re.I)
255
+ f = CheckboxFile(raw, res_headers.get("content-type"), m.group(1) if m else None)
256
+ f.last_response = meta
257
+ return f
258
+ text = raw.decode("utf-8")
259
+ if op["response"] == "text":
260
+ return text
261
+ try:
262
+ value = json.loads(text) if text else {}
263
+ except ValueError:
264
+ raise CheckboxError(f"Invalid JSON from the API (HTTP {status}).", type="api_error", code="invalid_response", status_code=status, request_id=meta.request_id) from None
265
+ if not isinstance(value, dict):
266
+ return value
267
+ if op.get("list") and op.get("paginated"):
268
+ page: ListObject[Any] = ListObject(value)
269
+ page.last_response = meta
270
+ page._params = dict(params)
271
+ page._fetch_page = lambda p, _op=op, _a=path_args, _o=request_options: self._send_op(_op, _a, p, _o)
272
+ return page
273
+ obj = CheckboxObject(value)
274
+ obj.last_response = meta
275
+ return obj
276
+
277
+
278
+ def _q(v: Any) -> str:
279
+ if v is True:
280
+ return "true"
281
+ if v is False:
282
+ return "false"
283
+ return str(v)
284
+
285
+
286
+ def _should_retry(status: int, code: Optional[str]) -> bool:
287
+ return status == 429 or status >= 500 or (status == 409 and code == "idempotency_key_in_use")
288
+
289
+
290
+ def _backoff(attempt: int) -> float:
291
+ """0.5 s, 1 s, 2 s, ... capped at 20 s, with jitter (75-100 %)."""
292
+ base = min(0.5 * (2**attempt), MAX_RETRY_DELAY)
293
+ return base * (0.75 + random.random() * 0.25)
294
+
295
+
296
+ def _retry_delay(attempt: int, retry_after: Optional[str]) -> float:
297
+ try:
298
+ s = float(retry_after) if retry_after is not None else 0.0
299
+ except ValueError:
300
+ s = 0.0
301
+ return min(s, MAX_RETRY_DELAY) if s > 0 else _backoff(attempt)