buildwithtrace-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. buildwithtrace_sdk-0.1.0/.github/workflows/ci.yml +29 -0
  2. buildwithtrace_sdk-0.1.0/.github/workflows/release.yml +34 -0
  3. buildwithtrace_sdk-0.1.0/.github/workflows/sync-to-org.yml +24 -0
  4. buildwithtrace_sdk-0.1.0/.gitignore +18 -0
  5. buildwithtrace_sdk-0.1.0/PKG-INFO +73 -0
  6. buildwithtrace_sdk-0.1.0/README.md +50 -0
  7. buildwithtrace_sdk-0.1.0/pyproject.toml +55 -0
  8. buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/__init__.py +26 -0
  9. buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/api/__init__.py +181 -0
  10. buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/api/auth.py +259 -0
  11. buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/api/rest.py +103 -0
  12. buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/api/sse.py +263 -0
  13. buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/byok.py +61 -0
  14. buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/client.py +700 -0
  15. buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/config/__init__.py +149 -0
  16. buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/config/credentials.py +268 -0
  17. buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/eda_index.py +132 -0
  18. buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/engine/__init__.py +368 -0
  19. buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/engine/downloader.py +327 -0
  20. buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/error_reporter.py +144 -0
  21. buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/tools/__init__.py +0 -0
  22. buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/tools/_concurrency.py +195 -0
  23. buildwithtrace_sdk-0.1.0/src/buildwithtrace_sdk/tools/executor.py +963 -0
  24. buildwithtrace_sdk-0.1.0/tests/test_api.py +326 -0
  25. buildwithtrace_sdk-0.1.0/tests/test_byok.py +219 -0
  26. buildwithtrace_sdk-0.1.0/tests/test_concurrency.py +348 -0
  27. buildwithtrace_sdk-0.1.0/tests/test_config.py +174 -0
  28. buildwithtrace_sdk-0.1.0/tests/test_config_internals.py +345 -0
  29. buildwithtrace_sdk-0.1.0/tests/test_engine.py +371 -0
  30. buildwithtrace_sdk-0.1.0/tests/test_engine_edge_cases.py +244 -0
  31. buildwithtrace_sdk-0.1.0/tests/test_error_reporter.py +66 -0
  32. buildwithtrace_sdk-0.1.0/tests/test_sdk.py +312 -0
  33. buildwithtrace_sdk-0.1.0/tests/test_streaming_edge_cases.py +330 -0
  34. buildwithtrace_sdk-0.1.0/tests/test_tool_loop.py +163 -0
  35. buildwithtrace_sdk-0.1.0/tests/test_tools.py +313 -0
  36. buildwithtrace_sdk-0.1.0/tests/test_tools_security.py +358 -0
@@ -0,0 +1,29 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ if: github.repository_owner == 'elcruzo' || github.repository_owner == 'buildwithtrace'
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ['3.10', '3.11', '3.12']
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+
22
+ - name: Install
23
+ run: pip install -e ".[dev]"
24
+
25
+ - name: Test
26
+ run: pytest -q
27
+
28
+ - name: Lint
29
+ run: ruff check src/
@@ -0,0 +1,34 @@
1
+ name: Release
2
+
3
+ # Publishes buildwithtrace-sdk to PyPI on a version tag via OIDC Trusted
4
+ # Publishing — NO PyPI token stored. Requires a Trusted Publisher on PyPI for
5
+ # this repo + workflow (pypi.org -> your project -> Publishing, or a "pending
6
+ # publisher" before the first release). Tag: `git tag v0.1.0 && git push origin v0.1.0`.
7
+ on:
8
+ push:
9
+ tags:
10
+ - 'v*'
11
+
12
+ permissions:
13
+ id-token: write # OIDC token for PyPI Trusted Publishing
14
+ contents: read
15
+
16
+ jobs:
17
+ publish:
18
+ runs-on: ubuntu-latest
19
+ environment: pypi
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - uses: actions/setup-python@v5
24
+ with:
25
+ python-version: '3.12'
26
+
27
+ - name: Build
28
+ run: |
29
+ pip install build
30
+ python -m build
31
+
32
+ # No password/token: authenticates via the PyPI OIDC trusted publisher.
33
+ - name: Publish to PyPI (OIDC)
34
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,24 @@
1
+ # Syncs elcruzo/trace-sdk-python -> buildwithtrace/sdk-python on push to main.
2
+ name: Sync to Organization
3
+
4
+ on:
5
+ push:
6
+ branches: [main]
7
+
8
+ jobs:
9
+ sync:
10
+ if: github.repository_owner == 'elcruzo'
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ with:
15
+ fetch-depth: 0
16
+ token: ${{ secrets.ORG_PUSH_TOKEN }}
17
+ persist-credentials: false
18
+
19
+ - name: Push to org repo
20
+ env:
21
+ ORG_PUSH_TOKEN: ${{ secrets.ORG_PUSH_TOKEN }}
22
+ run: |
23
+ git remote add org https://x-access-token:${ORG_PUSH_TOKEN}@github.com/buildwithtrace/sdk-python.git
24
+ git push org HEAD:main
@@ -0,0 +1,18 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .venv/
10
+ venv/
11
+ .DS_Store
12
+
13
+ # Local secrets — never commit (PyPI recovery codes, tokens, etc.)
14
+ .secrets/
15
+
16
+ # local dev-only (not published)
17
+ publish.sh
18
+ AGENTS.md
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: buildwithtrace-sdk
3
+ Version: 0.1.0
4
+ Summary: Trace Python SDK — programmatic client for the Trace AI EDA backend.
5
+ Project-URL: Homepage, https://buildwithtrace.com
6
+ Project-URL: Repository, https://github.com/buildwithtrace/sdk-python
7
+ Author-email: Trace <hello@buildwithtrace.com>
8
+ License: MIT
9
+ Keywords: ai,eda,kicad,pcb,sdk,trace
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: httpx-sse>=0.4.0
12
+ Requires-Dist: httpx>=0.27.0
13
+ Requires-Dist: keyring>=25.0
14
+ Requires-Dist: platformdirs>=4.0
15
+ Requires-Dist: rich>=13.0
16
+ Requires-Dist: tomli-w>=1.0
17
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
20
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
21
+ Requires-Dist: ruff>=0.6.0; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # buildwithtrace-sdk
25
+
26
+ Python SDK for [Trace](https://buildwithtrace.com) — programmatic access to the Trace AI EDA backend (symbol/footprint generation, semantic component search, and chat/agent over your KiCad designs).
27
+
28
+ ```bash
29
+ pip install buildwithtrace-sdk
30
+ ```
31
+
32
+ ```python
33
+ from buildwithtrace_sdk import Trace
34
+
35
+ client = Trace(api_key="...") # or TRACE_API_KEY env var
36
+
37
+ sym = client.generate_symbol("LM7805 5V voltage regulator")
38
+ sym.save("./symbols/") # writes LM7805.kicad_sym
39
+
40
+ fp = client.generate_footprint("SOIC-8 3.9x4.9mm")
41
+ fp.save("./footprints/") # writes SOIC-8.kicad_mod
42
+
43
+ for r in client.search("3.3V LDO", type="symbol", limit=5):
44
+ print(r.name, r.library)
45
+
46
+ # Read-only Q&A about a design:
47
+ ans = client.ask("What ERC violations exist?", project_dir="./my-board/")
48
+ print(ans.text)
49
+
50
+ # Agent mode — the SDK runs the client-side tool loop locally (sandboxed):
51
+ res = client.chat("Add a 100nF decoupling cap on VCC", mode="agent", project_dir="./my-board/")
52
+ print(res.text)
53
+ ```
54
+
55
+ Get an API key: `buildwithtrace auth token` (CLI) or buildwithtrace.com/dashboard/settings > Developer.
56
+
57
+ ## Notes
58
+
59
+ - `chat(mode="agent")` / `"plan"` produce file-editing tool calls. The SDK runs a
60
+ standalone, client-side tool-execution loop: when you pass `project_dir`, file ops
61
+ (read/write/search_replace/list_dir/grep/delete) and local ERC/DRC/export run on your
62
+ machine with the SAME safety as the desktop/CLI (extension allowlist, project-dir
63
+ sandbox, symlink-safe path resolution), looping until the turn completes. Writes are
64
+ auto-approved (programmatic agent). Without a `project_dir`, an emitted tool call
65
+ raises `TraceToolExecutionError` (file ops can't be sandboxed); GUI-only tools
66
+ (canvas snapshots, layer toggles) are always reported as unsupported.
67
+ - `.trace_sch`/`.trace_pcb` writes are converted to KiCad format via the bundled
68
+ converter when available; if the converter isn't importable the file is still written
69
+ and the turn continues (conversion is reported as failed, not fatal).
70
+ - BYOK supported via `chat(..., llm_provider=, llm_api_key=, llm_model_id=)`.
71
+
72
+ Extracted from the `buildwithtrace` CLI repo. The CLI re-exports `Trace` for
73
+ backward compatibility (`from buildwithtrace import Trace`).
@@ -0,0 +1,50 @@
1
+ # buildwithtrace-sdk
2
+
3
+ Python SDK for [Trace](https://buildwithtrace.com) — programmatic access to the Trace AI EDA backend (symbol/footprint generation, semantic component search, and chat/agent over your KiCad designs).
4
+
5
+ ```bash
6
+ pip install buildwithtrace-sdk
7
+ ```
8
+
9
+ ```python
10
+ from buildwithtrace_sdk import Trace
11
+
12
+ client = Trace(api_key="...") # or TRACE_API_KEY env var
13
+
14
+ sym = client.generate_symbol("LM7805 5V voltage regulator")
15
+ sym.save("./symbols/") # writes LM7805.kicad_sym
16
+
17
+ fp = client.generate_footprint("SOIC-8 3.9x4.9mm")
18
+ fp.save("./footprints/") # writes SOIC-8.kicad_mod
19
+
20
+ for r in client.search("3.3V LDO", type="symbol", limit=5):
21
+ print(r.name, r.library)
22
+
23
+ # Read-only Q&A about a design:
24
+ ans = client.ask("What ERC violations exist?", project_dir="./my-board/")
25
+ print(ans.text)
26
+
27
+ # Agent mode — the SDK runs the client-side tool loop locally (sandboxed):
28
+ res = client.chat("Add a 100nF decoupling cap on VCC", mode="agent", project_dir="./my-board/")
29
+ print(res.text)
30
+ ```
31
+
32
+ Get an API key: `buildwithtrace auth token` (CLI) or buildwithtrace.com/dashboard/settings > Developer.
33
+
34
+ ## Notes
35
+
36
+ - `chat(mode="agent")` / `"plan"` produce file-editing tool calls. The SDK runs a
37
+ standalone, client-side tool-execution loop: when you pass `project_dir`, file ops
38
+ (read/write/search_replace/list_dir/grep/delete) and local ERC/DRC/export run on your
39
+ machine with the SAME safety as the desktop/CLI (extension allowlist, project-dir
40
+ sandbox, symlink-safe path resolution), looping until the turn completes. Writes are
41
+ auto-approved (programmatic agent). Without a `project_dir`, an emitted tool call
42
+ raises `TraceToolExecutionError` (file ops can't be sandboxed); GUI-only tools
43
+ (canvas snapshots, layer toggles) are always reported as unsupported.
44
+ - `.trace_sch`/`.trace_pcb` writes are converted to KiCad format via the bundled
45
+ converter when available; if the converter isn't importable the file is still written
46
+ and the turn continues (conversion is reported as failed, not fatal).
47
+ - BYOK supported via `chat(..., llm_provider=, llm_api_key=, llm_model_id=)`.
48
+
49
+ Extracted from the `buildwithtrace` CLI repo. The CLI re-exports `Trace` for
50
+ backward compatibility (`from buildwithtrace import Trace`).
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "buildwithtrace-sdk"
7
+ dynamic = ["version"]
8
+ description = "Trace Python SDK — programmatic client for the Trace AI EDA backend."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Trace", email = "hello@buildwithtrace.com" }]
13
+ keywords = ["trace", "eda", "pcb", "kicad", "sdk", "ai"]
14
+ dependencies = [
15
+ "httpx>=0.27.0",
16
+ "httpx-sse>=0.4.0",
17
+ "keyring>=25.0",
18
+ "platformdirs>=4.0",
19
+ "tomli-w>=1.0",
20
+ "tomli>=2.0; python_version < '3.11'",
21
+ # rich is used only by the engine binary downloader's progress UI (lazy import).
22
+ "rich>=13.0",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "pytest>=8.0.0",
28
+ "pytest-asyncio>=0.23",
29
+ "ruff>=0.6.0",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://buildwithtrace.com"
34
+ Repository = "https://github.com/buildwithtrace/sdk-python"
35
+
36
+ # Single source of truth for the version: read __version__ from the package.
37
+ [tool.hatch.version]
38
+ path = "src/buildwithtrace_sdk/__init__.py"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/buildwithtrace_sdk"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+ asyncio_mode = "auto"
46
+
47
+ [tool.ruff]
48
+ line-length = 120
49
+ target-version = "py310"
50
+
51
+ [tool.ruff.lint]
52
+ select = ["E", "F", "I", "N", "W"]
53
+ # E501 (line length) is advisory — many lines are long error/URL strings we
54
+ # intentionally don't wrap. All correctness rules (F*, etc.) stay on.
55
+ ignore = ["E501"]
@@ -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)