ecadlib 0.3.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 (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/ui/server.py ADDED
@@ -0,0 +1,1029 @@
1
+ """Web UI: drag in a BOM, review the triage, generate and approve footprints, export to Altium.
2
+
3
+ ecadlib ui --lib path/to/kicad/libs [--workspace ecadlib_workspace] [--port 8765] [--offline]
4
+ (or: python -m ecadlib.ui ...)
5
+
6
+ Local mode (default): standard library only (http.server), bound to 127.0.0.1; a single-user desktop tool, provider
7
+ API keys stay in the user's environment.
8
+
9
+ Team mode (`--team`): one server for the whole team with sign-in (ecadlib/team.py): users and roles in
10
+ team_users.json, session cookies, a CSRF token on every POST, a minimum role per route (ROUTES), approvals signed as
11
+ the signed-in user, every write in team_audit.jsonl. A non-loopback address needs --cert/--key (built-in TLS) or an
12
+ explicit --allow-http (behind an HTTPS reverse proxy on another host)."""
13
+ import argparse, contextlib, hmac, io, http.cookies, ipaddress, itertools, json, mimetypes, re, secrets, socket, ssl, sys, \
14
+ threading, os, time, traceback, webbrowser
15
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
16
+ from pathlib import Path
17
+ from urllib.parse import parse_qs, quote, unquote, urlparse
18
+
19
+ from .. import agent_queue as AQ, hub as HUB, team as TM
20
+ from ..app import Workspace
21
+ from .. import easy_setup as ES
22
+
23
+ STATIC = Path(__file__).with_name("static")
24
+ MAX_UPLOAD = 20 * 1024 * 1024
25
+ MAX_JSON = 2 * 1024 * 1024 # JSON bodies (sign-in is unauthenticated: no 20 MB parse for anyone)
26
+ EXPECTED = (ValueError, KeyError, FileExistsError, json.JSONDecodeError, PermissionError) # user errors: no traceback
27
+ COOKIE = "ecadlib_session"
28
+
29
+ # Minimum team role per route. Team mode answers only the routes listed here (anything else is 404), so a new endpoint
30
+ # stays unreachable until it gets a role; tests/test_team.py checks every "/api/..." in this file is listed.
31
+ PUBLIC = "public"
32
+ ROUTES = {
33
+ ("GET", "/"): PUBLIC, ("GET", "/index.html"): PUBLIC, ("GET", "/hairline.js"): PUBLIC,
34
+ ("GET", "/api/session"): PUBLIC, ("POST", "/api/login"): PUBLIC,
35
+ ("POST", "/api/logout"): "viewer", ("POST", "/api/account/password"): "viewer",
36
+ ("GET", "/api/info"): "viewer", ("GET", "/api/specs"): "viewer", ("GET", "/api/rules"): "viewer",
37
+ ("GET", "/api/job"): "viewer", ("GET", "/api/file"): "viewer",
38
+ ("POST", "/api/job/cancel"): "viewer", # only the job's owner (or an admin) can see it to stop it
39
+ ("GET", "/api/parts"): "viewer", # a BOM's library plan as part cards (ecadlib.partview)
40
+ ("POST", "/api/demo"): "librarian", # first visit: the bundled sample BOM, checked and filled
41
+ ("GET", "/api/status"): "viewer", # workspace summary (Status card)
42
+ ("GET", "/api/doctor"): "admin", # setup check: tool paths, which API key names are set
43
+ ("GET", "/api/triage/history"): "viewer", ("GET", "/api/triage/result"): "viewer", # finished results: library data
44
+ ("POST", "/api/footprint/preview"): "viewer", ("POST", "/api/approvals/status"): "viewer",
45
+ ("POST", "/api/resolve/held"): "viewer",
46
+ ("POST", "/api/upload"): "librarian", ("POST", "/api/triage"): "librarian",
47
+ ("POST", "/api/librarian/run"): "librarian", ("POST", "/api/resolve/candidates"): "librarian",
48
+ ("POST", "/api/resolve/choose"): "librarian", ("POST", "/api/footprint/approve"): "librarian",
49
+ ("POST", "/api/export/dblib"): "librarian", ("POST", "/api/altium/models"): "librarian",
50
+ ("POST", "/api/export/tool"): "librarian", # Fusion Electronics / EAGLE .lbr, EasyEDA Pro .elibz
51
+ ("POST", "/api/altium365/plan"): "librarian", ("POST", "/api/altium365/workspaces"): "librarian",
52
+ ("POST", "/api/altium365/push"): "librarian", # dry run; a live push needs LIVE_PUSH
53
+ ("POST", "/api/release-check"): "librarian", ("POST", "/api/dedupe"): "librarian",
54
+ ("POST", "/api/approvals/decide"): "approver",
55
+ ("GET", "/api/agent/queue"): "viewer", ("GET", "/api/agent/queue/item"): "viewer",
56
+ ("GET", "/api/agent/queue/picture"): "viewer", # images under <workspace>/agent/ only
57
+ ("POST", "/api/agent/queue/rebuild"): "librarian", ("POST", "/api/agent/queue/reject"): "librarian",
58
+ ("POST", "/api/agent/queue/approve"): "approver",
59
+ ("POST", "/api/rules/propose"): "admin", # reads any folder on the server
60
+ ("POST", "/api/rules/save"): "admin",
61
+ ("GET", "/api/hub"): "viewer", ("GET", "/api/hub/part"): "viewer", # library hub: every part across tools
62
+ ("POST", "/api/hub/fix"): "librarian", ("POST", "/api/hub/copy"): "librarian",
63
+ ("GET", "/api/style"): "viewer", # the learned house style (ecadlib.house_style)
64
+ ("POST", "/api/style/learn"): "admin", # reads library folders; may call the configured model
65
+ ("POST", "/api/style/direction"): "admin",
66
+ ("GET", "/api/team/users"): "admin", ("POST", "/api/team/users"): "admin", ("GET", "/api/team/audit"): "admin",
67
+ }
68
+ LIVE_PUSH = "approver"
69
+ TEAM_ONLY = {"/api/login", "/api/logout", "/api/account/password", "/api/team/users", "/api/team/audit"}
70
+ AUDIT_KEYS = ("bom", "part", "decision", "dry_run", "workspace", "kind", "id", "merge", "overwrite", "allow", "unallow",
71
+ "action", "username", "role", "note")
72
+ TEAM_HEADERS = {"X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Referrer-Policy": "same-origin",
73
+ "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' "
74
+ "'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; "
75
+ "form-action 'self'; frame-ancestors 'none'"}
76
+
77
+
78
+ class Refused(Exception):
79
+ """403 with a message (team mode: role too low for this variant of a route)."""
80
+
81
+
82
+ class Cancelled(Exception):
83
+ """Raised inside a job's progress callback once someone pressed Stop (checked between lines / parts)."""
84
+
85
+
86
+ class Jobs:
87
+ """Long actions (triage looks parts up online, about a second per request) run in a thread; the page polls.
88
+ Each job remembers who started it (team mode: only they and admins see it) and an optional key: a second job
89
+ with the same key, e.g. the librarian on one BOM, is refused while the first runs. A job keeps a short list of
90
+ per-part lines ({key, n, total, name, state, text}) for the live progress list, and can be stopped (cancel)."""
91
+ MAX_LINES = 400
92
+
93
+ def __init__(self, root=None):
94
+ self.jobs, self.ids, self.lock = {}, itertools.count(1), threading.Lock()
95
+ self.root = root
96
+
97
+ def start(self, fn, *args, label="", owner=None, key=None):
98
+ with self.lock:
99
+ busy = key and self.running(key)
100
+ if busy:
101
+ raise FileExistsError(f"already running for {key.split(':', 1)[-1]}"
102
+ + (f" (started by {busy['owner']})" if busy["owner"] else "") + "; wait for it to finish")
103
+ jid = f"{next(self.ids)}-{secrets.token_hex(6)}"
104
+ job = {"id": jid, "state": "running", "done": 0, "total": 0, "label": label, "result": None, "error": None,
105
+ "owner": owner, "key": key, "lines": [], "cancel": False, "started": time.time()}
106
+ self.jobs[jid] = job
107
+ for old in [k for k in self.jobs if self.jobs[k]["state"] != "running"][:-20]: # keep the last 20 finished
108
+ del self.jobs[old]
109
+
110
+ def progress(done, total, lab):
111
+ if job["cancel"]:
112
+ raise Cancelled()
113
+ job.update(done=done, total=total, label=lab)
114
+
115
+ def line(key, **kw):
116
+ """Add or update one line of the live list (by key, e.g. the part name)."""
117
+ with self.lock:
118
+ cur = next((x for x in job["lines"] if x["key"] == key), None)
119
+ if cur is None:
120
+ if len(job["lines"]) >= self.MAX_LINES:
121
+ return
122
+ cur = {"key": key, "n": None, "total": None, "name": key, "state": "working", "text": ""}
123
+ job["lines"].append(cur)
124
+ cur.update({k: v for k, v in kw.items() if v is not None})
125
+ progress.line = line
126
+ progress.sort = lambda: job["lines"].sort(key=lambda x: (x["n"] is not None, x["n"] or 0))
127
+ progress.cancelled = lambda: job["cancel"]
128
+
129
+ def run():
130
+ try:
131
+ job["result"] = fn(*args, progress=progress)
132
+ job["state"] = "done"
133
+ except Cancelled:
134
+ job["state"] = "cancelled"
135
+ except Exception as e:
136
+ if not isinstance(e, EXPECTED):
137
+ traceback.print_exc()
138
+ job["error"], job["state"] = _msg(e, self.root), "error"
139
+ job["seconds"] = round(time.time() - job["started"], 1)
140
+ threading.Thread(target=run, daemon=True).start()
141
+ return {"job": jid}
142
+
143
+ def running(self, key):
144
+ return next((j for j in list(self.jobs.values()) if j["key"] == key and j["state"] == "running"), None)
145
+
146
+ def get(self, jid, user=None):
147
+ job = self.jobs.get(jid)
148
+ if job and user is not None and job["owner"] != user["username"] and user["role"] != "admin":
149
+ return None # team mode: other people's jobs are invisible
150
+ if not job:
151
+ return None
152
+ out = {k: v for k, v in job.items() if k not in ("key", "lines")}
153
+ out["lines"] = [dict(x) for x in list(job["lines"])]
154
+ out["elapsed"] = job.get("seconds") or round(time.time() - job["started"], 1)
155
+ return out
156
+
157
+ def cancel(self, jid, user=None):
158
+ """Ask a running job to stop at its next line or part. -> the job, or None when unknown / not yours."""
159
+ if self.get(jid, user) is None:
160
+ return None
161
+ job = self.jobs[jid]
162
+ if job["state"] == "running":
163
+ job["cancel"] = True
164
+ return self.get(jid, user)
165
+
166
+
167
+ def _triage_word(status):
168
+ s = str(status or "")
169
+ return ("in your library" if s.startswith("in_library") else "not fitted" if s.startswith("dnp")
170
+ else "unverified" if s.startswith("unverified") else "needs work")
171
+
172
+
173
+ def _line_lines(progress, what=None):
174
+ """Triage progress -> one live line per BOM line ("3/14 CL05B104KO5NNNC checking")."""
175
+ def cb(done, total, label):
176
+ progress(done, total, label)
177
+ if what:
178
+ progress.line("__triage", state="working", text=f"{what}: line {min(done + 1, total)} of {total}")
179
+ elif done < total:
180
+ progress.line(f"line {done + 1}", n=done + 1, total=total, name=label, state="working",
181
+ text=f"{done + 1}/{total} {label} checking")
182
+ cb.line = progress.line
183
+ return cb
184
+
185
+
186
+ _PART = re.compile(r"^\[(\d+)/(\d+)\] (.+)$")
187
+ _DONE = re.compile(r"^(done|open|skip) +(\S.*?) +sym=(\S+) +fp=(\S+)")
188
+
189
+
190
+ def _part_lines(progress):
191
+ """The librarian's log lines -> real part counts and one live line per part ("3/14 SOT-23 ready")."""
192
+ state = {"total": 0, "n": 0, "prev": None}
193
+
194
+ def cb(done, total, label):
195
+ m, d = _PART.match(label or ""), _DONE.match(label or "")
196
+ if m:
197
+ n, tot, name = int(m.group(1)), int(m.group(2)), m.group(3).strip()
198
+ if state["prev"]:
199
+ pn, pname = state["prev"]
200
+ progress.line(pname, state="found", text=f"{pn}/{tot} {pname} looked up")
201
+ state.update(total=tot, n=n, prev=(n, name))
202
+ progress(n - 1, tot, name)
203
+ progress.line(name, n=n, total=tot, name=name, state="working", text=f"{n}/{tot} {name} looking up")
204
+ elif d:
205
+ word = {"done": "found", "open": "still missing a symbol or footprint", "skip": "already done"}[d.group(1)]
206
+ progress.line(d.group(2), name=d.group(2), state="found" if d.group(1) != "open" else "missing",
207
+ text=f"{d.group(2)} {word}")
208
+ if state["total"]:
209
+ progress(state["total"], state["total"], "release check")
210
+ elif not state["total"]: # the log's own wording stays in the log
211
+ progress(0, 0, "finding symbols and footprints" if (label or "").startswith("finding") else "checking the BOM lines")
212
+ else:
213
+ progress(state["n"], state["total"], "checking each part before release")
214
+ cb.line = progress.line
215
+ return cb
216
+
217
+
218
+ def _final_lines(progress, r):
219
+ """After the release check: every part's line says ready / held / waiting for sign-off."""
220
+ from .. import messages as M
221
+ rows = (r.get("review") if isinstance(r, dict) else None) or []
222
+ n = len(rows)
223
+ for i, x in enumerate(rows, 1):
224
+ word = M.status_word(x["status"])
225
+ progress.line(x["part"], n=i, total=n, name=x["part"], state=x["status"], text=f"{i}/{n} {x['part']} {word}")
226
+ progress.sort()
227
+ return r
228
+
229
+
230
+ def _msg(e, root=None):
231
+ m = (e.args[0] if isinstance(e, KeyError) and e.args else str(e)) or type(e).__name__
232
+ if root: # workspace paths as the person sees them: relative
233
+ m = str(m).replace(str(root) + os.sep, "").replace(str(root) + "/", "").replace(str(root), "the workspace")
234
+ from .. import messages as M # absolute paths and module names never reach the page
235
+ m = M.clean(m)
236
+ return (m if isinstance(e, (ValueError, FileExistsError, PermissionError, KeyError))
237
+ else f"Something went wrong ({type(e).__name__}: {m}). The terminal running ecadlib ui shows the details.")[:600]
238
+
239
+
240
+ def _names(v):
241
+ if v is None:
242
+ return []
243
+ if not isinstance(v, list) or not all(isinstance(x, str) for x in v):
244
+ raise ValueError("allow / unallow must be a list of part names")
245
+ return v
246
+
247
+
248
+ def _str(v, what, optional=False):
249
+ """A string field from a JSON body. No identity is ever filled in here: an approval names its user explicitly."""
250
+ if v is None and optional:
251
+ return None
252
+ if not isinstance(v, str) or (not optional and not v.strip()):
253
+ raise ValueError(f"{what} must be a non-empty string")
254
+ return v
255
+
256
+
257
+ def _int(v, default):
258
+ try:
259
+ return int(v)
260
+ except (TypeError, ValueError):
261
+ return default
262
+
263
+
264
+ _LOCAL_WHO = []
265
+
266
+
267
+ def local_who():
268
+ """This computer's user, as approvals records it (ECADLIB_USER, git config, login name); looked up once."""
269
+ if not _LOCAL_WHO:
270
+ from ..approvals import identity
271
+ _LOCAL_WHO.append(identity())
272
+ return _LOCAL_WHO[0]
273
+
274
+
275
+ def _strs(v, what):
276
+ """A list of path strings from a JSON body (scan sources: any folder the person types, read for names only)."""
277
+ if v is None:
278
+ return []
279
+ if not isinstance(v, list) or not all(isinstance(x, str) and len(x) < 1000 and "\0" not in x for x in v):
280
+ raise ValueError(f"{what} must be a list of paths")
281
+ return [x for x in v if x.strip()]
282
+
283
+
284
+ def _team_file(ws, p):
285
+ """team_users.json & co. (password hashes, audit) are never downloadable nor readable as a BOM: also not under
286
+ another letter case (case-insensitive disks: macOS, Windows serve TEAM_USERS.JSON), as their temp files, or
287
+ through another hard link."""
288
+ if p.parent == ws.root and any(p.name.casefold().startswith(f.casefold()) for f in TM.TEAM_FILES):
289
+ return True
290
+ for f in TM.TEAM_FILES:
291
+ with contextlib.suppress(OSError):
292
+ if os.path.samefile(p, ws.root / f):
293
+ return True
294
+ return False
295
+
296
+
297
+ class _DeadlineReader(io.RawIOBase):
298
+ """Socket reads that share one deadline for the whole request (team mode): a client trickling a byte now and
299
+ then would otherwise reset the per-read timeout forever and hold its thread."""
300
+ def __init__(self, sock, deadline):
301
+ self.sock, self.deadline = sock, deadline
302
+
303
+ def readable(self):
304
+ return True
305
+
306
+ def readinto(self, b):
307
+ left = self.deadline - time.monotonic()
308
+ if left <= 0:
309
+ raise TimeoutError("request took too long")
310
+ self.sock.settimeout(left)
311
+ return self.sock.recv_into(b)
312
+
313
+
314
+ def make_handler(ws: Workspace, team=None):
315
+ """team: a team.Team for team mode; None = local mode (unchanged single-user behaviour)."""
316
+ jobs = Jobs(ws.root)
317
+ write_lock = threading.Lock() # one writer at a time in the workspace
318
+
319
+ class H(BaseHTTPRequestHandler):
320
+ server_version = "ecadlib-ui/0.1"
321
+ user = sess = _audit_route = None
322
+ _gated = False
323
+ if team:
324
+ timeout = 120 # a stalled client can't hold a thread forever
325
+
326
+ def setup(self):
327
+ super().setup()
328
+ deadline = time.monotonic() + float(team.cfg["request_seconds"]) if team else None
329
+ if team:
330
+ self.connection.settimeout(min(self.timeout, team.cfg["request_seconds"]))
331
+ if hasattr(self.request, "do_handshake"): # TLS: handshake here, in the request's own thread
332
+ self.request.do_handshake()
333
+ if team: # headers + body within request_seconds, all told
334
+ self.rfile.close() # nothing read yet; drops the makefile's socket ref
335
+ self.rfile = io.BufferedReader(_DeadlineReader(self.connection, deadline))
336
+
337
+ def finish(self):
338
+ if team:
339
+ with contextlib.suppress(OSError): # the answer gets its own (per-write) timeout
340
+ self.connection.settimeout(self.timeout)
341
+ super().finish()
342
+
343
+ def log_message(self, fmt, *args):
344
+ sys.stderr.write("ui: " + fmt % args + "\n")
345
+
346
+ def _send(self, code, body, ctype="application/json", headers=None):
347
+ data = body if isinstance(body, bytes) else (json.dumps(body) if ctype == "application/json" else body).encode()
348
+ self._status = code
349
+ if self._audit_route is not None and (self._gated or code in (401, 403)):
350
+ route, self._audit_route = self._audit_route, None
351
+ self._audit(route) # before the answer: logged by the time the client sees it
352
+ self.send_response(code)
353
+ self.send_header("Content-Type", ctype)
354
+ self.send_header("Content-Length", str(len(data)))
355
+ self.send_header("Cache-Control", "no-store")
356
+ if team:
357
+ for k, v in TEAM_HEADERS.items():
358
+ self.send_header(k, v)
359
+ if team.tls:
360
+ self.send_header("Strict-Transport-Security", "max-age=31536000")
361
+ for k, v in (headers or {}).items():
362
+ self.send_header(k, v)
363
+ self.end_headers()
364
+ self.wfile.write(data)
365
+
366
+ def _host_ok(self):
367
+ """Refuse DNS-rebinding requests: the Host header must name this server's own loopback address
368
+ (team mode: an IP literal, localhost, this machine's name or a configured allowed host)."""
369
+ if team:
370
+ return team.host_ok(self.headers.get("Host"))
371
+ port = self.server.server_address[1]
372
+ names = {"127.0.0.1", "localhost", "[::1]", self.server.server_address[0]}
373
+ return self.headers.get("Host") in {f"{h}:{port}" for h in names}
374
+
375
+ def _origin_ok(self):
376
+ """Stop other sites driving the API: an Origin, when sent, must be this server."""
377
+ origin = self.headers.get("Origin")
378
+ if not team:
379
+ scheme = "https" if getattr(self.server, "tls", False) else "http"
380
+ return origin in (None, f"{scheme}://{self.headers.get('Host')}")
381
+ if self.headers.get("Sec-Fetch-Site") == "cross-site":
382
+ return False
383
+ if origin is None:
384
+ return True
385
+ o = urlparse(origin) # scheme may differ behind an HTTPS proxy; host:port may not
386
+ return o.scheme in ("http", "https") and o.netloc.lower() == str(self.headers.get("Host") or "").lower()
387
+
388
+ # ---------------------------------------------------------- team mode: who is asking
389
+ def _from_proxy(self):
390
+ return self.client_address[0] in team.cfg["trusted_proxies"]
391
+
392
+ def _ip(self):
393
+ """Client address for logs and throttling (the X-Forwarded-For hop when a trusted proxy forwards)."""
394
+ if team and self._from_proxy() and self.headers.get("X-Forwarded-For"):
395
+ return self.headers["X-Forwarded-For"].split(",")[-1].strip()[:64]
396
+ return self.client_address[0]
397
+
398
+ def _secure(self):
399
+ return team.tls or team.cfg["secure_cookies"] or (
400
+ self._from_proxy() and self.headers.get("X-Forwarded-Proto", "").lower() == "https")
401
+
402
+ def _cookie(self):
403
+ c = http.cookies.SimpleCookie()
404
+ try:
405
+ c.load(self.headers.get("Cookie") or "")
406
+ except http.cookies.CookieError:
407
+ return None
408
+ return c[COOKIE].value if COOKIE in c else None
409
+
410
+ def _set_cookie(self, tok, max_age):
411
+ return (f"{COOKIE}={tok}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age}"
412
+ + ("; Secure" if self._secure() else ""))
413
+
414
+ def _identify(self):
415
+ """Sets self.user (users-file record) and self.sess (session dict, or 'proxy')."""
416
+ hdr = team.cfg["proxy_user_header"]
417
+ if team.cfg["trusted_proxies"] and self._from_proxy() and self.headers.get(hdr):
418
+ self.user, self.sess = team.store.find(self.headers[hdr]), "proxy"
419
+ return
420
+ s = team.sessions.get(self._cookie())
421
+ u = s and team.store.get(s["user"])
422
+ if s and (not u or int(u.get("gen") or 1) != s["gen"]): # removed or password reset: signed out
423
+ team.sessions.drop(self._cookie())
424
+ s = u = None
425
+ self.user, self.sess = u, s
426
+
427
+ def _csrf(self):
428
+ if self.sess == "proxy":
429
+ return team.proxy_csrf(self.user["username"])
430
+ return self.sess["csrf"] if self.sess else None
431
+
432
+ def _need(self, role):
433
+ if TM.RANK[self.user["role"]] < TM.RANK[role]:
434
+ raise Refused(f"this needs the {role} role; you are {self.user['role']}")
435
+
436
+ def _gate(self, method):
437
+ """Checks before any route runs. Returns the parsed URL, or None after sending a refusal."""
438
+ u = urlparse(self.path)
439
+ self._status, self._fields = None, {}
440
+ if not self._host_ok():
441
+ return self._send(403, {"error": "unexpected Host header"})
442
+ if method == "POST" and not self._origin_ok():
443
+ return self._send(403, {"error": "cross-origin request refused"})
444
+ if not team:
445
+ return self._send(404, {"error": "not found"}) if u.path in TEAM_ONLY else u
446
+ role = ROUTES.get((method, u.path))
447
+ if role is None:
448
+ return self._send(404, {"error": "not found"})
449
+ self._identify()
450
+ if role == PUBLIC:
451
+ self._gated = True
452
+ return u
453
+ if not self.user:
454
+ if self.sess == "proxy":
455
+ return self._send(403, {"error": "your sign-in name is not a member of this ecadlib team"})
456
+ return self._send(401, {"error": "sign in first"})
457
+ if method == "POST" and not hmac.compare_digest(str(self.headers.get("X-CSRF-Token") or ""), self._csrf() or "-"):
458
+ return self._send(403, {"error": "missing or wrong CSRF token: reload the page"})
459
+ if TM.RANK[self.user["role"]] < TM.RANK[role]:
460
+ return self._send(403, {"error": f"this needs the {role} role; you are {self.user['role']}"})
461
+ self._gated = True
462
+ return u
463
+
464
+ def _audit(self, path):
465
+ if team and self._status is not None:
466
+ team.audit({"user": self.user["username"] if self.user else None, "ip": self._ip(), "route": path,
467
+ "status": self._status, **self._fields})
468
+
469
+ # ---------------------------------------------------------- request bodies
470
+ def _bom(self, body=None, write=False):
471
+ """Web clients may only name BOMs inside the workspace (absolute paths would read any file on disk)."""
472
+ bom = (body if body is not None else self._json()).get("bom")
473
+ if not isinstance(bom, str) or not bom:
474
+ raise ValueError("no BOM given")
475
+ if len(bom) > 1000 or any(len(x) > 200 for x in bom.replace("\\", "/").split("/")):
476
+ raise ValueError("that BOM name is too long; pick a BOM from the list")
477
+ if _team_file(ws, ws.inside(bom)):
478
+ raise ValueError(f"{bom} is not a BOM")
479
+ if write and jobs.running("librarian:" + bom):
480
+ raise FileExistsError(f"the librarian is still running on {bom}; try again when it has finished")
481
+ return bom
482
+
483
+ def _body(self, limit=MAX_UPLOAD):
484
+ n = int(self.headers.get("Content-Length") or 0)
485
+ if n < 0 or n > limit:
486
+ self.close_connection = True
487
+ if 0 < n <= MAX_UPLOAD: # read and drop it (in chunks, never kept), so the client
488
+ try: # gets the 400 instead of a reset while still sending
489
+ while n > 0:
490
+ got = self.rfile.read(min(n, 65536))
491
+ if not got:
492
+ break
493
+ n -= len(got)
494
+ except (OSError, ValueError):
495
+ pass
496
+ raise ValueError("bad or too large Content-Length")
497
+ try:
498
+ data = self.rfile.read(n)
499
+ except TimeoutError:
500
+ self.close_connection = True
501
+ raise ValueError("the request took too long to arrive") from None
502
+ if len(data) < n:
503
+ raise ValueError("the request body was cut short")
504
+ return data
505
+
506
+ def _json(self):
507
+ b = json.loads(self._body(MAX_JSON) or b"{}")
508
+ if not isinstance(b, dict):
509
+ raise ValueError("expected a JSON object")
510
+ self._fields = {k: b[k] for k in AUDIT_KEYS if k in b and isinstance(b[k], (str, bool, int, list))
511
+ and len(json.dumps(b[k])) < 500}
512
+ return b
513
+
514
+ def _owner(self):
515
+ return self.user["username"] if team and self.user else None
516
+
517
+ # ---------------------------------------------------------- routes
518
+ def do_GET(self):
519
+ u = self._gate("GET")
520
+ if u is None:
521
+ return
522
+ q = {k: v[0] for k, v in parse_qs(u.query).items()}
523
+ try:
524
+ if u.path in ("/", "/index.html"):
525
+ return self._send(200, (STATIC / "index.html").read_bytes(), "text/html; charset=utf-8")
526
+ if not team and u.path == "/setup": # setup page: local mode only (ecadlib.easy_setup)
527
+ return self._send(200, (STATIC / "setup.html").read_bytes(), "text/html; charset=utf-8")
528
+ if not team and u.path in ES.GET_ROUTES:
529
+ return self._send(200, ES.GET_ROUTES[u.path](ws.root, q))
530
+ if u.path == "/hairline.js": # the line figures (scripts/build_ui_figures.sh)
531
+ return self._send(200, (STATIC / "hairline.js").read_bytes(), "text/javascript; charset=utf-8")
532
+ if u.path == "/api/session":
533
+ return self._send(200, self._session_info())
534
+ if u.path == "/api/info":
535
+ return self._send(200, ws.info())
536
+ if u.path == "/api/specs":
537
+ return self._send(200, ws.example_specs())
538
+ if u.path == "/api/rules":
539
+ return self._send(200, ws.rules_info())
540
+ if u.path == "/api/style":
541
+ return self._send(200, ws.house_style())
542
+ if u.path == "/api/status":
543
+ return self._send(200, ws.status())
544
+ if u.path == "/api/doctor":
545
+ return self._send(200, ws.doctor())
546
+ if u.path == "/api/triage/history": # every finished triage, whoever ran it
547
+ return self._send(200, ws.triage_history())
548
+ if u.path == "/api/triage/result":
549
+ try:
550
+ return self._send(200, ws.triage_result(q.get("id", "")))
551
+ except KeyError as e:
552
+ return self._send(404, {"error": _msg(e)})
553
+ if u.path == "/api/parts": # part cards of a BOM's library plan
554
+ return self._send(200, ws.library_parts(self._bom({"bom": q.get("bom")}), q.get("pictures") != "0"))
555
+ if u.path == "/api/hub": # the library hub: one page of parts, filtered
556
+ lst = lambda k: [x for x in (q.get(k) or "").split(",") if x]
557
+ return self._send(200, ws.hub(q.get("q", ""), lst("have"), lst("lack"), lst("state"), q.get("origin", ""),
558
+ lst("flags"), q.get("sort", "name"), _int(q.get("page"), 1),
559
+ _int(q.get("size"), 100), q.get("rescan") == "1",
560
+ q.get("tool", ""), q.get("library", ""), q.get("project", "")))
561
+ if u.path == "/api/hub/part":
562
+ try:
563
+ return self._send(200, ws.hub_part(q.get("key", "")))
564
+ except KeyError as e:
565
+ return self._send(404, {"error": _msg(e)})
566
+ if u.path == "/api/job": # a running job (its live log): owner or admin only
567
+ job = jobs.get(q.get("id", ""), self.user if team else None)
568
+ return self._send(200, job) if job else self._send(404, {"error": "no such job"})
569
+ if u.path == "/api/file": # download anything inside the workspace
570
+ p = ws.inside(q.get("path", ""))
571
+ if not p.is_file() or _team_file(ws, p):
572
+ return self._send(404, {"error": "not found"})
573
+ ctype = mimetypes.guess_type(p.name)[0] or "application/octet-stream"
574
+ plain = re.sub(r'[^\x20-\x7e]|["\\]', "_", p.name) # headers are latin-1: UTF-8 name in filename*
575
+ return self._send(200, p.read_bytes(), ctype,
576
+ {"Content-Disposition": f'attachment; filename="{plain}"'
577
+ + (f"; filename*=UTF-8''{quote(p.name, errors='replace')}" if plain != p.name else "")})
578
+ if u.path == "/api/agent/queue":
579
+ return self._send(200, ws.agent_queue_list(q.get("status") or None, q.get("batch") or None))
580
+ if u.path == "/api/agent/queue/item":
581
+ if not q.get("id"):
582
+ raise ValueError("no queue item id given")
583
+ try:
584
+ return self._send(200, ws.agent_queue_get(q["id"]))
585
+ except KeyError as e:
586
+ return self._send(404, {"error": _msg(e)})
587
+ if u.path == "/api/agent/queue/picture": # shown inline: images only, inside agent/ only
588
+ p = ws.agent_picture(q.get("path", ""))
589
+ return self._send(200, p.read_bytes(), AQ.PICTURE_TYPES[p.suffix.lower()],
590
+ {"X-Content-Type-Options": "nosniff", "Content-Security-Policy": "default-src 'none'"}
591
+ if not team else None)
592
+ if team and u.path == "/api/team/users":
593
+ return self._send(200, self._users())
594
+ if team and u.path == "/api/team/audit":
595
+ return self._send(200, {"entries": TM.read_audit(ws.root, 300, int(team.cfg["audit_keep"]))})
596
+ return self._send(404, {"error": "not found"})
597
+ except Exception as e:
598
+ return self._error(e)
599
+
600
+ def do_POST(self):
601
+ # team mode: _send() writes the audit line (every POST that passes the gate, and 401/403 refusals)
602
+ self._audit_route = urlparse(self.path).path if team else None
603
+ u = self._gate("POST")
604
+ if u is None:
605
+ return
606
+ try:
607
+ return self._post(u)
608
+ except Exception as e:
609
+ return self._error(e)
610
+
611
+ def _post(self, u):
612
+ if team and u.path in TEAM_ONLY:
613
+ return self._team_post(u.path)
614
+ if not team and u.path in ES.POST_ROUTES: # setup page (ecadlib.easy_setup)
615
+ b = self._json()
616
+ with write_lock if u.path in ES.WRITES else contextlib.nullcontext():
617
+ return self._send(200, ES.POST_ROUTES[u.path](ws.root, b))
618
+ if u.path == "/api/footprint/preview": # read-only, no lock
619
+ b = self._json()
620
+ return self._send(200, ws.preview_footprint(b["spec"], b.get("density")))
621
+ if u.path == "/api/job/cancel": # Stop: the job ends at its next line or part
622
+ job = jobs.cancel(_str(self._json().get("id"), "id"), self.user if team else None)
623
+ return self._send(200, job) if job else self._send(404, {"error": "no such job"})
624
+ if u.path == "/api/demo": # first visit: the bundled sample BOM, offline
625
+ self._json()
626
+ with write_lock:
627
+ rel = ws.demo_bom()
628
+ return self._send(200, dict(jobs.start(self._demo, rel, self._owner(), label=rel, owner=self._owner(),
629
+ key="librarian:" + rel), bom=rel))
630
+ if u.path == "/api/upload":
631
+ name = unquote(self.headers.get("X-Filename", "bom.csv"))
632
+ self._fields = {"file": name[:200]}
633
+ with write_lock:
634
+ rel = ws.save_upload(name, self._body())
635
+ return self._send(200, jobs.start(self._triage, rel, self._owner(), label=rel, owner=self._owner()))
636
+ if u.path == "/api/triage":
637
+ bom = self._bom()
638
+ return self._send(200, jobs.start(self._triage, bom, self._owner(), label=bom, owner=self._owner()))
639
+ if u.path == "/api/librarian/run": # long, network-bound: a background job
640
+ b = self._json()
641
+ bom = self._bom(b)
642
+ refresh = [] if b.get("refresh") is True else (b.get("refresh") or None)
643
+ with contextlib.suppress(Exception):
644
+ HUB.note_action(ws.root, u.path, dict(b, bom=bom), self._who(b))
645
+ return self._send(200, jobs.start(self._librarian, bom, b.get("sources"), refresh, label=bom,
646
+ owner=self._owner(), key="librarian:" + bom))
647
+ if u.path == "/api/resolve/candidates": # may fetch KiCad library files: a background job
648
+ b = self._json()
649
+ bom, part = self._bom(b), _str(b.get("part"), "part")
650
+ ws.held_parts(bom) # no plan yet: answer now, not from the job
651
+ return self._send(200, jobs.start(self._candidates, bom, part, b.get("kind") or None,
652
+ b.get("limit") or 10, label=part, owner=self._owner()))
653
+ if u.path == "/api/rules/propose": # reads library folders the person names: a job
654
+ b = self._json()
655
+ paths, a365 = _strs(b.get("paths"), "paths"), _strs(b.get("a365"), "a365")
656
+ choices = b.get("choices") or {}
657
+ if not isinstance(choices, dict):
658
+ raise ValueError("choices must map setting keys to values")
659
+ return self._send(200, jobs.start(self._propose, paths, a365, choices, label="library rules",
660
+ owner=self._owner()))
661
+ if u.path == "/api/hub/fix": # a fix button on the library hub
662
+ b = self._json()
663
+ key, action = _str(b.get("key"), "key"), _str(b.get("action"), "action")
664
+ if action == "build": # a one-line BOM through the librarian: a job
665
+ with write_lock:
666
+ bom = ws.hub_build_bom(key)
667
+ HUB.record(ws.root, [{"when": HUB.now(), "who": self._who(b), "what": "asked ecadlib to build it",
668
+ "kind": "action", "key": key, "bom": bom}])
669
+ return self._send(200, dict(jobs.start(self._librarian, bom, None, None, label=bom, owner=self._owner(),
670
+ key="librarian:" + bom), bom=bom))
671
+ with write_lock:
672
+ return self._send(200, ws.hub_fix(key, action, self._who(b)))
673
+ if u.path == "/api/hub/copy": # copy parts to another tool (and into a project)
674
+ b = self._json()
675
+ keys = b.get("keys")
676
+ if not isinstance(keys, list) or not keys or not all(isinstance(k, str) for k in keys) or len(keys) > 2000:
677
+ raise ValueError("keys must be a list of part keys from /api/hub")
678
+ with write_lock:
679
+ return self._send(200, ws.hub_copy(keys, _str(b.get("tool"), "tool"), _str(b.get("project"), "project", True) or "",
680
+ self._who(b)))
681
+ if u.path == "/api/style/learn": # scans library folders, maybe asks the model: a job
682
+ b = self._json()
683
+ return self._send(200, jobs.start(self._learn_style, _strs(b.get("paths"), "paths"),
684
+ _strs(b.get("direction"), "direction"), b.get("ai") is True,
685
+ _str(b.get("model"), "model", True) or None, label="house style",
686
+ owner=self._owner()))
687
+ if u.path == "/api/altium365/workspaces": # read-only network call, no lock
688
+ self._json()
689
+ return self._send(200, ws.altium365_workspaces())
690
+ actions = {
691
+ "/api/footprint/approve": lambda b: ws.approve_footprint(b["spec"], b.get("density"), bool(b.get("overwrite"))),
692
+ "/api/export/dblib": lambda b: ws.export_dblib(self._bom(b, True)),
693
+ "/api/export/tool": lambda b: ws.tool_export(self._bom(b, True), b.get("tool")),
694
+ "/api/altium/models": lambda b: ws.build_altium_models(self._bom(b, True)),
695
+ "/api/altium365/plan": lambda b: ws.altium365_plan(self._bom(b, True)),
696
+ "/api/release-check": lambda b: ws.release_check(self._bom(b, True), _names(b.get("allow")), _names(b.get("unallow")),
697
+ TM.identity(self.user) if team else _str(b.get("user"), "user", True)),
698
+ "/api/resolve/held": lambda b: ws.held_parts(self._bom(b)),
699
+ "/api/resolve/choose": lambda b: ws.choose_part(self._bom(b, True), _str(b.get("part"), "part"), b.get("kind"),
700
+ _str(b.get("id"), "id"), b.get("pin_map") or None),
701
+ "/api/approvals/status": lambda b: ws.approval_status(self._bom(b)),
702
+ "/api/approvals/decide": self._decide,
703
+ "/api/dedupe": lambda b: ws.find_duplicates(self._bom(b, True), b.get("merge") is True),
704
+ "/api/rules/save": lambda b: ws.save_rules(_str(b.get("text"), "text"), b.get("overwrite") is True),
705
+ "/api/style/direction": lambda b: ws.style_direction(_str(b.get("text"), "text", True) or None,
706
+ b.get("clear") is True),
707
+ "/api/altium365/push": self._push,
708
+ "/api/agent/queue/approve": lambda b: self._queue(b, "approve"),
709
+ "/api/agent/queue/reject": lambda b: self._queue(b, "reject"),
710
+ "/api/agent/queue/rebuild": lambda b: self._queue(b, "rebuild"),
711
+ }
712
+ if u.path in actions:
713
+ b = self._json()
714
+ with write_lock:
715
+ res = actions[u.path](b)
716
+ with contextlib.suppress(Exception): # the hub's "who changed it": never fails the action
717
+ HUB.note_action(ws.root, u.path, b, self._who(b), res)
718
+ return self._send(200, res)
719
+ return self._send(404, {"error": "not found"})
720
+
721
+ def _who(self, b):
722
+ """Who did it, for the hub's history: the signed-in user (team), else the name typed in the page or this
723
+ computer's user (git config / login name)."""
724
+ if team and self.user:
725
+ return TM.identity(self.user)
726
+ typed = b.get("user") if isinstance(b.get("user"), str) else ""
727
+ return typed.strip()[:120] or local_who()
728
+
729
+ def _decide(self, b):
730
+ role, comment = _str(b.get("role"), "role", True), _str(b.get("comment"), "comment", True)
731
+ if not team:
732
+ return ws.approve_part(self._bom(b, True), _str(b.get("part"), "part"), b.get("decision"),
733
+ _str(b.get("user"), "user"), role, comment)
734
+ roles = TM.sign_roles(self.user) # identity and approval role come from the session
735
+ role = (role or "").strip() or roles[0]
736
+ if role not in roles:
737
+ raise Refused(f"you sign as {', '.join(roles)}, not {role}")
738
+ return ws.approve_part(self._bom(b, True), _str(b.get("part"), "part"), b.get("decision"), TM.identity(self.user),
739
+ role, comment, via="ecadlib ui (team, signed in)")
740
+
741
+ def _queue(self, b, action):
742
+ """Review queue decision. Team mode: identity and approval role from the session (typed names ignored);
743
+ rebuild on a failed / rejected item is a retry."""
744
+ i = _str(b.get("id"), "id")
745
+ note, role = _str(b.get("note"), "note", True) or "", _str(b.get("role"), "role", True)
746
+ expect = _str(b.get("expect"), "expect", True) # the build the person looked at
747
+ if action == "rebuild" and ws.agent_queue_get(i)["item"]["status"] in ("failed", "rejected"):
748
+ action = "retry"
749
+ if not team:
750
+ return ws.agent_queue_decide(i, action, _str(b.get("user"), "user"), role, note, expect=expect)
751
+ roles = TM.sign_roles(self.user)
752
+ role = (role or "").strip() or roles[0]
753
+ if role not in roles:
754
+ raise Refused(f"you sign as {', '.join(roles)}, not {role}")
755
+ return ws.agent_queue_decide(i, action, TM.identity(self.user), role, note, via="ecadlib ui (team, signed in)",
756
+ aliases=(self.user["username"], self.user.get("email") or ""), expect=expect)
757
+
758
+ def _push(self, b):
759
+ dry = b.get("dry_run", True) is not False
760
+ if team and not dry:
761
+ self._need(LIVE_PUSH)
762
+ return ws.altium365_push(self._bom(b, True), b.get("workspace"), dry_run=dry)
763
+
764
+ # ---------------------------------------------------------- team mode routes
765
+ def _session_info(self):
766
+ if not team:
767
+ return {"team": False}
768
+ if not self.user:
769
+ return {"team": True, "user": None, "proxy": bool(team.cfg["trusted_proxies"])}
770
+ u = self.user
771
+ return {"team": True, "csrf": self._csrf(), "proxy": self.sess == "proxy", "roles": list(TM.ROLES),
772
+ "user": {"username": u["username"], "name": u.get("name") or "", "email": u.get("email") or "",
773
+ "role": u["role"], "identity": TM.identity(u), "sign_as": TM.sign_roles(u)}}
774
+
775
+ def _users(self):
776
+ locked = [n for n in team.store.users() if team.throttle.blocked(n, "")]
777
+ return {"users": team.store.public(), "roles": list(TM.ROLES), "locked": locked, "me": self.user["username"]}
778
+
779
+ def _team_post(self, path):
780
+ b = self._json()
781
+ if path == "/api/login":
782
+ return self._login(b)
783
+ if path == "/api/logout":
784
+ if self.sess != "proxy":
785
+ team.sessions.drop(self._cookie())
786
+ return self._send(200, {"ok": True}, headers={"Set-Cookie": self._set_cookie("", 0)})
787
+ if path == "/api/account/password":
788
+ if self.sess == "proxy":
789
+ raise PermissionError("signed in through the SSO proxy: change the password there")
790
+ name, ip = self.user["username"], self._ip() # same lockout as sign-in: no unlimited guessing
791
+ wait, ticket = team.throttle.attempt(name, ip)
792
+ if wait:
793
+ return self._too_many(wait)
794
+ if not TM.verify_password(b.get("current"), self.user.get("password")):
795
+ raise PermissionError("current password is wrong")
796
+ team.throttle.ok(name, ip, ticket)
797
+ team.store.reset_password(self.user["username"], b.get("new")) # ends every other session
798
+ self.user = team.store.get(self.user["username"])
799
+ return self._new_session()
800
+ act, name, st = b.get("action"), b.get("username"), team.store # /api/team/users (admin)
801
+ if act == "add":
802
+ st.add(name, b.get("role"), b.get("password") or None, b.get("name") or "", b.get("email") or "",
803
+ b.get("sign_as"))
804
+ elif act == "set-role":
805
+ st.set_role(name, b.get("role"), b.get("sign_as"))
806
+ elif act == "reset-password":
807
+ st.reset_password(name, b.get("password"))
808
+ team.throttle.unlock(TM.check_username(name))
809
+ elif act == "remove":
810
+ if TM.check_username(name) == self.user["username"]:
811
+ raise ValueError("you cannot remove yourself")
812
+ st.remove(name)
813
+ elif act == "unlock":
814
+ team.throttle.unlock(TM.check_username(name))
815
+ else:
816
+ raise ValueError("action must be add, set-role, reset-password, remove or unlock")
817
+ return self._send(200, self._users())
818
+
819
+ def _too_many(self, wait):
820
+ return self._send(429, {"error": f"too many failed sign-ins: try again in {max(1, wait // 60)} minute(s), "
821
+ "or ask an admin to unlock you"}, headers={"Retry-After": str(wait)})
822
+
823
+ def _new_session(self):
824
+ tok, s = team.sessions.create(self.user["username"], int(self.user.get("gen") or 1))
825
+ self.sess = s
826
+ return self._send(200, self._session_info(), headers={"Set-Cookie": self._set_cookie(tok, team.sessions.remaining(s))})
827
+
828
+ def _login(self, b):
829
+ name, pw, ip = str(b.get("username") or "").strip().lower()[:64], b.get("password"), self._ip()
830
+ self._fields = {"username": name}
831
+ wait, ticket = team.throttle.attempt(name, ip) # counted before the check: parallel tries too
832
+ if wait:
833
+ return self._too_many(wait)
834
+ u = team.store.get(name)
835
+ if not TM.verify_password(pw, u and u.get("password")):
836
+ return self._send(401, {"error": "wrong user name or password"})
837
+ team.throttle.ok(name, ip, ticket)
838
+ self.user = u
839
+ return self._new_session()
840
+
841
+ # ---------------------------------------------------------- jobs
842
+ @staticmethod
843
+ def _librarian(bom, sources, refresh, progress=None):
844
+ return _final_lines(progress, ws.librarian_run(bom, sources, (), refresh, _part_lines(progress)))
845
+
846
+ @staticmethod
847
+ def _demo(bom, user, progress=None):
848
+ """Triage, then fill the library from the built-in standard parts only (no network, no API key)."""
849
+ t = ws.triage(bom, _line_lines(progress, "Checking the BOM"), lock=write_lock, user=user)
850
+ progress.line("__triage", state="done", text=f"BOM checked: {len(t['rows'])} lines")
851
+ r = _final_lines(progress, ws.librarian_run(bom, ["standard"], (), None, _part_lines(progress)))
852
+ return dict(r, triage=t)
853
+
854
+ @staticmethod
855
+ def _candidates(bom, part, kind, limit, progress=None):
856
+ return ws.part_candidates(bom, part, kind, limit, progress)
857
+
858
+ @staticmethod
859
+ def _propose(paths, a365, choices, progress=None):
860
+ return ws.propose_rules(paths, a365, choices, progress)
861
+
862
+ @staticmethod
863
+ def _learn_style(paths, direction, ai, model, progress=None):
864
+ return ws.learn_house_style(paths, direction, ai, model, progress)
865
+
866
+ @staticmethod
867
+ def _triage(bom, user, progress=None):
868
+ t = ws.triage(bom, _line_lines(progress), lock=write_lock, user=user) # lock only while reports are written
869
+ for r in t["rows"]:
870
+ progress.line(f"line {r['line']}", state="done", text=f"{r['line']}/{len(t['rows'])} "
871
+ f"{r.get('mpn') or r.get('value') or r.get('designator') or ''} {_triage_word(r.get('status'))}")
872
+ return t
873
+
874
+ def _error(self, e):
875
+ if isinstance(e, Refused):
876
+ return self._send(403, {"error": str(e)})
877
+ if not isinstance(e, EXPECTED):
878
+ traceback.print_exc()
879
+ code = 409 if isinstance(e, FileExistsError) else 403 if isinstance(e, PermissionError) else 400
880
+ return self._send(code, {"error": _msg(e, ws.root)})
881
+
882
+ return H
883
+
884
+
885
+ class Server(ThreadingHTTPServer):
886
+ daemon_threads = True
887
+ tls = False
888
+ team = None
889
+ request_queue_size = 128 # a burst of sign-ins must queue, not get connection resets
890
+
891
+ def __init__(self, *a, **kw):
892
+ self.per_ip, self.per_ip_lock = {}, threading.Lock()
893
+ super().__init__(*a, **kw)
894
+
895
+ def _limited(self, ip):
896
+ return self.team is not None and int(self.team.cfg["max_connections_per_ip"]) > 0 and \
897
+ ip not in self.team.cfg["trusted_proxies"]
898
+
899
+ def process_request(self, request, client_address):
900
+ """Team mode: at most max_connections_per_ip open connections per address (slow clients can't take every
901
+ thread); more are closed at once. A trusted proxy (Caddy) is exempt: it carries everyone."""
902
+ ip = client_address[0]
903
+ if self._limited(ip):
904
+ with self.per_ip_lock:
905
+ if self.per_ip.get(ip, 0) >= int(self.team.cfg["max_connections_per_ip"]):
906
+ self.shutdown_request(request)
907
+ return
908
+ self.per_ip[ip] = self.per_ip.get(ip, 0) + 1
909
+ super().process_request(request, client_address)
910
+
911
+ def process_request_thread(self, request, client_address):
912
+ try:
913
+ super().process_request_thread(request, client_address)
914
+ finally:
915
+ ip = client_address[0]
916
+ if self._limited(ip):
917
+ with self.per_ip_lock:
918
+ self.per_ip[ip] = self.per_ip.get(ip, 1) - 1
919
+ if self.per_ip[ip] <= 0:
920
+ del self.per_ip[ip]
921
+
922
+ def handle_error(self, request, client_address):
923
+ e = sys.exc_info()[1]
924
+ if isinstance(e, (ssl.SSLError, ConnectionError, TimeoutError, socket.timeout)):
925
+ return # bad handshakes / dropped clients: not worth a traceback
926
+ super().handle_error(request, client_address)
927
+
928
+
929
+ def make_server(ws, host="127.0.0.1", port=0, team=None, cert=None, key=None):
930
+ """The UI server (not started). cert/key: serve HTTPS with the standard library's ssl (TLS 1.2+)."""
931
+ host = host.strip("[]")
932
+ cls = type("Server6", (Server,), {"address_family": socket.AF_INET6}) if ":" in host else Server # --host ::1 / ::
933
+ srv = cls((host, port), make_handler(ws, team))
934
+ srv.team = team
935
+ if cert:
936
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
937
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
938
+ ctx.load_cert_chain(cert, key)
939
+ srv.socket = ctx.wrap_socket(srv.socket, server_side=True, do_handshake_on_connect=False)
940
+ srv.tls = True
941
+ if team:
942
+ team.tls = True
943
+ return srv
944
+
945
+
946
+ def _loopback(host):
947
+ if host == "localhost":
948
+ return True
949
+ try:
950
+ return ipaddress.ip_address(host.strip("[]")).is_loopback
951
+ except ValueError:
952
+ return False
953
+
954
+
955
+ def main(argv=None):
956
+ ap = argparse.ArgumentParser(prog="ecadlib ui", description="ecadlib web UI (local, or --team for a shared server)")
957
+ ap.add_argument("--lib", action="append", default=[], help="KiCad library dir to index (repeatable)")
958
+ ap.add_argument("--workspace", default="ecadlib_workspace")
959
+ ap.add_argument("--providers", help="comma list; default all")
960
+ ap.add_argument("--offline", action="store_true", help="use cached provider responses only")
961
+ ap.add_argument("--host", default="127.0.0.1", help="loopback only without --team (127.0.0.1, ::1, localhost)")
962
+ ap.add_argument("--port", type=int, default=8765)
963
+ ap.add_argument("--no-browser", action="store_true")
964
+ ap.add_argument("--setup", action="store_true", help="open the setup page (AI model and key, Altium / KiCad, folders)")
965
+ t = ap.add_argument_group("team mode (shared server with sign-in; users: ecadlib team add-user ...)")
966
+ t.add_argument("--team", action="store_true", help="require sign-in; roles from team_users.json in the workspace")
967
+ t.add_argument("--cert", help="TLS certificate (PEM) for HTTPS")
968
+ t.add_argument("--key", help="TLS private key (PEM)")
969
+ t.add_argument("--allow-http", action="store_true",
970
+ help="plain HTTP on a non-loopback address (only behind an HTTPS reverse proxy on a trusted network)")
971
+ t.add_argument("--allowed-host", action="append", default=[], help="extra Host name the server answers to (repeatable)")
972
+ t.add_argument("--trusted-proxy", action="append", default=[],
973
+ help="IP of an SSO reverse proxy whose user header is believed (repeatable)")
974
+ t.add_argument("--proxy-user-header", help="header carrying the signed-in user (default X-Forwarded-User)")
975
+ a = ap.parse_args(argv)
976
+ from .. import userconfig
977
+ userconfig.apply_env() # keys stored by the setup page (env variables win)
978
+ if bool(a.cert) != bool(a.key):
979
+ sys.exit("give both --cert and --key")
980
+ for f in filter(None, (a.cert, a.key)):
981
+ if not Path(f).is_file():
982
+ sys.exit(f"no such file: {f}")
983
+ team = None
984
+ if not a.team:
985
+ if not _loopback(a.host):
986
+ sys.exit(f"refusing to serve on {a.host}: the UI has no login without --team, so anyone who can reach it "
987
+ "could edit the workspace and push to Altium 365 with your token. Keep the default 127.0.0.1; to "
988
+ "share it, run `ecadlib ui --team` (README: Team server), or forward the port over SSH "
989
+ "(ssh -L 8765:127.0.0.1:8765 host).")
990
+ else:
991
+ if not _loopback(a.host) and not a.cert and not a.allow_http:
992
+ sys.exit(f"refusing to serve team mode on {a.host} over plain HTTP: passwords and session cookies would "
993
+ "cross the network unencrypted. Give --cert and --key, or put an HTTPS reverse proxy (Caddy, nginx) "
994
+ "in front and bind to 127.0.0.1, or (proxy on another host) add --allow-http.")
995
+ try:
996
+ team = TM.Team(Path(a.workspace).resolve(), {"allowed_hosts": a.allowed_host, "trusted_proxies": a.trusted_proxy,
997
+ "proxy_user_header": a.proxy_user_header})
998
+ except (ValueError, json.JSONDecodeError) as e:
999
+ sys.exit(f"team settings: {e}")
1000
+ if not team.has_admin():
1001
+ sys.exit("no admin yet: create one first (only the command line can):\n"
1002
+ f" ecadlib team --workspace {a.workspace} add-user YOURNAME --role admin --name \"Your Name\" --email you@example.com")
1003
+ if not _loopback(a.host) and not a.cert:
1004
+ print("\n" + "!" * 78 + f"\n!! WARNING: team mode on {a.host} over PLAIN HTTP (--allow-http). Passwords and\n"
1005
+ "!! session cookies can be read by anyone on the network path. Only do this behind an HTTPS proxy.\n"
1006
+ + "!" * 78 + "\n", file=sys.stderr)
1007
+ ws = Workspace(a.workspace, a.lib, a.providers.split(",") if a.providers else None, a.offline)
1008
+ try:
1009
+ srv = make_server(ws, a.host, a.port, team, a.cert, a.key)
1010
+ except ssl.SSLError as e:
1011
+ sys.exit(f"TLS certificate/key: {e}")
1012
+ except OSError as e:
1013
+ sys.exit(f"can't listen on {a.host}:{a.port} ({e.strerror}); is ecadlib already running? Try --port {a.port + 1}")
1014
+ shown = socket.getfqdn() if a.host.strip("[]") in ("0.0.0.0", "::") else a.host
1015
+ shown = f"[{shown}]" if ":" in shown and not shown.startswith("[") else shown # IPv6 literal in a URL
1016
+ url = f"{'https' if a.cert else 'http'}://{shown}:{srv.server_address[1]}/"
1017
+ print(f"ecadlib UI on {url} (workspace {ws.root}{'; team mode' if team else ''}; Ctrl+C to stop)")
1018
+ if a.setup and team:
1019
+ print("the setup page is for a local ecadlib (keys are per person); in team mode use the command line")
1020
+ if not a.no_browser and not team:
1021
+ webbrowser.open(url + ("setup" if a.setup else ""))
1022
+ try:
1023
+ srv.serve_forever()
1024
+ except KeyboardInterrupt:
1025
+ pass
1026
+
1027
+
1028
+ if __name__ == "__main__":
1029
+ main()