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.
Files changed (119) hide show
  1. thrustlab/__init__.py +23 -0
  2. thrustlab/_async_polling.py +47 -0
  3. thrustlab/_http.py +166 -0
  4. thrustlab/_models/__init__.py +211 -0
  5. thrustlab/_models/api_key.py +184 -0
  6. thrustlab/_models/api_key_create.py +74 -0
  7. thrustlab/_models/api_key_with_secret.py +192 -0
  8. thrustlab/_models/billing_portal_session_resource.py +74 -0
  9. thrustlab/_models/checkout_session_resource.py +94 -0
  10. thrustlab/_models/component_brand.py +80 -0
  11. thrustlab/_models/component_brands_response.py +108 -0
  12. thrustlab/_models/component_create_body.py +83 -0
  13. thrustlab/_models/component_create_body_spec_json.py +46 -0
  14. thrustlab/_models/component_list_response.py +108 -0
  15. thrustlab/_models/component_patch.py +111 -0
  16. thrustlab/_models/component_patch_spec_json_type_0.py +46 -0
  17. thrustlab/_models/component_resource.py +188 -0
  18. thrustlab/_models/component_resource_spec_json.py +46 -0
  19. thrustlab/_models/create_webhook_endpoint_v1_webhook_endpoints_post_response_create_webhook_endpoint_v1_webhook_endpoints_post.py +50 -0
  20. thrustlab/_models/credit_balance_resource.py +135 -0
  21. thrustlab/_models/credit_bucket_breakdown.py +92 -0
  22. thrustlab/_models/credit_bucket_breakdown_type.py +9 -0
  23. thrustlab/_models/credit_usage_event_resource.py +154 -0
  24. thrustlab/_models/credit_usage_event_resource_credit_type.py +9 -0
  25. thrustlab/_models/credit_usage_event_resource_type.py +16 -0
  26. thrustlab/_models/credit_usage_list_response.py +116 -0
  27. thrustlab/_models/credit_usage_related_resource.py +71 -0
  28. thrustlab/_models/credit_usage_related_resource_object.py +9 -0
  29. 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
  30. 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
  31. thrustlab/_models/get_event_v1_events_event_id_get_response_get_event_v1_events_event_id_get.py +46 -0
  32. thrustlab/_models/get_webhook_endpoint_v1_webhook_endpoints_public_id_get_response_get_webhook_endpoint_v1_webhook_endpoints_public_id_get.py +48 -0
  33. thrustlab/_models/http_validation_error.py +79 -0
  34. thrustlab/_models/list_deliveries_v1_webhook_endpoints_public_id_deliveries_get_response_list_deliveries_v1_webhook_endpoints_public_id_deliveries_get.py +49 -0
  35. thrustlab/_models/list_events_v1_events_get_response_list_events_v1_events_get.py +46 -0
  36. thrustlab/_models/list_response_api_key.py +116 -0
  37. thrustlab/_models/list_webhook_endpoints_v1_webhook_endpoints_get_response_list_webhook_endpoints_v1_webhook_endpoints_get.py +46 -0
  38. thrustlab/_models/patch_webhook_endpoint_v1_webhook_endpoints_public_id_patch_response_patch_webhook_endpoint_v1_webhook_endpoints_public_id_patch.py +49 -0
  39. thrustlab/_models/portal_session_in.py +42 -0
  40. thrustlab/_models/project_create_body.py +103 -0
  41. thrustlab/_models/project_list_response.py +108 -0
  42. thrustlab/_models/project_patch.py +113 -0
  43. thrustlab/_models/project_resource.py +127 -0
  44. 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
  45. 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
  46. thrustlab/_models/rotor_group_in.py +107 -0
  47. thrustlab/_models/rotor_group_resource.py +97 -0
  48. thrustlab/_models/simulation_cancel_body.py +24 -0
  49. thrustlab/_models/simulation_create_body.py +128 -0
  50. thrustlab/_models/simulation_inputs.py +66 -0
  51. thrustlab/_models/simulation_list_response.py +88 -0
  52. thrustlab/_models/simulation_patch.py +121 -0
  53. thrustlab/_models/simulation_patch_plot_config_json_type_0_item.py +46 -0
  54. thrustlab/_models/simulation_resource.py +283 -0
  55. thrustlab/_models/simulation_resource_error_type_0.py +46 -0
  56. thrustlab/_models/simulation_resource_plot_config_json_type_0_item.py +46 -0
  57. thrustlab/_models/simulation_resource_result_type_0.py +46 -0
  58. thrustlab/_models/starred_component_create_body.py +80 -0
  59. thrustlab/_models/starred_component_list_response.py +108 -0
  60. thrustlab/_models/starred_component_resource.py +106 -0
  61. thrustlab/_models/submission_create_body.py +121 -0
  62. thrustlab/_models/submission_create_body_data_json.py +46 -0
  63. thrustlab/_models/submission_list_response.py +108 -0
  64. thrustlab/_models/submission_patch.py +174 -0
  65. thrustlab/_models/submission_patch_data_json_type_0.py +46 -0
  66. thrustlab/_models/submission_resource.py +197 -0
  67. thrustlab/_models/submission_resource_data_json.py +46 -0
  68. thrustlab/_models/subscription_create.py +58 -0
  69. thrustlab/_models/subscription_resource.py +168 -0
  70. thrustlab/_models/subscription_resource_status.py +12 -0
  71. thrustlab/_models/subscription_resource_tier.py +12 -0
  72. thrustlab/_models/sweep_cancel_body.py +42 -0
  73. thrustlab/_models/sweep_config_in.py +172 -0
  74. thrustlab/_models/sweep_create_body.py +138 -0
  75. thrustlab/_models/sweep_inputs.py +66 -0
  76. thrustlab/_models/sweep_list_response.py +88 -0
  77. thrustlab/_models/sweep_param_range_in.py +136 -0
  78. thrustlab/_models/sweep_param_range_in_mode.py +9 -0
  79. thrustlab/_models/sweep_patch.py +121 -0
  80. thrustlab/_models/sweep_patch_plot_config_json_type_0_item.py +46 -0
  81. thrustlab/_models/sweep_point_list_response.py +88 -0
  82. thrustlab/_models/sweep_point_resource.py +103 -0
  83. thrustlab/_models/sweep_point_resource_inputs.py +46 -0
  84. thrustlab/_models/sweep_point_resource_rotors_type_0.py +46 -0
  85. thrustlab/_models/sweep_resource.py +327 -0
  86. thrustlab/_models/sweep_resource_error_type_0.py +46 -0
  87. thrustlab/_models/sweep_resource_plot_config_json_type_0_item.py +46 -0
  88. thrustlab/_models/sweep_resource_summary_type_0.py +46 -0
  89. thrustlab/_models/sweep_resource_sweep_config_type_0.py +46 -0
  90. thrustlab/_models/sweep_rotor_group_in.py +95 -0
  91. thrustlab/_models/types.py +55 -0
  92. thrustlab/_models/user_patch.py +73 -0
  93. thrustlab/_models/user_resource.py +170 -0
  94. thrustlab/_models/v1_health_v1_health_get_response_v1_health_v1_health_get.py +46 -0
  95. thrustlab/_models/validation_error.py +90 -0
  96. thrustlab/_models/webhook_endpoint_create.py +91 -0
  97. thrustlab/_models/webhook_endpoint_patch.py +144 -0
  98. thrustlab/_pagination.py +109 -0
  99. thrustlab/_version.py +3 -0
  100. thrustlab/client.py +100 -0
  101. thrustlab/exceptions.py +121 -0
  102. thrustlab/resources/__init__.py +1 -0
  103. thrustlab/resources/_base.py +12 -0
  104. thrustlab/resources/api_keys.py +43 -0
  105. thrustlab/resources/components.py +60 -0
  106. thrustlab/resources/credits.py +40 -0
  107. thrustlab/resources/projects.py +54 -0
  108. thrustlab/resources/simulations.py +74 -0
  109. thrustlab/resources/starred_components.py +57 -0
  110. thrustlab/resources/submissions.py +44 -0
  111. thrustlab/resources/subscriptions.py +42 -0
  112. thrustlab/resources/sweeps.py +96 -0
  113. thrustlab/resources/users.py +13 -0
  114. thrustlab/resources/webhook_endpoints.py +138 -0
  115. thrustlab/webhooks.py +121 -0
  116. thrustlab-0.1.0.dist-info/METADATA +190 -0
  117. thrustlab-0.1.0.dist-info/RECORD +119 -0
  118. thrustlab-0.1.0.dist-info/WHEEL +4 -0
  119. 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
+ [![PyPI](https://img.shields.io/pypi/v/thrustlab.svg)](https://pypi.org/project/thrustlab/)
35
+ [![Python](https://img.shields.io/pypi/pyversions/thrustlab.svg)](https://pypi.org/project/thrustlab/)
36
+ [![License](https://img.shields.io/pypi/l/thrustlab.svg)](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