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.
- ecadlib/__init__.py +3 -0
- ecadlib/__main__.py +5 -0
- ecadlib/a365_sync.py +637 -0
- ecadlib/agent.py +1407 -0
- ecadlib/agent_queue.py +780 -0
- ecadlib/agent_watch.py +732 -0
- ecadlib/altium365.py +385 -0
- ecadlib/altium_dblib_sync.py +530 -0
- ecadlib/altium_export.py +209 -0
- ecadlib/altium_forms.py +768 -0
- ecadlib/altium_lib.py +1089 -0
- ecadlib/altium_menu.py +1751 -0
- ecadlib/altium_pkg.py +396 -0
- ecadlib/altium_project.py +1095 -0
- ecadlib/app.py +1458 -0
- ecadlib/approvals.py +395 -0
- ecadlib/assets/demo_bom.csv +8 -0
- ecadlib/assets/kicad_action_plugin.py +607 -0
- ecadlib/assets/kicad_reread.py +60 -0
- ecadlib/assets/pcblib_template.json.gz +0 -0
- ecadlib/bom_inputs.py +339 -0
- ecadlib/bom_triage.py +519 -0
- ecadlib/cad_model.py +528 -0
- ecadlib/cfb.py +171 -0
- ecadlib/cli.py +185 -0
- ecadlib/clihelp.py +149 -0
- ecadlib/cliutil.py +119 -0
- ecadlib/completion.py +173 -0
- ecadlib/costs.py +276 -0
- ecadlib/crosscheck.py +412 -0
- ecadlib/datasheet.py +2168 -0
- ecadlib/dedupe.py +572 -0
- ecadlib/detect.py +170 -0
- ecadlib/doctor.py +450 -0
- ecadlib/ds_route.py +541 -0
- ecadlib/eagle_export.py +1409 -0
- ecadlib/easy_setup.py +338 -0
- ecadlib/easyeda_convert.py +386 -0
- ecadlib/easyeda_pro_export.py +972 -0
- ecadlib/eval/__init__.py +0 -0
- ecadlib/eval/baselines/kicad-v1.json +404 -0
- ecadlib/eval/build_set.py +299 -0
- ecadlib/eval/cli.py +119 -0
- ecadlib/eval/gate.py +358 -0
- ecadlib/eval/golden/demo.txt +379 -0
- ecadlib/eval/golden/specs.txt +3120 -0
- ecadlib/eval/golden/testpack.txt +463 -0
- ecadlib/eval/golden.py +446 -0
- ecadlib/eval/replay/kicad-v1.json.gz +0 -0
- ecadlib/eval/runner.py +456 -0
- ecadlib/eval/score.py +179 -0
- ecadlib/eval/sets/kicad-v1.json +630 -0
- ecadlib/firstrun.py +400 -0
- ecadlib/footprint_adv.py +1006 -0
- ecadlib/footprint_bench.py +218 -0
- ecadlib/footprint_check.py +562 -0
- ecadlib/footprint_gen.py +582 -0
- ecadlib/footprint_rules.py +1092 -0
- ecadlib/forge.py +267 -0
- ecadlib/fp_share.py +95 -0
- ecadlib/fp_tidy.py +389 -0
- ecadlib/fsutil.py +62 -0
- ecadlib/house_style.py +1051 -0
- ecadlib/httpcache.py +124 -0
- ecadlib/hub.py +1490 -0
- ecadlib/hub_copy.py +161 -0
- ecadlib/hub_tools.py +79 -0
- ecadlib/kicad_dblib.py +257 -0
- ecadlib/kicad_export.py +837 -0
- ecadlib/kicad_import.py +913 -0
- ecadlib/kicad_index.py +160 -0
- ecadlib/kicad_menu.py +193 -0
- ecadlib/kicad_pcm.py +475 -0
- ecadlib/launcher.py +156 -0
- ecadlib/librarian.py +963 -0
- ecadlib/lifecycle.py +691 -0
- ecadlib/llm/__init__.py +276 -0
- ecadlib/llm/cli.py +70 -0
- ecadlib/llm/openai_compat.py +214 -0
- ecadlib/llm/profiles.py +245 -0
- ecadlib/llm/rails.py +570 -0
- ecadlib/llm/run.py +126 -0
- ecadlib/llm/sampling.py +49 -0
- ecadlib/mcp_server.py +723 -0
- ecadlib/messages.py +419 -0
- ecadlib/migrate.py +407 -0
- ecadlib/notify.py +329 -0
- ecadlib/package_schema.json +1238 -0
- ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
- ecadlib/package_specs/C_0402_1005Metric.json +22 -0
- ecadlib/package_specs/C_0805_2012Metric.json +22 -0
- ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
- ecadlib/package_specs/D_SMA.json +27 -0
- ecadlib/package_specs/D_SMB.json +27 -0
- ecadlib/package_specs/D_SOD-123.json +43 -0
- ecadlib/package_specs/D_SOD-323.json +43 -0
- ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
- ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
- ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
- ecadlib/package_specs/L_0805_2012Metric.json +22 -0
- ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
- ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
- ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
- ecadlib/package_specs/R_0402_1005Metric.json +22 -0
- ecadlib/package_specs/R_0603_1608Metric.json +22 -0
- ecadlib/package_specs/R_0805_2012Metric.json +22 -0
- ecadlib/package_specs/R_1206_3216Metric.json +22 -0
- ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
- ecadlib/package_specs/SOT-23-5.json +57 -0
- ecadlib/package_specs/SOT-23-6.json +62 -0
- ecadlib/package_specs/SOT-23.json +48 -0
- ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
- ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
- ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
- ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
- ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
- ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
- ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
- ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
- ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
- ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
- ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
- ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
- ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
- ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
- ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
- ecadlib/package_specs/standard.json.gz +0 -0
- ecadlib/parity.py +1204 -0
- ecadlib/parts_export.py +154 -0
- ecadlib/partview.py +215 -0
- ecadlib/plugins.py +231 -0
- ecadlib/providers.py +226 -0
- ecadlib/rebuild.py +471 -0
- ecadlib/release_check.py +437 -0
- ecadlib/reports.py +274 -0
- ecadlib/resolve.py +556 -0
- ecadlib/review_cli.py +292 -0
- ecadlib/rules.py +979 -0
- ecadlib/rules_infer.py +1205 -0
- ecadlib/sexpr.py +149 -0
- ecadlib/sheetsafe.py +65 -0
- ecadlib/sim_models.py +1087 -0
- ecadlib/sourcing.py +1161 -0
- ecadlib/stdparts.py +755 -0
- ecadlib/step_gen.py +929 -0
- ecadlib/symbol_layout.py +798 -0
- ecadlib/team.py +558 -0
- ecadlib/team_setup.py +288 -0
- ecadlib/term.py +420 -0
- ecadlib/term_views.py +64 -0
- ecadlib/testpack.py +334 -0
- ecadlib/ui/__init__.py +1 -0
- ecadlib/ui/__main__.py +3 -0
- ecadlib/ui/server.py +1029 -0
- ecadlib/ui/static/hairline.js +2 -0
- ecadlib/ui/static/index.html +2098 -0
- ecadlib/ui/static/setup.html +294 -0
- ecadlib/upkeep.py +717 -0
- ecadlib/upstream.py +573 -0
- ecadlib/userconfig.py +257 -0
- ecadlib/visual_check.py +1603 -0
- ecadlib/ws_status.py +203 -0
- ecadlib-0.3.0.dist-info/METADATA +2245 -0
- ecadlib-0.3.0.dist-info/RECORD +199 -0
- ecadlib-0.3.0.dist-info/WHEEL +5 -0
- ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
- ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
- ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
- 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()
|