traceforge-pcb 1.0.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 (219) hide show
  1. traceforge/__init__.py +3 -0
  2. traceforge/__main__.py +3 -0
  3. traceforge/acp_server.py +570 -0
  4. traceforge/agent.py +680 -0
  5. traceforge/altium/__init__.py +6 -0
  6. traceforge/altium/cfb.py +70 -0
  7. traceforge/altium/cli.py +60 -0
  8. traceforge/altium/model.py +163 -0
  9. traceforge/altium/outputs.py +24 -0
  10. traceforge/altium/pcb.py +301 -0
  11. traceforge/altium/project.py +328 -0
  12. traceforge/altium/schematic.py +400 -0
  13. traceforge/altium/tools.py +166 -0
  14. traceforge/approval.py +219 -0
  15. traceforge/attachments.py +346 -0
  16. traceforge/background.py +253 -0
  17. traceforge/bench/__init__.py +5 -0
  18. traceforge/bench/adapters.py +337 -0
  19. traceforge/bench/checks.py +410 -0
  20. traceforge/bench/cli.py +121 -0
  21. traceforge/bench/design.py +439 -0
  22. traceforge/bench/kicad.py +99 -0
  23. traceforge/bench/partscheck.py +159 -0
  24. traceforge/bench/review.py +100 -0
  25. traceforge/bench/runner.py +226 -0
  26. traceforge/bench/score.py +204 -0
  27. traceforge/bench/tasks.py +102 -0
  28. traceforge/chat.py +1185 -0
  29. traceforge/checks.py +328 -0
  30. traceforge/checks_sides.py +118 -0
  31. traceforge/cli.py +2224 -0
  32. traceforge/clipboard.py +107 -0
  33. traceforge/config.py +299 -0
  34. traceforge/context.py +276 -0
  35. traceforge/design/__init__.py +1 -0
  36. traceforge/design/constraints.py +397 -0
  37. traceforge/design/generate.py +1789 -0
  38. traceforge/design/outline.py +905 -0
  39. traceforge/design/schematic_layout.py +932 -0
  40. traceforge/design/schematic_lint.py +329 -0
  41. traceforge/design/schematic_route.py +207 -0
  42. traceforge/design/sheets.py +854 -0
  43. traceforge/design/sides.py +328 -0
  44. traceforge/design/spec.py +462 -0
  45. traceforge/design/specsheet.py +2351 -0
  46. traceforge/design/specsheet_render.py +183 -0
  47. traceforge/desktop/__init__.py +109 -0
  48. traceforge/desktop/keys.py +90 -0
  49. traceforge/desktop/project.py +443 -0
  50. traceforge/desktop/review.py +20 -0
  51. traceforge/desktop/server.py +1277 -0
  52. traceforge/desktop/static/app.css +223 -0
  53. traceforge/desktop/static/app.js +683 -0
  54. traceforge/desktop/static/highlight.js +77 -0
  55. traceforge/desktop/static/index.html +89 -0
  56. traceforge/desktop/static/md.js +90 -0
  57. traceforge/desktop/static/viewer.js +213 -0
  58. traceforge/desktop/symbols.py +212 -0
  59. traceforge/desktop/update.py +65 -0
  60. traceforge/diagnostics.py +67 -0
  61. traceforge/errata.py +219 -0
  62. traceforge/fab.py +108 -0
  63. traceforge/fab_layers.py +44 -0
  64. traceforge/features/__init__.py +6 -0
  65. traceforge/features/candidates.py +117 -0
  66. traceforge/features/cli.py +200 -0
  67. traceforge/features/package.py +433 -0
  68. traceforge/features/revdiff.py +224 -0
  69. traceforge/features/sarif.py +145 -0
  70. traceforge/features/viewer.py +265 -0
  71. traceforge/firmware/__init__.py +3 -0
  72. traceforge/firmware/cli.py +174 -0
  73. traceforge/firmware/drivers.py +372 -0
  74. traceforge/firmware/drivers_data.py +256 -0
  75. traceforge/firmware/mcus.py +177 -0
  76. traceforge/firmware/netlist.py +55 -0
  77. traceforge/firmware/pinmap.py +296 -0
  78. traceforge/firmware/scaffold.py +466 -0
  79. traceforge/firmware/toolchain.py +262 -0
  80. traceforge/hooks.py +237 -0
  81. traceforge/kicad/__init__.py +0 -0
  82. traceforge/kicad/cli.py +519 -0
  83. traceforge/kicad/diffpair.py +174 -0
  84. traceforge/kicad/edgegeom.py +191 -0
  85. traceforge/kicad/edit.py +405 -0
  86. traceforge/kicad/escape.py +1410 -0
  87. traceforge/kicad/formats.py +1073 -0
  88. traceforge/kicad/impedance.py +320 -0
  89. traceforge/kicad/install.py +75 -0
  90. traceforge/kicad/libraries.py +490 -0
  91. traceforge/kicad/models3d.py +942 -0
  92. traceforge/kicad/outputs.py +81 -0
  93. traceforge/kicad/pcb.py +787 -0
  94. traceforge/kicad/planes.py +1242 -0
  95. traceforge/kicad/project.py +136 -0
  96. traceforge/kicad/route.py +2698 -0
  97. traceforge/kicad/routegeom.py +164 -0
  98. traceforge/kicad/schematic.py +268 -0
  99. traceforge/kicad/sides.py +34 -0
  100. traceforge/kicad/stackup.py +424 -0
  101. traceforge/kicad_plugin/__init__.py +12 -0
  102. traceforge/kicad_plugin/actions.py +92 -0
  103. traceforge/kicad_plugin/cli.py +66 -0
  104. traceforge/kicad_plugin/client.py +86 -0
  105. traceforge/kicad_plugin/context.py +257 -0
  106. traceforge/kicad_plugin/discovery.py +81 -0
  107. traceforge/kicad_plugin/install.py +203 -0
  108. traceforge/kicad_plugin/overlay.py +60 -0
  109. traceforge/kicad_plugin/payload/action_plugin.py +65 -0
  110. traceforge/kicad_plugin/payload/ipc_main.py +42 -0
  111. traceforge/kicad_plugin/payload/plugin.json +90 -0
  112. traceforge/kicad_plugin/payload/requirements.txt +1 -0
  113. traceforge/kicad_plugin/views.py +202 -0
  114. traceforge/langchain.py +57 -0
  115. traceforge/local.py +580 -0
  116. traceforge/mcp_server.py +304 -0
  117. traceforge/models.py +310 -0
  118. traceforge/next_steps.py +110 -0
  119. traceforge/parts/__init__.py +52 -0
  120. traceforge/parts/alternates.py +488 -0
  121. traceforge/parts/bom.py +423 -0
  122. traceforge/parts/brief.py +705 -0
  123. traceforge/parts/catalog.py +297 -0
  124. traceforge/parts/cli_sourcing.py +156 -0
  125. traceforge/parts/data/duty_rates.yaml +37 -0
  126. traceforge/parts/data/hs_codes.yaml +103 -0
  127. traceforge/parts/datasheet.py +278 -0
  128. traceforge/parts/fx.py +100 -0
  129. traceforge/parts/model.py +185 -0
  130. traceforge/parts/net.py +187 -0
  131. traceforge/parts/offers.py +46 -0
  132. traceforge/parts/packages.py +397 -0
  133. traceforge/parts/pinout.py +714 -0
  134. traceforge/parts/providers/__init__.py +17 -0
  135. traceforge/parts/providers/base.py +100 -0
  136. traceforge/parts/providers/jlc.py +364 -0
  137. traceforge/parts/providers/keyed.py +661 -0
  138. traceforge/parts/report.py +508 -0
  139. traceforge/parts/supply.py +330 -0
  140. traceforge/parts/verify.py +455 -0
  141. traceforge/parts/web.py +575 -0
  142. traceforge/plans.py +70 -0
  143. traceforge/preferences.py +127 -0
  144. traceforge/prices.py +130 -0
  145. traceforge/prices.yaml +28 -0
  146. traceforge/prompts/__init__.py +54 -0
  147. traceforge/prompts/system.md +103 -0
  148. traceforge/providers.py +764 -0
  149. traceforge/remote/__init__.py +33 -0
  150. traceforge/remote/client.py +422 -0
  151. traceforge/remote/oidc.py +240 -0
  152. traceforge/remote/orgs.py +510 -0
  153. traceforge/remote/server.py +690 -0
  154. traceforge/remote/store.py +100 -0
  155. traceforge/review_changes.py +187 -0
  156. traceforge/routing.py +99 -0
  157. traceforge/runtime.py +435 -0
  158. traceforge/sessions.py +324 -0
  159. traceforge/sexpr.py +279 -0
  160. traceforge/sim/__init__.py +17 -0
  161. traceforge/sim/acmath.py +234 -0
  162. traceforge/sim/circuit.py +499 -0
  163. traceforge/sim/cli.py +162 -0
  164. traceforge/sim/engine.py +172 -0
  165. traceforge/sim/ltspice.py +175 -0
  166. traceforge/sim/measure.py +123 -0
  167. traceforge/sim/models.py +76 -0
  168. traceforge/sim/project.py +218 -0
  169. traceforge/sim/rawfile.py +123 -0
  170. traceforge/sim/results.py +298 -0
  171. traceforge/sim/tune.py +172 -0
  172. traceforge/skill_commands.py +165 -0
  173. traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
  174. traceforge/skill_library/compliance-review/SKILL.md +51 -0
  175. traceforge/skill_library/design-review/SKILL.md +47 -0
  176. traceforge/skill_library/errata-hunt/SKILL.md +30 -0
  177. traceforge/skill_library/firmware/SKILL.md +26 -0
  178. traceforge/skill_library/power-budget/SKILL.md +27 -0
  179. traceforge/skill_library/release-package/SKILL.md +29 -0
  180. traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
  181. traceforge/skill_library/thermal-review/SKILL.md +15 -0
  182. traceforge/skills.py +131 -0
  183. traceforge/thermal.py +557 -0
  184. traceforge/tools/__init__.py +48 -0
  185. traceforge/tools/agent_tools.py +213 -0
  186. traceforge/tools/base.py +303 -0
  187. traceforge/tools/batch.py +155 -0
  188. traceforge/tools/connections.py +245 -0
  189. traceforge/tools/files.py +777 -0
  190. traceforge/tools/firmware_tools.py +218 -0
  191. traceforge/tools/kicad_tools.py +1072 -0
  192. traceforge/tools/models3d_tools.py +141 -0
  193. traceforge/tools/parts_tools.py +473 -0
  194. traceforge/tools/processes.py +246 -0
  195. traceforge/tools/review_tools.py +178 -0
  196. traceforge/tools/sim_tools.py +230 -0
  197. traceforge/tui.py +620 -0
  198. traceforge/updates.py +124 -0
  199. traceforge/validate/__init__.py +77 -0
  200. traceforge/validate/builtins.py +1091 -0
  201. traceforge/validate/circuits.py +1240 -0
  202. traceforge/validate/config.py +410 -0
  203. traceforge/validate/default.yaml +113 -0
  204. traceforge/validate/design.py +191 -0
  205. traceforge/validate/explain.py +112 -0
  206. traceforge/validate/grounding.py +169 -0
  207. traceforge/validate/ipc2221.py +350 -0
  208. traceforge/validate/multilayer.py +805 -0
  209. traceforge/validate/netmodel.py +261 -0
  210. traceforge/validate/partcheck.py +472 -0
  211. traceforge/validate/report.py +280 -0
  212. traceforge/validate/review.py +322 -0
  213. traceforge/validate/runner.py +662 -0
  214. traceforge/workspace.py +171 -0
  215. traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
  216. traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
  217. traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
  218. traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
  219. traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,240 @@
1
+ """Single sign-on for the hosted server through OpenID Connect (authorization code + PKCE).
2
+
3
+ Flow: ``start`` builds the IdP login URL (state, nonce and PKCE verifier are kept in
4
+ memory for ten minutes); the IdP redirects back with a code; ``finish`` exchanges it at the
5
+ token endpoint, validates the ID token with PyJWT against the IdP's JWKS, maps groups to a
6
+ role and returns the claims. The caller turns that into a seat and an API key.
7
+
8
+ ID token checks: asymmetric signature only (``none`` and ``HS*`` are refused, so a
9
+ token cannot be forged with the public key or without a key), key chosen by ``kid`` from
10
+ the JWKS (refetched once when unknown), ``iss`` equals the configured issuer (and the
11
+ discovery document's issuer must match too), ``aud`` contains the client id (``azp`` must
12
+ equal it when there are several), ``exp``/``iat``/``sub`` required with 60 s clock leeway,
13
+ and ``nonce`` equals the one issued for that login.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import hashlib
20
+ import secrets
21
+ import time
22
+ from dataclasses import dataclass, field
23
+ from typing import Any
24
+ from urllib.parse import urlencode
25
+
26
+ import httpx
27
+ import jwt
28
+
29
+ ALLOWED_ALGS = ("RS256", "RS384", "RS512", "ES256", "ES384", "PS256", "EdDSA")
30
+ LEEWAY = 60
31
+ LOGIN_TTL = 600
32
+
33
+
34
+ class OIDCError(Exception):
35
+ def __init__(self, code: str, message: str):
36
+ super().__init__(message)
37
+ self.code = code
38
+ self.message = message
39
+
40
+
41
+ @dataclass
42
+ class OIDCConfig:
43
+ issuer: str
44
+ client_id: str
45
+ redirect_uri: str
46
+ org: str # organisation that SSO users join
47
+ client_secret: str = "" # from the environment, never from a file in a project
48
+ groups_claim: str = "groups"
49
+ group_roles: dict[str, str] = field(default_factory=dict) # IdP group -> admin | member
50
+ default_role: str = "member"
51
+ allowed_domains: list[str] = field(default_factory=list) # empty: any email domain
52
+ auto_provision: bool = True # create a seat on first login (else invite first)
53
+ scopes: str = "openid email profile"
54
+ require_https: bool = True
55
+
56
+ @classmethod
57
+ def from_dict(cls, data: dict[str, Any], client_secret: str = "") -> OIDCConfig:
58
+ known = {k: v for k, v in data.items() if k in cls.__dataclass_fields__}
59
+ return cls(**{**known, "client_secret": client_secret or known.get("client_secret", "")})
60
+
61
+
62
+ def role_for(config: OIDCConfig, claims: dict[str, Any]) -> str:
63
+ """admin when any group maps to admin, else the first mapped role, else the default."""
64
+ groups = claims.get(config.groups_claim) or []
65
+ if isinstance(groups, str):
66
+ groups = [groups]
67
+ roles = {config.group_roles[g] for g in groups if g in config.group_roles}
68
+ if "admin" in roles:
69
+ return "admin"
70
+ return "member" if roles else config.default_role
71
+
72
+
73
+ class OIDCClient:
74
+ def __init__(self, config: OIDCConfig, http: httpx.Client | None = None):
75
+ self.config = config
76
+ self.http = http or httpx.Client(timeout=10, follow_redirects=False)
77
+ self._discovery: dict[str, Any] | None = None
78
+ self._keys: dict[str, Any] = {}
79
+ self._keys_fetched = 0.0
80
+ self._logins: dict[str, dict[str, str]] = {}
81
+
82
+ def _check_url(self, url: str) -> str:
83
+ if self.config.require_https and not url.startswith("https://"):
84
+ local = url.startswith(("http://127.0.0.1", "http://localhost"))
85
+ if not local:
86
+ raise OIDCError("insecure_idp", f"refusing non-HTTPS identity provider URL {url}")
87
+ return url
88
+
89
+ def discover(self) -> dict[str, Any]:
90
+ if self._discovery is None:
91
+ base = self._check_url(self.config.issuer.rstrip("/"))
92
+ try:
93
+ doc = (
94
+ self.http.get(f"{base}/.well-known/openid-configuration")
95
+ .raise_for_status()
96
+ .json()
97
+ )
98
+ except (httpx.HTTPError, ValueError) as exc:
99
+ raise OIDCError("discovery_failed", f"OIDC discovery failed: {exc}") from exc
100
+ if str(doc.get("issuer", "")).rstrip("/") != self.config.issuer.rstrip("/"):
101
+ raise OIDCError(
102
+ "issuer_mismatch",
103
+ "discovery document issuer differs from the configured issuer",
104
+ )
105
+ for key in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
106
+ if not doc.get(key):
107
+ raise OIDCError("discovery_failed", f"discovery document lacks {key}")
108
+ self._check_url(doc[key])
109
+ self._discovery = doc
110
+ return self._discovery
111
+
112
+ def _jwks(self, force: bool = False) -> dict[str, Any]:
113
+ if force or not self._keys or time.time() - self._keys_fetched > 3600:
114
+ if force and time.time() - self._keys_fetched < 10:
115
+ return self._keys # do not let a stream of unknown kids hammer the IdP
116
+ try:
117
+ data = self.http.get(self.discover()["jwks_uri"]).raise_for_status().json()
118
+ except (httpx.HTTPError, ValueError) as exc:
119
+ raise OIDCError(
120
+ "jwks_failed", f"could not fetch the IdP signing keys: {exc}"
121
+ ) from exc
122
+ self._keys = {k["kid"]: k for k in data.get("keys", []) if k.get("kid")}
123
+ self._keys_fetched = time.time()
124
+ return self._keys
125
+
126
+ # ------------------------------------------------------------------ tokens
127
+
128
+ def validate_id_token(self, token: str, nonce: str | None) -> dict[str, Any]:
129
+ try:
130
+ header = jwt.get_unverified_header(token)
131
+ except jwt.PyJWTError as exc:
132
+ raise OIDCError("bad_token", f"malformed ID token: {exc}") from exc
133
+ alg = header.get("alg")
134
+ if alg not in ALLOWED_ALGS:
135
+ raise OIDCError("bad_algorithm", f"ID token algorithm {alg!r} is not accepted")
136
+ kid = header.get("kid")
137
+ jwk = self._jwks().get(kid) or self._jwks(force=True).get(kid)
138
+ if jwk is None:
139
+ raise OIDCError("unknown_key", "ID token is signed with a key the IdP does not publish")
140
+ try:
141
+ key = jwt.PyJWK(jwk).key
142
+ claims = jwt.decode(
143
+ token,
144
+ key,
145
+ algorithms=[alg],
146
+ audience=self.config.client_id,
147
+ issuer=self.config.issuer,
148
+ leeway=LEEWAY,
149
+ options={"require": ["exp", "iat", "iss", "aud", "sub"]},
150
+ )
151
+ except jwt.ExpiredSignatureError as exc:
152
+ raise OIDCError("expired", "ID token has expired") from exc
153
+ except jwt.InvalidAudienceError as exc:
154
+ raise OIDCError("bad_audience", "ID token was issued for a different client") from exc
155
+ except jwt.InvalidIssuerError as exc:
156
+ raise OIDCError("bad_issuer", "ID token has the wrong issuer") from exc
157
+ except jwt.InvalidSignatureError as exc:
158
+ raise OIDCError("bad_signature", "ID token signature does not verify") from exc
159
+ except jwt.PyJWTError as exc:
160
+ raise OIDCError("bad_token", f"invalid ID token: {exc}") from exc
161
+ aud = claims.get("aud")
162
+ if isinstance(aud, list) and len(aud) > 1 and claims.get("azp") != self.config.client_id:
163
+ raise OIDCError("bad_audience", "ID token azp does not match this client")
164
+ if nonce is not None and not secrets.compare_digest(str(claims.get("nonce", "")), nonce):
165
+ raise OIDCError("bad_nonce", "ID token nonce does not match this login")
166
+ return claims
167
+
168
+ # ------------------------------------------------------------------- login
169
+
170
+ def start(self) -> dict[str, str]:
171
+ doc = self.discover()
172
+ state, nonce = secrets.token_urlsafe(24), secrets.token_urlsafe(24)
173
+ verifier = secrets.token_urlsafe(48)
174
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(
175
+ b"="
176
+ )
177
+ now = time.time()
178
+ self._logins = {s: v for s, v in self._logins.items() if now - float(v["t"]) < LOGIN_TTL}
179
+ self._logins[state] = {"nonce": nonce, "verifier": verifier, "t": str(now)}
180
+ query = urlencode(
181
+ {
182
+ "response_type": "code",
183
+ "client_id": self.config.client_id,
184
+ "redirect_uri": self.config.redirect_uri,
185
+ "scope": self.config.scopes,
186
+ "state": state,
187
+ "nonce": nonce,
188
+ "code_challenge": challenge.decode(),
189
+ "code_challenge_method": "S256",
190
+ }
191
+ )
192
+ return {"url": f"{doc['authorization_endpoint']}?{query}", "state": state}
193
+
194
+ def finish(self, code: str, state: str) -> dict[str, Any]:
195
+ """Exchange the code, validate the ID token; returns the claims plus the mapped role."""
196
+ login = self._logins.pop(state, None)
197
+ if login is None or time.time() - float(login["t"]) > LOGIN_TTL:
198
+ raise OIDCError("bad_state", "unknown or expired login; start again")
199
+ data = {
200
+ "grant_type": "authorization_code",
201
+ "code": code,
202
+ "redirect_uri": self.config.redirect_uri,
203
+ "client_id": self.config.client_id,
204
+ "code_verifier": login["verifier"],
205
+ }
206
+ if self.config.client_secret:
207
+ data["client_secret"] = self.config.client_secret
208
+ try:
209
+ response = self.http.post(self.discover()["token_endpoint"], data=data)
210
+ response.raise_for_status()
211
+ token = response.json().get("id_token")
212
+ except (httpx.HTTPError, ValueError) as exc:
213
+ raise OIDCError("token_exchange_failed", f"token exchange failed: {exc}") from exc
214
+ if not token:
215
+ raise OIDCError("token_exchange_failed", "the IdP returned no id_token")
216
+ claims = self.validate_id_token(token, login["nonce"])
217
+ email = str(claims.get("email") or "").lower()
218
+ if not email:
219
+ raise OIDCError("no_email", "the ID token has no email claim (request the email scope)")
220
+ if claims.get("email_verified") is False:
221
+ raise OIDCError("email_unverified", "the IdP says this email address is not verified")
222
+ domains = [d.lower().lstrip("@") for d in self.config.allowed_domains]
223
+ if domains and email.rsplit("@", 1)[-1] not in domains:
224
+ raise OIDCError("domain_not_allowed", f"{email} is not in an allowed email domain")
225
+ return {**claims, "email": email, "role": role_for(self.config, claims)}
226
+
227
+
228
+ def load_oidc_from_env() -> OIDCClient | None:
229
+ """``TRACEFORGE_OIDC_CONFIG`` (a YAML file) plus ``TRACEFORGE_OIDC_CLIENT_SECRET``."""
230
+ import os
231
+ from pathlib import Path
232
+
233
+ import yaml
234
+
235
+ path = os.environ.get("TRACEFORGE_OIDC_CONFIG")
236
+ if not path:
237
+ return None
238
+ data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
239
+ secret = os.environ.get("TRACEFORGE_OIDC_CLIENT_SECRET", "")
240
+ return OIDCClient(OIDCConfig.from_dict(data, secret))