buildwithtrace-sdk 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.
@@ -0,0 +1,26 @@
1
+ """Trace Python SDK — programmatic access to Trace AI for PCB/schematic design.
2
+
3
+ from buildwithtrace_sdk import Trace
4
+ client = Trace(api_key="...")
5
+ sym = client.generate_symbol("LM7805 5V regulator")
6
+ """
7
+
8
+ from buildwithtrace_sdk.client import (
9
+ GenerateResult,
10
+ SearchResult,
11
+ TextResult,
12
+ Trace,
13
+ TraceError,
14
+ TraceToolExecutionError,
15
+ )
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = [
20
+ "Trace",
21
+ "GenerateResult",
22
+ "SearchResult",
23
+ "TextResult",
24
+ "TraceError",
25
+ "TraceToolExecutionError",
26
+ ]
@@ -0,0 +1,181 @@
1
+ """HTTP API client — httpx with auth, retry, environment switching."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Any, AsyncIterator, Optional
6
+
7
+ import httpx
8
+
9
+ from buildwithtrace_sdk.config import get_api_base_url, get_backend_url
10
+ from buildwithtrace_sdk.config.credentials import get_access_token, get_refresh_token, store_tokens
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ REQUEST_TIMEOUT = 30.0
15
+ CONNECT_TIMEOUT = 10.0
16
+ STREAM_TIMEOUT = 300.0
17
+ MAX_RETRIES = 3
18
+ RETRY_BACKOFF = [1.0, 2.0, 4.0]
19
+
20
+
21
+ class TraceAPIError(Exception):
22
+ """Raised when the Trace API returns an error."""
23
+
24
+ def __init__(self, status_code: int, message: str, code: str = ""):
25
+ self.status_code = status_code
26
+ self.message = message
27
+ self.code = code
28
+ super().__init__(f"[{status_code}] {message}")
29
+
30
+
31
+ class AuthRequiredError(TraceAPIError):
32
+ """Raised when authentication is required but no token available."""
33
+
34
+ def __init__(self):
35
+ super().__init__(401, "Authentication required. Run: buildwithtrace auth login")
36
+
37
+
38
+ def _get_headers() -> dict[str, str]:
39
+ """Build request headers with auth token if available."""
40
+ headers = {
41
+ "Content-Type": "application/json",
42
+ "User-Agent": "trace-cli/0.1.0",
43
+ }
44
+ token = get_access_token()
45
+ if token:
46
+ headers["Authorization"] = f"Bearer {token}"
47
+ return headers
48
+
49
+
50
+ async def _try_refresh_token() -> bool:
51
+ """Attempt to refresh the access token. Returns True on success."""
52
+ refresh_token = get_refresh_token()
53
+ if not refresh_token:
54
+ return False
55
+
56
+ backend_url = get_backend_url()
57
+ async with httpx.AsyncClient(timeout=httpx.Timeout(REQUEST_TIMEOUT, connect=CONNECT_TIMEOUT)) as client:
58
+ try:
59
+ resp = await client.post(
60
+ f"{backend_url}/api/v3/auth/refresh",
61
+ json={"refresh_token": refresh_token},
62
+ )
63
+ if resp.status_code == 200:
64
+ data = resp.json()
65
+ store_tokens(
66
+ access_token=data["access_token"],
67
+ refresh_token=data["refresh_token"],
68
+ user_data=data.get("user"),
69
+ )
70
+ return True
71
+ except httpx.HTTPError:
72
+ pass
73
+ return False
74
+
75
+
76
+ async def request(
77
+ method: str,
78
+ path: str,
79
+ json: Optional[dict] = None,
80
+ params: Optional[dict] = None,
81
+ require_auth: bool = True,
82
+ timeout: float = REQUEST_TIMEOUT,
83
+ ) -> dict[str, Any]:
84
+ """Make an API request with auth, retry, and error handling."""
85
+ if require_auth and not get_access_token():
86
+ raise AuthRequiredError()
87
+
88
+ url = f"{get_api_base_url()}{path}"
89
+
90
+ for attempt in range(MAX_RETRIES):
91
+ headers = _get_headers()
92
+ async with httpx.AsyncClient(timeout=timeout) as client:
93
+ try:
94
+ resp = await client.request(
95
+ method, url, json=json, params=params, headers=headers
96
+ )
97
+ except httpx.ConnectError:
98
+ raise TraceAPIError(
99
+ 0, f"Cannot connect to {get_backend_url()}. Run: buildwithtrace doctor"
100
+ )
101
+ except httpx.TimeoutException:
102
+ if attempt < MAX_RETRIES - 1:
103
+ await asyncio.sleep(RETRY_BACKOFF[attempt])
104
+ continue
105
+ raise TraceAPIError(408, "Request timed out")
106
+
107
+ if resp.status_code == 401:
108
+ if await _try_refresh_token():
109
+ continue
110
+ raise TraceAPIError(401, "Session expired. Run: buildwithtrace auth login")
111
+
112
+ if resp.status_code == 429:
113
+ if attempt < MAX_RETRIES - 1:
114
+ retry_after = float(resp.headers.get("Retry-After", RETRY_BACKOFF[attempt]))
115
+ logger.info(f"Rate limited. Retrying in {retry_after}s...")
116
+ await asyncio.sleep(retry_after)
117
+ continue
118
+ raise TraceAPIError(429, "Rate limited. Try again later.")
119
+
120
+ if resp.status_code == 402:
121
+ raise TraceAPIError(402, "Quota exceeded. Run: buildwithtrace billing upgrade")
122
+
123
+ if resp.status_code >= 500:
124
+ if attempt < MAX_RETRIES - 1:
125
+ await asyncio.sleep(RETRY_BACKOFF[attempt])
126
+ continue
127
+ raise TraceAPIError(resp.status_code, "Server error. Try again later.")
128
+
129
+ if resp.status_code >= 400:
130
+ ct = resp.headers.get("content-type", "")
131
+ if "application/json" in ct:
132
+ body = resp.json()
133
+ else:
134
+ body = {"detail": resp.text[:200]}
135
+ raise TraceAPIError(
136
+ resp.status_code,
137
+ body.get("detail", body.get("message", resp.text[:200])),
138
+ code=body.get("code", ""),
139
+ )
140
+
141
+ ct = resp.headers.get("content-type", "")
142
+ if "application/json" in ct:
143
+ return resp.json()
144
+ return {"_raw": resp.text, "_status": resp.status_code}
145
+
146
+ raise TraceAPIError(0, "Max retries exceeded")
147
+
148
+
149
+ async def stream_sse(
150
+ path: str,
151
+ json: Optional[dict] = None,
152
+ require_auth: bool = True,
153
+ ) -> AsyncIterator[dict]:
154
+ """Stream SSE events from the backend (for /chat/stream, /pcb/autoroute)."""
155
+ from httpx_sse import aconnect_sse
156
+
157
+ if require_auth and not get_access_token():
158
+ raise AuthRequiredError()
159
+
160
+ url = f"{get_api_base_url()}{path}"
161
+ headers = _get_headers()
162
+
163
+ async with httpx.AsyncClient(timeout=httpx.Timeout(STREAM_TIMEOUT, connect=10.0)) as client:
164
+ async with aconnect_sse(client, "POST", url, json=json, headers=headers) as event_source:
165
+ async for event in event_source.aiter_sse():
166
+ if event.data:
167
+ try:
168
+ import json as json_mod
169
+ yield {"event": event.event, "data": json_mod.loads(event.data)}
170
+ except (ValueError, TypeError):
171
+ yield {"event": event.event, "data": event.data}
172
+
173
+
174
+ async def get(path: str, **kwargs) -> dict:
175
+ """GET request shorthand."""
176
+ return await request("GET", path, **kwargs)
177
+
178
+
179
+ async def post(path: str, **kwargs) -> dict:
180
+ """POST request shorthand."""
181
+ return await request("POST", path, **kwargs)
@@ -0,0 +1,259 @@
1
+ """OAuth browser-based authentication flow."""
2
+
3
+ import json
4
+ import logging
5
+ import webbrowser
6
+ from http.server import BaseHTTPRequestHandler, HTTPServer
7
+ from threading import Thread
8
+ from typing import Optional
9
+ from urllib.parse import parse_qs, unquote, urlparse
10
+
11
+ from buildwithtrace_sdk.config import get_backend_url
12
+ from buildwithtrace_sdk.config.credentials import store_tokens
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ CALLBACK_TIMEOUT = 120
17
+
18
+
19
+ class _OAuthCallbackHandler(BaseHTTPRequestHandler):
20
+ """HTTP handler that receives the OAuth callback."""
21
+
22
+ token_data: Optional[dict] = None
23
+
24
+ def do_GET(self):
25
+ parsed = urlparse(self.path)
26
+ params = parse_qs(parsed.query)
27
+
28
+ if "code" in params:
29
+ self.server._auth_code = params["code"][0]
30
+ self._respond_success()
31
+ elif "token" in params or "access_token" in params:
32
+ token = params.get("token", params.get("access_token", [None]))[0]
33
+ refresh = params.get("refresh_token", [None])[0]
34
+ user_raw = params.get("user", [None])[0]
35
+ user_data = None
36
+ if user_raw:
37
+ try:
38
+ user_data = json.loads(unquote(user_raw))
39
+ except (json.JSONDecodeError, TypeError):
40
+ pass
41
+ self.server._token_data = {
42
+ "access_token": token,
43
+ "refresh_token": refresh,
44
+ "user": user_data,
45
+ }
46
+ self._respond_success()
47
+ elif "error" in params:
48
+ error = params["error"][0]
49
+ self.server._auth_error = error
50
+ self._respond_error(error)
51
+ else:
52
+ self._respond_error("No token or code received")
53
+
54
+ def _respond_success(self):
55
+ self.send_response(200)
56
+ self.send_header("Content-Type", "text/html")
57
+ self.end_headers()
58
+ self.wfile.write(b"""<!DOCTYPE html><html><body style="font-family:system-ui;text-align:center;padding:60px">
59
+ <h1>Authenticated!</h1><p>You can close this tab and return to your terminal.</p>
60
+ <script>window.close()</script></body></html>""")
61
+
62
+ def _respond_error(self, error: str):
63
+ self.send_response(400)
64
+ self.send_header("Content-Type", "text/html")
65
+ self.end_headers()
66
+ self.wfile.write(f"""<!DOCTYPE html><html><body style="font-family:system-ui;text-align:center;padding:60px">
67
+ <h1>Authentication Failed</h1><p>{error}</p></body></html>""".encode())
68
+
69
+ def log_message(self, format, *args):
70
+ pass
71
+
72
+
73
+ def _find_free_port() -> int:
74
+ """Find a free port for the local callback server."""
75
+ import socket
76
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
77
+ s.bind(("127.0.0.1", 0))
78
+ return s.getsockname()[1]
79
+
80
+
81
+ async def browser_oauth_login(provider: str = "google") -> dict:
82
+ """Run browser-based OAuth flow. Returns token data dict."""
83
+ port = _find_free_port()
84
+ callback_url = f"http://localhost:{port}/callback"
85
+
86
+ server = HTTPServer(("127.0.0.1", port), _OAuthCallbackHandler)
87
+ server._auth_code = None
88
+ server._token_data = None
89
+ server._auth_error = None
90
+ server.timeout = CALLBACK_TIMEOUT
91
+
92
+ def _serve_until_result():
93
+ for _ in range(5):
94
+ server.handle_request()
95
+ if server._token_data or server._auth_code or server._auth_error:
96
+ break
97
+
98
+ thread = Thread(target=_serve_until_result, daemon=True)
99
+ thread.start()
100
+
101
+ backend_url = get_backend_url()
102
+ auth_url = f"{backend_url}/api/v3/auth/{provider}/login?callback=cli&redirect_uri={callback_url}"
103
+
104
+ webbrowser.open(auth_url)
105
+
106
+ thread.join(timeout=CALLBACK_TIMEOUT)
107
+ server.server_close()
108
+
109
+ if server._auth_error:
110
+ raise RuntimeError(f"OAuth failed: {server._auth_error}")
111
+
112
+ if server._token_data:
113
+ store_tokens(
114
+ access_token=server._token_data["access_token"],
115
+ refresh_token=server._token_data["refresh_token"],
116
+ user_data=server._token_data.get("user"),
117
+ )
118
+ return server._token_data
119
+
120
+ if server._auth_code:
121
+ return await _exchange_code(server._auth_code)
122
+
123
+ raise RuntimeError(f"OAuth timed out after {CALLBACK_TIMEOUT}s. Try again or use: buildwithtrace auth login --email")
124
+
125
+
126
+ async def _exchange_code(code: str) -> dict:
127
+ """Exchange an OAuth code for tokens via the backend."""
128
+ import httpx
129
+ backend_url = get_backend_url()
130
+
131
+ async with httpx.AsyncClient(timeout=30.0) as client:
132
+ resp = await client.post(
133
+ f"{backend_url}/api/v3/auth/exchange-code",
134
+ json={"code": code},
135
+ )
136
+ if resp.status_code != 200:
137
+ raise RuntimeError(f"Code exchange failed: {resp.text}")
138
+
139
+ data = resp.json()
140
+ store_tokens(
141
+ access_token=data["access_token"],
142
+ refresh_token=data["refresh_token"],
143
+ user_data=data.get("user"),
144
+ )
145
+ return data
146
+
147
+
148
+ async def email_password_login(email: str, password: str) -> dict:
149
+ """Login with email and password directly."""
150
+ import httpx
151
+ backend_url = get_backend_url()
152
+
153
+ async with httpx.AsyncClient(timeout=30.0) as client:
154
+ resp = await client.post(
155
+ f"{backend_url}/api/v3/auth/login",
156
+ json={"email": email, "password": password},
157
+ )
158
+ if resp.status_code != 200:
159
+ body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
160
+ raise RuntimeError(body.get("detail", f"Login failed: {resp.status_code}"))
161
+
162
+ data = resp.json()
163
+ store_tokens(
164
+ access_token=data["access_token"],
165
+ refresh_token=data["refresh_token"],
166
+ user_data=data.get("user"),
167
+ )
168
+ return data
169
+
170
+
171
+ def _cli_platform() -> str:
172
+ """Map the OS to the backend's platform vocabulary (macos|windows|linux)."""
173
+ import platform as _p
174
+ system = _p.system()
175
+ if system == "Darwin":
176
+ return "macos"
177
+ if system == "Windows":
178
+ return "windows"
179
+ if system == "Linux":
180
+ return "linux"
181
+ return system.lower() or "unknown"
182
+
183
+
184
+ def _cli_version() -> str:
185
+ """Reported app version (best-effort).
186
+
187
+ Prefers the installed `buildwithtrace` CLI dist (when this SDK runs inside the
188
+ CLI wheel), then falls back to this SDK's own version, then "unknown". After
189
+ the SDK-as-core split this code ships in `buildwithtrace-sdk`, so the bare
190
+ `buildwithtrace` lookup alone would report "unknown" when used standalone.
191
+ """
192
+ from importlib.metadata import version
193
+
194
+ for dist in ("buildwithtrace", "buildwithtrace-sdk"):
195
+ try:
196
+ return version(dist)
197
+ except Exception:
198
+ continue
199
+ try:
200
+ from buildwithtrace_sdk import __version__
201
+ return __version__
202
+ except Exception:
203
+ return "unknown"
204
+
205
+
206
+ async def register_device() -> None:
207
+ """Link this CLI install to the authenticated account (best-effort).
208
+
209
+ Sends the stable device_id + platform/version so the backend can record the
210
+ device and attempt to attribute a prior anonymous download to it. Never
211
+ raises -- linking is a nice-to-have, not part of the login contract.
212
+ """
213
+ import platform as _p
214
+
215
+ from buildwithtrace_sdk.api import post
216
+ from buildwithtrace_sdk.config import get_or_create_device_id
217
+
218
+ try:
219
+ await post(
220
+ "/auth/register-device",
221
+ json={
222
+ "device_id": get_or_create_device_id(),
223
+ "device_platform": _cli_platform(),
224
+ "os_version": _p.platform(),
225
+ "app_version": _cli_version(),
226
+ "linked_via": "cli",
227
+ },
228
+ )
229
+ except Exception as e: # noqa: BLE001 - best-effort, must never break login
230
+ logger.debug("CLI device registration failed: %s", e)
231
+
232
+
233
+ async def token_login(token: str) -> dict:
234
+ """Login with a pre-existing API token (for CI)."""
235
+ import httpx
236
+ backend_url = get_backend_url()
237
+
238
+ async with httpx.AsyncClient(timeout=30.0) as client:
239
+ resp = await client.get(
240
+ f"{backend_url}/api/v3/auth/verify",
241
+ headers={"Authorization": f"Bearer {token}"},
242
+ )
243
+ if resp.status_code != 200:
244
+ raise RuntimeError("Token verification failed")
245
+
246
+ data = resp.json()
247
+ if not data.get("valid"):
248
+ raise RuntimeError("Invalid or expired token")
249
+
250
+ store_tokens(
251
+ access_token=token,
252
+ refresh_token="",
253
+ user_data={
254
+ "id": data.get("user_id"),
255
+ "email": data.get("email"),
256
+ "full_name": data.get("full_name"),
257
+ },
258
+ )
259
+ return data
@@ -0,0 +1,103 @@
1
+ """Synchronous (non-SSE) REST calls to the Trace backend.
2
+
3
+ Used by the generation commands + MCP tools, which target the synchronous REST shim
4
+ `POST /api/<version>/components/generate/{symbol,footprint}` (version from
5
+ get_api_base_url(), default "latest") instead of the agent SSE stream — the agent's
6
+ `generate_component` tool is gated to the newest API version, and the REST shim is the
7
+ stable, version-independent path the CLI/SDK use for generation.
8
+ """
9
+
10
+ import httpx
11
+
12
+ from buildwithtrace_sdk.api.sse import _actionable_error
13
+ from buildwithtrace_sdk.config import get_api_base_url
14
+ from buildwithtrace_sdk.config.credentials import get_access_token, refresh_token
15
+
16
+ # Server-side budget: datasheet parse (up to 420s) + generation (up to 180s). Give the
17
+ # client generous headroom over that so we surface the server's own timeout, not ours.
18
+ GENERATE_TIMEOUT = 660.0
19
+
20
+
21
+ class RestError(Exception):
22
+ """A non-streaming REST call failed. `message` is already user-actionable."""
23
+
24
+ def __init__(self, message: str, status_code: int | None = None):
25
+ super().__init__(message)
26
+ self.status_code = status_code
27
+
28
+
29
+ async def post_json(path: str, body: dict, timeout: float = 60.0) -> dict:
30
+ """POST JSON to `{api_base}{path}` with auth; refresh once on 401. Returns parsed JSON.
31
+
32
+ Raises RestError (with an actionable message) on any failure.
33
+ """
34
+ token = get_access_token()
35
+ if not token:
36
+ raise RestError("Not authenticated.\n Fix: Run `buildwithtrace auth login`", 401)
37
+
38
+ url = f"{get_api_base_url()}{path}"
39
+ headers = {
40
+ "Authorization": f"Bearer {token}",
41
+ "Content-Type": "application/json",
42
+ "User-Agent": "trace-cli/0.1.0",
43
+ }
44
+ client_timeout = httpx.Timeout(timeout, connect=10.0)
45
+
46
+ for attempt in range(2): # one retry to refresh an expired token
47
+ try:
48
+ async with httpx.AsyncClient(timeout=client_timeout) as client:
49
+ resp = await client.post(url, json=body, headers=headers)
50
+
51
+ if resp.status_code == 401 and attempt == 0:
52
+ new_token = await refresh_token()
53
+ if new_token:
54
+ headers["Authorization"] = f"Bearer {new_token}"
55
+ continue
56
+ raise RestError(_actionable_error(401, ""), 401)
57
+
58
+ if resp.status_code >= 400:
59
+ body_text = resp.text[:200] if resp.text else ""
60
+ raise RestError(_actionable_error(resp.status_code, body_text), resp.status_code)
61
+
62
+ return resp.json()
63
+ except httpx.ConnectError as e:
64
+ raise RestError(
65
+ "Cannot connect to Trace backend.\n Fix: Check your network, or run `buildwithtrace doctor`."
66
+ ) from e
67
+ except httpx.TimeoutException as e:
68
+ raise RestError(
69
+ f"Request timed out after {int(timeout)}s.\n Fix: Retry; large datasheets can take a while."
70
+ ) from e
71
+
72
+ raise RestError(_actionable_error(401, ""), 401)
73
+
74
+
75
+ async def generate_component(
76
+ kind: str,
77
+ description: str,
78
+ datasheet_url: str | None = None,
79
+ package_type: str | None = None,
80
+ additional_instructions: str | None = None,
81
+ ) -> dict:
82
+ """Generate a symbol or footprint via the synchronous REST shim.
83
+
84
+ Returns the backend result dict (symbol: `kicad_sym`, `name`, ...; footprint:
85
+ `kicad_mod`, `name`, ...). Raises RestError on failure.
86
+ """
87
+ if kind not in ("symbol", "footprint"):
88
+ raise ValueError(f"kind must be 'symbol' or 'footprint', got {kind!r}")
89
+
90
+ # The shim requires description >= 5 chars; pad a bare/short part number.
91
+ desc = description.strip()
92
+ if len(desc) < 5:
93
+ desc = f"{desc} component".strip()
94
+
95
+ body: dict = {"description": desc}
96
+ if datasheet_url:
97
+ body["datasheet_url"] = datasheet_url
98
+ if additional_instructions:
99
+ body["additional_instructions"] = additional_instructions
100
+ if kind == "footprint" and package_type:
101
+ body["package_type"] = package_type
102
+
103
+ return await post_json(f"/components/generate/{kind}", body, timeout=GENERATE_TIMEOUT)