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.
- checkbox_site-0.1.0/LICENSE +21 -0
- checkbox_site-0.1.0/PKG-INFO +121 -0
- checkbox_site-0.1.0/README.md +99 -0
- checkbox_site-0.1.0/checkbox/__init__.py +61 -0
- checkbox_site-0.1.0/checkbox/_client.py +301 -0
- checkbox_site-0.1.0/checkbox/_resource.py +119 -0
- checkbox_site-0.1.0/checkbox/_resources.py +3009 -0
- checkbox_site-0.1.0/checkbox/_version.py +2 -0
- checkbox_site-0.1.0/checkbox/errors.py +162 -0
- checkbox_site-0.1.0/checkbox/py.typed +0 -0
- checkbox_site-0.1.0/checkbox/types.py +7457 -0
- checkbox_site-0.1.0/checkbox/webhook.py +106 -0
- checkbox_site-0.1.0/checkbox_site.egg-info/PKG-INFO +121 -0
- checkbox_site-0.1.0/checkbox_site.egg-info/SOURCES.txt +19 -0
- checkbox_site-0.1.0/checkbox_site.egg-info/dependency_links.txt +1 -0
- checkbox_site-0.1.0/checkbox_site.egg-info/top_level.txt +1 -0
- checkbox_site-0.1.0/pyproject.toml +38 -0
- checkbox_site-0.1.0/setup.cfg +4 -0
- checkbox_site-0.1.0/tests/test_client.py +263 -0
- checkbox_site-0.1.0/tests/test_generated.py +32 -0
- checkbox_site-0.1.0/tests/test_webhook.py +71 -0
|
@@ -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)
|