thrustlab 0.1.0__py3-none-any.whl
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.
- thrustlab/__init__.py +23 -0
- thrustlab/_async_polling.py +47 -0
- thrustlab/_http.py +166 -0
- thrustlab/_models/__init__.py +211 -0
- thrustlab/_models/api_key.py +184 -0
- thrustlab/_models/api_key_create.py +74 -0
- thrustlab/_models/api_key_with_secret.py +192 -0
- thrustlab/_models/billing_portal_session_resource.py +74 -0
- thrustlab/_models/checkout_session_resource.py +94 -0
- thrustlab/_models/component_brand.py +80 -0
- thrustlab/_models/component_brands_response.py +108 -0
- thrustlab/_models/component_create_body.py +83 -0
- thrustlab/_models/component_create_body_spec_json.py +46 -0
- thrustlab/_models/component_list_response.py +108 -0
- thrustlab/_models/component_patch.py +111 -0
- thrustlab/_models/component_patch_spec_json_type_0.py +46 -0
- thrustlab/_models/component_resource.py +188 -0
- thrustlab/_models/component_resource_spec_json.py +46 -0
- thrustlab/_models/create_webhook_endpoint_v1_webhook_endpoints_post_response_create_webhook_endpoint_v1_webhook_endpoints_post.py +50 -0
- thrustlab/_models/credit_balance_resource.py +135 -0
- thrustlab/_models/credit_bucket_breakdown.py +92 -0
- thrustlab/_models/credit_bucket_breakdown_type.py +9 -0
- thrustlab/_models/credit_usage_event_resource.py +154 -0
- thrustlab/_models/credit_usage_event_resource_credit_type.py +9 -0
- thrustlab/_models/credit_usage_event_resource_type.py +16 -0
- thrustlab/_models/credit_usage_list_response.py +116 -0
- thrustlab/_models/credit_usage_related_resource.py +71 -0
- thrustlab/_models/credit_usage_related_resource_object.py +9 -0
- thrustlab/_models/fire_test_event_v1_webhook_endpoints_public_id_test_post_response_fire_test_event_v1_webhook_endpoints_public_id_test_post.py +48 -0
- thrustlab/_models/get_delivery_v1_webhook_endpoints_public_id_deliveries_delivery_public_id_get_response_get_delivery_v1_webhook_endpoints_public_id_deliveries_delivery_public_id_get.py +49 -0
- thrustlab/_models/get_event_v1_events_event_id_get_response_get_event_v1_events_event_id_get.py +46 -0
- thrustlab/_models/get_webhook_endpoint_v1_webhook_endpoints_public_id_get_response_get_webhook_endpoint_v1_webhook_endpoints_public_id_get.py +48 -0
- thrustlab/_models/http_validation_error.py +79 -0
- thrustlab/_models/list_deliveries_v1_webhook_endpoints_public_id_deliveries_get_response_list_deliveries_v1_webhook_endpoints_public_id_deliveries_get.py +49 -0
- thrustlab/_models/list_events_v1_events_get_response_list_events_v1_events_get.py +46 -0
- thrustlab/_models/list_response_api_key.py +116 -0
- thrustlab/_models/list_webhook_endpoints_v1_webhook_endpoints_get_response_list_webhook_endpoints_v1_webhook_endpoints_get.py +46 -0
- thrustlab/_models/patch_webhook_endpoint_v1_webhook_endpoints_public_id_patch_response_patch_webhook_endpoint_v1_webhook_endpoints_public_id_patch.py +49 -0
- thrustlab/_models/portal_session_in.py +42 -0
- thrustlab/_models/project_create_body.py +103 -0
- thrustlab/_models/project_list_response.py +108 -0
- thrustlab/_models/project_patch.py +113 -0
- thrustlab/_models/project_resource.py +127 -0
- thrustlab/_models/retry_delivery_v1_webhook_endpoints_public_id_deliveries_delivery_public_id_retry_post_response_retry_delivery_v1_webhook_endpoints_public_id_deliveries_delivery_public_id_retry_post.py +49 -0
- thrustlab/_models/rotate_webhook_secret_v1_webhook_endpoints_public_id_rotate_secret_post_response_rotate_webhook_secret_v1_webhook_endpoints_public_id_rotate_secret_post.py +49 -0
- thrustlab/_models/rotor_group_in.py +107 -0
- thrustlab/_models/rotor_group_resource.py +97 -0
- thrustlab/_models/simulation_cancel_body.py +24 -0
- thrustlab/_models/simulation_create_body.py +128 -0
- thrustlab/_models/simulation_inputs.py +66 -0
- thrustlab/_models/simulation_list_response.py +88 -0
- thrustlab/_models/simulation_patch.py +121 -0
- thrustlab/_models/simulation_patch_plot_config_json_type_0_item.py +46 -0
- thrustlab/_models/simulation_resource.py +283 -0
- thrustlab/_models/simulation_resource_error_type_0.py +46 -0
- thrustlab/_models/simulation_resource_plot_config_json_type_0_item.py +46 -0
- thrustlab/_models/simulation_resource_result_type_0.py +46 -0
- thrustlab/_models/starred_component_create_body.py +80 -0
- thrustlab/_models/starred_component_list_response.py +108 -0
- thrustlab/_models/starred_component_resource.py +106 -0
- thrustlab/_models/submission_create_body.py +121 -0
- thrustlab/_models/submission_create_body_data_json.py +46 -0
- thrustlab/_models/submission_list_response.py +108 -0
- thrustlab/_models/submission_patch.py +174 -0
- thrustlab/_models/submission_patch_data_json_type_0.py +46 -0
- thrustlab/_models/submission_resource.py +197 -0
- thrustlab/_models/submission_resource_data_json.py +46 -0
- thrustlab/_models/subscription_create.py +58 -0
- thrustlab/_models/subscription_resource.py +168 -0
- thrustlab/_models/subscription_resource_status.py +12 -0
- thrustlab/_models/subscription_resource_tier.py +12 -0
- thrustlab/_models/sweep_cancel_body.py +42 -0
- thrustlab/_models/sweep_config_in.py +172 -0
- thrustlab/_models/sweep_create_body.py +138 -0
- thrustlab/_models/sweep_inputs.py +66 -0
- thrustlab/_models/sweep_list_response.py +88 -0
- thrustlab/_models/sweep_param_range_in.py +136 -0
- thrustlab/_models/sweep_param_range_in_mode.py +9 -0
- thrustlab/_models/sweep_patch.py +121 -0
- thrustlab/_models/sweep_patch_plot_config_json_type_0_item.py +46 -0
- thrustlab/_models/sweep_point_list_response.py +88 -0
- thrustlab/_models/sweep_point_resource.py +103 -0
- thrustlab/_models/sweep_point_resource_inputs.py +46 -0
- thrustlab/_models/sweep_point_resource_rotors_type_0.py +46 -0
- thrustlab/_models/sweep_resource.py +327 -0
- thrustlab/_models/sweep_resource_error_type_0.py +46 -0
- thrustlab/_models/sweep_resource_plot_config_json_type_0_item.py +46 -0
- thrustlab/_models/sweep_resource_summary_type_0.py +46 -0
- thrustlab/_models/sweep_resource_sweep_config_type_0.py +46 -0
- thrustlab/_models/sweep_rotor_group_in.py +95 -0
- thrustlab/_models/types.py +55 -0
- thrustlab/_models/user_patch.py +73 -0
- thrustlab/_models/user_resource.py +170 -0
- thrustlab/_models/v1_health_v1_health_get_response_v1_health_v1_health_get.py +46 -0
- thrustlab/_models/validation_error.py +90 -0
- thrustlab/_models/webhook_endpoint_create.py +91 -0
- thrustlab/_models/webhook_endpoint_patch.py +144 -0
- thrustlab/_pagination.py +109 -0
- thrustlab/_version.py +3 -0
- thrustlab/client.py +100 -0
- thrustlab/exceptions.py +121 -0
- thrustlab/resources/__init__.py +1 -0
- thrustlab/resources/_base.py +12 -0
- thrustlab/resources/api_keys.py +43 -0
- thrustlab/resources/components.py +60 -0
- thrustlab/resources/credits.py +40 -0
- thrustlab/resources/projects.py +54 -0
- thrustlab/resources/simulations.py +74 -0
- thrustlab/resources/starred_components.py +57 -0
- thrustlab/resources/submissions.py +44 -0
- thrustlab/resources/subscriptions.py +42 -0
- thrustlab/resources/sweeps.py +96 -0
- thrustlab/resources/users.py +13 -0
- thrustlab/resources/webhook_endpoints.py +138 -0
- thrustlab/webhooks.py +121 -0
- thrustlab-0.1.0.dist-info/METADATA +190 -0
- thrustlab-0.1.0.dist-info/RECORD +119 -0
- thrustlab-0.1.0.dist-info/WHEEL +4 -0
- thrustlab-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""``client.submissions`` — community component submissions.
|
|
2
|
+
|
|
3
|
+
Endpoints:
|
|
4
|
+
GET /v1/submissions
|
|
5
|
+
POST /v1/submissions
|
|
6
|
+
GET /v1/submissions/{id}
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
from thrustlab._pagination import CursorPager
|
|
14
|
+
from thrustlab.resources._base import Resource
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SubmissionsResource(Resource):
|
|
18
|
+
def list(self, *, limit: int = 20, starting_after: Optional[str] = None) -> CursorPager[dict[str, Any]]:
|
|
19
|
+
"""List submissions (cursor-paginated).
|
|
20
|
+
|
|
21
|
+
Returns a :class:`CursorPager` that lazily fetches subsequent pages.
|
|
22
|
+
"""
|
|
23
|
+
params: dict[str, Any] = {"limit": limit}
|
|
24
|
+
if starting_after:
|
|
25
|
+
params["starting_after"] = starting_after
|
|
26
|
+
return CursorPager(
|
|
27
|
+
fetch_page=lambda p: self._transport.request("GET", "/v1/submissions", params=p),
|
|
28
|
+
initial_params=params,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def create(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
component_data: dict[str, Any],
|
|
35
|
+
idempotency_key: Optional[str] = None,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
"""Submit a new component for review."""
|
|
38
|
+
return self._transport.request(
|
|
39
|
+
"POST", "/v1/submissions", json=component_data,
|
|
40
|
+
idempotency_key=idempotency_key,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def retrieve(self, submission_id: str) -> dict[str, Any]:
|
|
44
|
+
return self._transport.request("GET", f"/v1/submissions/{submission_id}")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""``client.subscriptions`` — subscription info and billing portal.
|
|
2
|
+
|
|
3
|
+
Endpoints:
|
|
4
|
+
GET /v1/subscriptions/me
|
|
5
|
+
POST /v1/subscriptions/billing_portal/sessions
|
|
6
|
+
|
|
7
|
+
Note: billing_portal/sessions returns 503 ``billing_not_configured`` in Phase A
|
|
8
|
+
(before Stripe account setup). The SDK propagates this as ``APIServerError``
|
|
9
|
+
without special-casing.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from thrustlab.resources._base import Resource
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SubscriptionsResource(Resource):
|
|
20
|
+
def me(self) -> dict[str, Any]:
|
|
21
|
+
"""Return the authenticated user's subscription details."""
|
|
22
|
+
return self._transport.request("GET", "/v1/subscriptions/me")
|
|
23
|
+
|
|
24
|
+
def create_billing_portal_session(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
return_url: Optional[str] = None,
|
|
28
|
+
idempotency_key: Optional[str] = None,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
"""Create a Stripe billing-portal session and return the redirect URL.
|
|
31
|
+
|
|
32
|
+
Raises ``APIServerError`` with code ``billing_not_configured`` when
|
|
33
|
+
Stripe is not yet configured (Phase A).
|
|
34
|
+
"""
|
|
35
|
+
body: dict[str, Any] = {}
|
|
36
|
+
if return_url:
|
|
37
|
+
body["return_url"] = return_url
|
|
38
|
+
return self._transport.request(
|
|
39
|
+
"POST", "/v1/subscriptions/billing_portal/sessions",
|
|
40
|
+
json=body or None,
|
|
41
|
+
idempotency_key=idempotency_key,
|
|
42
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""``client.sweeps`` — run and manage parameter sweeps.
|
|
2
|
+
|
|
3
|
+
Endpoints:
|
|
4
|
+
GET /v1/sweeps
|
|
5
|
+
POST /v1/sweeps
|
|
6
|
+
GET /v1/sweeps/{id}
|
|
7
|
+
POST /v1/sweeps/{id}/cancel
|
|
8
|
+
GET /v1/sweeps/{id}/points
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
from thrustlab._async_polling import poll_until_terminal
|
|
16
|
+
from thrustlab._pagination import CursorPager
|
|
17
|
+
from thrustlab.resources._base import Resource
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SweepsResource(Resource):
|
|
21
|
+
def list(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
project_id: Optional[str] = None,
|
|
25
|
+
limit: int = 20,
|
|
26
|
+
starting_after: Optional[str] = None,
|
|
27
|
+
) -> CursorPager[dict[str, Any]]:
|
|
28
|
+
"""List sweeps (cursor-paginated).
|
|
29
|
+
|
|
30
|
+
Returns a :class:`CursorPager` that lazily fetches subsequent pages.
|
|
31
|
+
"""
|
|
32
|
+
params: dict[str, Any] = {"limit": limit}
|
|
33
|
+
if project_id:
|
|
34
|
+
params["project_id"] = project_id
|
|
35
|
+
if starting_after:
|
|
36
|
+
params["starting_after"] = starting_after
|
|
37
|
+
return CursorPager(
|
|
38
|
+
fetch_page=lambda p: self._transport.request("GET", "/v1/sweeps", params=p),
|
|
39
|
+
initial_params=params,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def create(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
project_id: str,
|
|
46
|
+
idempotency_key: Optional[str] = None,
|
|
47
|
+
**params: Any,
|
|
48
|
+
) -> dict[str, Any]:
|
|
49
|
+
"""Submit a sweep. Returns the resource in pending/running state."""
|
|
50
|
+
body = {"project_id": project_id, **params}
|
|
51
|
+
return self._transport.request(
|
|
52
|
+
"POST", "/v1/sweeps", json=body,
|
|
53
|
+
idempotency_key=idempotency_key,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def retrieve(self, sweep_id: str) -> dict[str, Any]:
|
|
57
|
+
return self._transport.request("GET", f"/v1/sweeps/{sweep_id}")
|
|
58
|
+
|
|
59
|
+
def cancel(self, sweep_id: str, *, idempotency_key: Optional[str] = None) -> dict[str, Any]:
|
|
60
|
+
return self._transport.request(
|
|
61
|
+
"POST", f"/v1/sweeps/{sweep_id}/cancel",
|
|
62
|
+
idempotency_key=idempotency_key,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def list_points(
|
|
66
|
+
self,
|
|
67
|
+
sweep_id: str,
|
|
68
|
+
*,
|
|
69
|
+
limit: int = 100,
|
|
70
|
+
starting_after: Optional[str] = None,
|
|
71
|
+
) -> CursorPager[dict[str, Any]]:
|
|
72
|
+
"""List computed sweep points (cursor-paginated).
|
|
73
|
+
|
|
74
|
+
Returns a :class:`CursorPager` that lazily fetches subsequent pages.
|
|
75
|
+
"""
|
|
76
|
+
params: dict[str, Any] = {"limit": limit}
|
|
77
|
+
if starting_after:
|
|
78
|
+
params["starting_after"] = starting_after
|
|
79
|
+
return CursorPager(
|
|
80
|
+
fetch_page=lambda p: self._transport.request(
|
|
81
|
+
"GET", f"/v1/sweeps/{sweep_id}/points", params=p
|
|
82
|
+
),
|
|
83
|
+
initial_params=params,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def wait(self, sweep_id: str, *, poll_interval: float = 2.0, timeout: float = 600.0) -> dict[str, Any]:
|
|
87
|
+
"""Poll until the sweep reaches a terminal state.
|
|
88
|
+
|
|
89
|
+
Returns the final resource dict. If ``timeout`` is exceeded, the dict
|
|
90
|
+
has ``status="timed_out"`` (SDK-side sentinel, not a server state).
|
|
91
|
+
"""
|
|
92
|
+
return poll_until_terminal(
|
|
93
|
+
fetch=lambda: self.retrieve(sweep_id),
|
|
94
|
+
timeout=timeout,
|
|
95
|
+
poll_interval=poll_interval,
|
|
96
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""``client.users`` — authenticated user profile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from thrustlab.resources._base import Resource
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UsersResource(Resource):
|
|
11
|
+
def me(self) -> dict[str, Any]:
|
|
12
|
+
"""Return the authenticated user's profile."""
|
|
13
|
+
return self._transport.request("GET", "/v1/users/me")
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""``client.webhook_endpoints`` — manage webhook endpoints, events, and deliveries.
|
|
2
|
+
|
|
3
|
+
Endpoints:
|
|
4
|
+
GET /v1/webhook_endpoints
|
|
5
|
+
POST /v1/webhook_endpoints
|
|
6
|
+
GET /v1/webhook_endpoints/{id}
|
|
7
|
+
PATCH /v1/webhook_endpoints/{id}
|
|
8
|
+
DELETE /v1/webhook_endpoints/{id}
|
|
9
|
+
POST /v1/webhook_endpoints/{id}/rotate_secret
|
|
10
|
+
POST /v1/webhook_endpoints/{id}/test
|
|
11
|
+
GET /v1/events
|
|
12
|
+
GET /v1/webhook_endpoints/{id}/deliveries
|
|
13
|
+
POST /v1/webhook_endpoints/{id}/deliveries/{did}/retry
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, List, Optional
|
|
19
|
+
|
|
20
|
+
from thrustlab._pagination import CursorPager
|
|
21
|
+
from thrustlab.resources._base import Resource
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WebhookEndpointsResource(Resource):
|
|
25
|
+
def list(self, *, limit: int = 20, starting_after: Optional[str] = None) -> CursorPager[dict[str, Any]]:
|
|
26
|
+
"""List webhook endpoints (cursor-paginated).
|
|
27
|
+
|
|
28
|
+
Returns a :class:`CursorPager` that lazily fetches subsequent pages.
|
|
29
|
+
"""
|
|
30
|
+
params: dict[str, Any] = {"limit": limit}
|
|
31
|
+
if starting_after:
|
|
32
|
+
params["starting_after"] = starting_after
|
|
33
|
+
return CursorPager(
|
|
34
|
+
fetch_page=lambda p: self._transport.request("GET", "/v1/webhook_endpoints", params=p),
|
|
35
|
+
initial_params=params,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def create(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
url: str,
|
|
42
|
+
events: List[str],
|
|
43
|
+
description: Optional[str] = None,
|
|
44
|
+
idempotency_key: Optional[str] = None,
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
body: dict[str, Any] = {"url": url, "events": events}
|
|
47
|
+
if description is not None:
|
|
48
|
+
body["description"] = description
|
|
49
|
+
return self._transport.request(
|
|
50
|
+
"POST", "/v1/webhook_endpoints", json=body,
|
|
51
|
+
idempotency_key=idempotency_key,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def retrieve(self, endpoint_id: str) -> dict[str, Any]:
|
|
55
|
+
return self._transport.request("GET", f"/v1/webhook_endpoints/{endpoint_id}")
|
|
56
|
+
|
|
57
|
+
def update(
|
|
58
|
+
self,
|
|
59
|
+
endpoint_id: str,
|
|
60
|
+
*,
|
|
61
|
+
idempotency_key: Optional[str] = None,
|
|
62
|
+
**fields: Any,
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
return self._transport.request(
|
|
65
|
+
"PATCH", f"/v1/webhook_endpoints/{endpoint_id}", json=fields,
|
|
66
|
+
idempotency_key=idempotency_key,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def delete(self, endpoint_id: str, *, idempotency_key: Optional[str] = None) -> None:
|
|
70
|
+
self._transport.request(
|
|
71
|
+
"DELETE", f"/v1/webhook_endpoints/{endpoint_id}",
|
|
72
|
+
idempotency_key=idempotency_key,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def rotate_secret(self, endpoint_id: str, *, idempotency_key: Optional[str] = None) -> dict[str, Any]:
|
|
76
|
+
"""Rotate the signing secret for this endpoint."""
|
|
77
|
+
return self._transport.request(
|
|
78
|
+
"POST", f"/v1/webhook_endpoints/{endpoint_id}/rotate_secret",
|
|
79
|
+
idempotency_key=idempotency_key,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def test(self, endpoint_id: str, *, idempotency_key: Optional[str] = None) -> dict[str, Any]:
|
|
83
|
+
"""Send a test event to the endpoint."""
|
|
84
|
+
return self._transport.request(
|
|
85
|
+
"POST", f"/v1/webhook_endpoints/{endpoint_id}/test",
|
|
86
|
+
idempotency_key=idempotency_key,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def list_events(self, *, limit: int = 20, starting_after: Optional[str] = None) -> CursorPager[dict[str, Any]]:
|
|
90
|
+
"""List all event types (cursor-paginated).
|
|
91
|
+
|
|
92
|
+
Returns a :class:`CursorPager` that lazily fetches subsequent pages.
|
|
93
|
+
"""
|
|
94
|
+
params: dict[str, Any] = {"limit": limit}
|
|
95
|
+
if starting_after:
|
|
96
|
+
params["starting_after"] = starting_after
|
|
97
|
+
return CursorPager(
|
|
98
|
+
fetch_page=lambda p: self._transport.request("GET", "/v1/events", params=p),
|
|
99
|
+
initial_params=params,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def list_deliveries(
|
|
103
|
+
self,
|
|
104
|
+
endpoint_id: str,
|
|
105
|
+
*,
|
|
106
|
+
status: Optional[str] = None,
|
|
107
|
+
limit: int = 20,
|
|
108
|
+
starting_after: Optional[str] = None,
|
|
109
|
+
) -> CursorPager[dict[str, Any]]:
|
|
110
|
+
"""List delivery attempts for an endpoint (cursor-paginated).
|
|
111
|
+
|
|
112
|
+
Returns a :class:`CursorPager` that lazily fetches subsequent pages.
|
|
113
|
+
"""
|
|
114
|
+
params: dict[str, Any] = {"limit": limit}
|
|
115
|
+
if status:
|
|
116
|
+
params["status"] = status
|
|
117
|
+
if starting_after:
|
|
118
|
+
params["starting_after"] = starting_after
|
|
119
|
+
return CursorPager(
|
|
120
|
+
fetch_page=lambda p: self._transport.request(
|
|
121
|
+
"GET", f"/v1/webhook_endpoints/{endpoint_id}/deliveries", params=p
|
|
122
|
+
),
|
|
123
|
+
initial_params=params,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def retry_delivery(
|
|
127
|
+
self,
|
|
128
|
+
endpoint_id: str,
|
|
129
|
+
delivery_id: str,
|
|
130
|
+
*,
|
|
131
|
+
idempotency_key: Optional[str] = None,
|
|
132
|
+
) -> dict[str, Any]:
|
|
133
|
+
"""Retry a failed delivery."""
|
|
134
|
+
return self._transport.request(
|
|
135
|
+
"POST",
|
|
136
|
+
f"/v1/webhook_endpoints/{endpoint_id}/deliveries/{delivery_id}/retry",
|
|
137
|
+
idempotency_key=idempotency_key,
|
|
138
|
+
)
|
thrustlab/webhooks.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Webhook signature verification + Event parsing.
|
|
2
|
+
|
|
3
|
+
Mirrors the live webhook-delivery service's HMAC scheme. The server-side
|
|
4
|
+
implementation is in ``backend/app/services/webhooks.py``.
|
|
5
|
+
|
|
6
|
+
Signing scheme (as of 2026-04-25):
|
|
7
|
+
Header name: ``Thrustlab-Signature``
|
|
8
|
+
Header value: ``t=<unix_ts>,v1=<hex>``
|
|
9
|
+
Signed blob: ``f"{unix_ts}.{body_utf8}".encode("utf-8")``
|
|
10
|
+
Algorithm: ``hmac.new(secret.encode("utf-8"), blob, hashlib.sha256).hexdigest()``
|
|
11
|
+
Tolerance: 300 seconds (5 minutes) by default
|
|
12
|
+
|
|
13
|
+
Example::
|
|
14
|
+
|
|
15
|
+
from thrustlab import Webhook
|
|
16
|
+
from thrustlab.exceptions import SignatureVerificationError
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
event = Webhook.verify(request.body, request.headers["Thrustlab-Signature"], secret)
|
|
20
|
+
except SignatureVerificationError as e:
|
|
21
|
+
return 400, str(e)
|
|
22
|
+
process(event)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import hmac
|
|
29
|
+
import json
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from thrustlab.exceptions import SignatureVerificationError
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
DEFAULT_TOLERANCE_SECONDS = 5 * 60 # 300 s
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Event:
|
|
42
|
+
"""A parsed, verified webhook event."""
|
|
43
|
+
|
|
44
|
+
id: str
|
|
45
|
+
type: str
|
|
46
|
+
created: int
|
|
47
|
+
data: dict[str, Any]
|
|
48
|
+
raw: dict[str, Any]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Webhook:
|
|
52
|
+
@staticmethod
|
|
53
|
+
def verify(
|
|
54
|
+
payload: bytes | str,
|
|
55
|
+
signature_header: str,
|
|
56
|
+
secret: str,
|
|
57
|
+
*,
|
|
58
|
+
tolerance: int = DEFAULT_TOLERANCE_SECONDS,
|
|
59
|
+
) -> Event:
|
|
60
|
+
"""Verify the webhook signature and return the parsed Event.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
payload: Raw request body (bytes or str).
|
|
64
|
+
signature_header: Value of the ``Thrustlab-Signature`` header.
|
|
65
|
+
secret: Signing secret starting with ``whsec_``.
|
|
66
|
+
tolerance: Maximum age of the timestamp in seconds (default 300).
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Parsed :class:`Event` if verification succeeds.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
:class:`~thrustlab.exceptions.SignatureVerificationError` on:
|
|
73
|
+
- Missing or malformed header (no ``t=`` or ``v1=`` parts)
|
|
74
|
+
- HMAC mismatch (wrong secret, tampered payload)
|
|
75
|
+
- Timestamp outside the tolerance window (replay protection)
|
|
76
|
+
"""
|
|
77
|
+
if isinstance(payload, str):
|
|
78
|
+
payload_bytes = payload.encode("utf-8")
|
|
79
|
+
else:
|
|
80
|
+
payload_bytes = payload
|
|
81
|
+
|
|
82
|
+
timestamp, sig = _parse_signature_header(signature_header)
|
|
83
|
+
|
|
84
|
+
# Construct the signed blob exactly as the server does:
|
|
85
|
+
# f"{unix_ts}.{body_utf8}".encode("utf-8")
|
|
86
|
+
signed = f"{timestamp}.{payload_bytes.decode('utf-8')}".encode("utf-8")
|
|
87
|
+
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
|
|
88
|
+
|
|
89
|
+
if not hmac.compare_digest(expected, sig):
|
|
90
|
+
raise SignatureVerificationError("HMAC signature does not match")
|
|
91
|
+
|
|
92
|
+
# Check timestamp *after* HMAC so we don't leak timing info on bad sigs.
|
|
93
|
+
if abs(time.time() - timestamp) > tolerance:
|
|
94
|
+
raise SignatureVerificationError(
|
|
95
|
+
f"timestamp outside tolerance ({tolerance}s)"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
body = json.loads(payload_bytes)
|
|
99
|
+
return Event(
|
|
100
|
+
id=body.get("id", ""),
|
|
101
|
+
type=body.get("type", ""),
|
|
102
|
+
created=body.get("created", 0),
|
|
103
|
+
data=body.get("data", {}),
|
|
104
|
+
raw=body,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_signature_header(header: str) -> tuple[int, str]:
|
|
109
|
+
"""Parse ``t=<unix>,v1=<hex>`` header into ``(timestamp, sig_hex)``."""
|
|
110
|
+
parts: dict[str, str] = {}
|
|
111
|
+
for chunk in header.split(","):
|
|
112
|
+
if "=" not in chunk:
|
|
113
|
+
raise SignatureVerificationError("malformed signature header")
|
|
114
|
+
k, v = chunk.split("=", 1)
|
|
115
|
+
parts[k.strip()] = v.strip()
|
|
116
|
+
if "t" not in parts or "v1" not in parts:
|
|
117
|
+
raise SignatureVerificationError("missing required signature parts (t or v1)")
|
|
118
|
+
try:
|
|
119
|
+
return int(parts["t"]), parts["v1"]
|
|
120
|
+
except ValueError as exc:
|
|
121
|
+
raise SignatureVerificationError("malformed timestamp") from exc
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: thrustlab
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the ThrustLab API
|
|
5
|
+
Project-URL: Homepage, https://thrustlab.com
|
|
6
|
+
Project-URL: Documentation, https://thrustlab.com/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/<owner>/thrustlab
|
|
8
|
+
Project-URL: Changelog, https://thrustlab.com/docs/changelog
|
|
9
|
+
Author-email: ThrustLab <support@thrustlab.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: attrs>=23.0
|
|
22
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
23
|
+
Requires-Dist: pydantic<3.0,>=2.7
|
|
24
|
+
Requires-Dist: python-dateutil>=2.8
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: openapi-python-client>=0.21; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
29
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# thrustlab — official Python SDK for the ThrustLab API
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/thrustlab/)
|
|
35
|
+
[](https://pypi.org/project/thrustlab/)
|
|
36
|
+
[](https://github.com/kylebedrich/thrustlab/blob/master/backend/sdks/python/LICENSE)
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install thrustlab
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Requires Python 3.10+.
|
|
45
|
+
|
|
46
|
+
## Quickstart
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from thrustlab import Client
|
|
50
|
+
|
|
51
|
+
client = Client(api_key="key_...") # or set $THRUSTLAB_API_KEY
|
|
52
|
+
|
|
53
|
+
# Create a project
|
|
54
|
+
project = client.projects.create(name="my project")
|
|
55
|
+
|
|
56
|
+
# Run a simulation and wait for the result
|
|
57
|
+
sim = client.simulations.create(
|
|
58
|
+
project_id=project["id"],
|
|
59
|
+
motor="comp_motor_xxx",
|
|
60
|
+
propeller="comp_prop_xxx",
|
|
61
|
+
battery="comp_batt_xxx",
|
|
62
|
+
throttle=0.7,
|
|
63
|
+
)
|
|
64
|
+
result = client.simulations.wait(sim["id"], timeout=300)
|
|
65
|
+
print(result)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
| Setting | Constructor arg | Env var | Default |
|
|
71
|
+
|--------------|------------------|-----------------------|--------------------------|
|
|
72
|
+
| API key | `api_key=` | `THRUSTLAB_API_KEY` | (required) |
|
|
73
|
+
| Base URL | `base_url=` | `THRUSTLAB_BASE_URL` | `https://thrustlab.com` |
|
|
74
|
+
| Timeout | `timeout=` | — | `30` (seconds) |
|
|
75
|
+
| Max retries | `max_retries=` | — | `3` |
|
|
76
|
+
|
|
77
|
+
## Resources
|
|
78
|
+
|
|
79
|
+
Every `/v1/` route family is exposed as an attribute on the client:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
client.api_keys
|
|
83
|
+
client.users
|
|
84
|
+
client.projects
|
|
85
|
+
client.simulations
|
|
86
|
+
client.sweeps
|
|
87
|
+
client.components
|
|
88
|
+
client.submissions
|
|
89
|
+
client.starred_components
|
|
90
|
+
client.geometry
|
|
91
|
+
client.credits
|
|
92
|
+
client.subscriptions
|
|
93
|
+
client.webhook_endpoints
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Error handling
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from thrustlab import Client
|
|
100
|
+
from thrustlab.exceptions import (
|
|
101
|
+
AuthenticationError,
|
|
102
|
+
ValidationError,
|
|
103
|
+
RateLimitError,
|
|
104
|
+
NotFoundError,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
client = Client()
|
|
108
|
+
try:
|
|
109
|
+
client.simulations.create(project_id="proj_xxx", motor="comp_xxx", ...)
|
|
110
|
+
except ValidationError as exc:
|
|
111
|
+
print(f"bad request: {exc.code} ({exc.param}): {exc.message}")
|
|
112
|
+
except RateLimitError as exc:
|
|
113
|
+
print(f"rate limited; retry after {exc.retry_after}s")
|
|
114
|
+
except NotFoundError as exc:
|
|
115
|
+
print(f"not found: {exc.message}")
|
|
116
|
+
except AuthenticationError as exc:
|
|
117
|
+
print(f"auth failed: {exc.message}")
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Every error carries `.code`, `.type`, `.request_id`, `.http_status`, and
|
|
121
|
+
`.message`. `ValidationError` additionally exposes `.param`. See
|
|
122
|
+
[thrustlab.com/docs/guides/errors](https://thrustlab.com/docs/guides/errors)
|
|
123
|
+
for the full code reference.
|
|
124
|
+
|
|
125
|
+
## Pagination
|
|
126
|
+
|
|
127
|
+
List endpoints return a `CursorPager` — a lazy iterator that fetches the
|
|
128
|
+
next page only when needed:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
# Iterate all pages automatically
|
|
132
|
+
for project in client.projects.list():
|
|
133
|
+
print(project["id"])
|
|
134
|
+
|
|
135
|
+
# Materialise the first page only
|
|
136
|
+
first_page = list(client.projects.list(limit=20))
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Async polling
|
|
140
|
+
|
|
141
|
+
`simulations.wait()` and `sweeps.wait()` block until the resource reaches a
|
|
142
|
+
terminal state (`succeeded`, `failed`, or `cancelled`) or the timeout fires:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
sweep = client.sweeps.create(...)
|
|
146
|
+
result = client.sweeps.wait(sweep["id"], timeout=600, poll_interval=2.0)
|
|
147
|
+
if result["status"] == "succeeded":
|
|
148
|
+
print(result["outputs"])
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Webhooks
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from thrustlab import Webhook
|
|
155
|
+
from thrustlab.exceptions import SignatureVerificationError
|
|
156
|
+
|
|
157
|
+
WEBHOOK_SECRET = "whsec_..."
|
|
158
|
+
|
|
159
|
+
@app.post("/webhooks/thrustlab")
|
|
160
|
+
async def handle(request):
|
|
161
|
+
payload = await request.body()
|
|
162
|
+
sig = request.headers["Thrustlab-Signature"]
|
|
163
|
+
try:
|
|
164
|
+
event = Webhook.verify(payload, sig, WEBHOOK_SECRET)
|
|
165
|
+
except SignatureVerificationError:
|
|
166
|
+
return Response(status_code=400)
|
|
167
|
+
if event.type == "simulation.succeeded":
|
|
168
|
+
print(event.data)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Retries
|
|
172
|
+
|
|
173
|
+
The client automatically retries on 429 (rate limit) and 5xx responses using
|
|
174
|
+
exponential backoff with jitter. Set `max_retries=0` to disable:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
client = Client(max_retries=0)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Links
|
|
181
|
+
|
|
182
|
+
- Documentation: https://thrustlab.com/docs
|
|
183
|
+
- API reference: https://thrustlab.com/docs/reference
|
|
184
|
+
- SDK guide: https://thrustlab.com/docs/sdk/python
|
|
185
|
+
- Changelog: https://thrustlab.com/docs/changelog
|
|
186
|
+
- Issue tracker: https://github.com/kylebedrich/thrustlab/issues
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT
|