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.
- buildwithtrace_sdk/__init__.py +26 -0
- buildwithtrace_sdk/api/__init__.py +181 -0
- buildwithtrace_sdk/api/auth.py +259 -0
- buildwithtrace_sdk/api/rest.py +103 -0
- buildwithtrace_sdk/api/sse.py +263 -0
- buildwithtrace_sdk/byok.py +61 -0
- buildwithtrace_sdk/client.py +700 -0
- buildwithtrace_sdk/config/__init__.py +149 -0
- buildwithtrace_sdk/config/credentials.py +268 -0
- buildwithtrace_sdk/eda_index.py +132 -0
- buildwithtrace_sdk/engine/__init__.py +368 -0
- buildwithtrace_sdk/engine/downloader.py +327 -0
- buildwithtrace_sdk/error_reporter.py +144 -0
- buildwithtrace_sdk/tools/__init__.py +0 -0
- buildwithtrace_sdk/tools/_concurrency.py +195 -0
- buildwithtrace_sdk/tools/executor.py +963 -0
- buildwithtrace_sdk-0.1.0.dist-info/METADATA +73 -0
- buildwithtrace_sdk-0.1.0.dist-info/RECORD +19 -0
- buildwithtrace_sdk-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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)
|