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/agent_queue.py
ADDED
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
"""Library agent work queue: one durable JSON file per work item, a state machine, history and batches.
|
|
2
|
+
|
|
3
|
+
ecadlib queue list [--status review] [--batch ID] [--workspace DIR]
|
|
4
|
+
ecadlib queue show ID
|
|
5
|
+
ecadlib queue approve|reject|rebuild|retry ID --user "Ann <ann@x.com>" [--role R] [-m NOTE]
|
|
6
|
+
(or: python -m ecadlib.agent_queue ...)
|
|
7
|
+
|
|
8
|
+
Files under <workspace>/agent/queue/ (all JSON, written to a temp file and moved over the old one with os.replace):
|
|
9
|
+
items/<id>.json one work item (Item below)
|
|
10
|
+
batches/<id>.json one batch: {"id", "items", "created", "updated", "pr_url", "status", ...}
|
|
11
|
+
.lock the lock file (see _Lock)
|
|
12
|
+
|
|
13
|
+
Item = {"id", "key", "mpn", "manufacturer", "origin": "watch"|"upkeep"|"manual", "change": Change or upkeep finding,
|
|
14
|
+
"status", "history": [{"at","from","to","user","note"}], "build": {...}, "proof": Proof or None,
|
|
15
|
+
"pictures": {"before": [paths], "after": [paths]}, "batch": id or None, "pr_url": str or None,
|
|
16
|
+
"created", "updated"}
|
|
17
|
+
Part key: "manufacturer|MPN" (manufacturer stripped and lower-cased, MPN stripped and upper-cased; "|MPN" without one).
|
|
18
|
+
|
|
19
|
+
States: detected -> building -> proving -> review -> approved -> pushed
|
|
20
|
+
any open state -> failed | rejected; failed/rejected -> detected (retry); review -> building (rebuild).
|
|
21
|
+
`upsert` dedupes by part key: a re-detected part whose item is still open updates that item (a history entry with
|
|
22
|
+
from == to says so); a failed/rejected item is re-opened (-> detected) only when the change itself is different;
|
|
23
|
+
a part whose last item was pushed gets a new item.
|
|
24
|
+
|
|
25
|
+
`build` fields the review page and approvals use (the builder fills them; all optional):
|
|
26
|
+
bom, part workspace-relative BOM and the librarian plan item name: approve/reject are then also recorded
|
|
27
|
+
through approvals.py in that plan (so release_check / altium365 push see them)
|
|
28
|
+
user who started the build: that person may not approve the item (no self-approval)
|
|
29
|
+
sources, notes, files what was built from where, shown on the review page
|
|
30
|
+
A person's Rebuild sets build.requested_by/requested_at/request_note and moves review -> building (failed/rejected
|
|
31
|
+
-> detected): the agent picks it up from there.
|
|
32
|
+
|
|
33
|
+
Locking: a threading.RLock per queue folder (one process) plus an exclusive lock on .lock across processes (the web
|
|
34
|
+
server, `ecadlib agent serve` and this CLI may run at once): fcntl.flock on POSIX, msvcrt.locking on Windows. On
|
|
35
|
+
Windows msvcrt gives up after about 10 s of contention (raises OSError: try again) and network drives may not honour
|
|
36
|
+
it; keep the workspace on a local disk there. Reads take the lock too, so nobody sees a half-applied batch. Within one
|
|
37
|
+
hold the items are read once (many upserts under one `with q.lock:` cost one folder read, not one per part).
|
|
38
|
+
approve / reject also take <workspace>/agent/plan.lock (without waiting: "decide again in a minute") while they record
|
|
39
|
+
into the plan, which the agent holds while the librarian rewrites it. decide(expect=build_token(item)) refuses a
|
|
40
|
+
decision on a build other than the one the person looked at."""
|
|
41
|
+
import argparse, contextlib, copy, datetime, json, os, re, secrets, sys, threading, time
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
STATES = ("detected", "building", "proving", "review", "approved", "pushed", "failed", "rejected")
|
|
45
|
+
OPEN = ("detected", "building", "proving", "review", "approved")
|
|
46
|
+
NEXT = {"detected": ("building",), "building": ("proving",), "proving": ("review",), "review": ("approved", "building"),
|
|
47
|
+
"approved": ("pushed",), "pushed": (), "failed": ("detected",), "rejected": ("detected",)}
|
|
48
|
+
for _s in OPEN:
|
|
49
|
+
NEXT[_s] = NEXT[_s] + ("failed", "rejected")
|
|
50
|
+
ORIGINS = ("watch", "upkeep", "manual")
|
|
51
|
+
ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
|
52
|
+
FIXED = ("id", "key", "status", "history", "created") # changed only by upsert / transition
|
|
53
|
+
PICTURE_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def now():
|
|
57
|
+
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Manufacturer names as part keys use them: "ST", "STMicroelectronics" and "STMicroelectronics N.V." are one maker.
|
|
61
|
+
from .rules import MANUFACTURER_ALIASES as MFR_ALIASES # defaults; [agent] manufacturer_aliases extends
|
|
62
|
+
MFR_SUFFIXES = {"inc", "incorporated", "corp", "corporation", "co", "company", "ltd", "limited", "llc", "lp", "gmbh",
|
|
63
|
+
"ag", "sa", "nv", "bv", "plc", "kg", "kk", "oy", "ab", "spa", "srl", "pte", "pty", "sas", "se"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _clean_maker(name):
|
|
67
|
+
"""lower case, no accents or punctuation, no legal suffix (Inc, Ltd, GmbH, N.V., "& Co. KG" ...)."""
|
|
68
|
+
import unicodedata
|
|
69
|
+
s = unicodedata.normalize("NFKD", str(name or "")).encode("ascii", "ignore").decode().lower()
|
|
70
|
+
s = re.sub(r"\b([a-z])\.(?=[a-z]\.)", r"\1", s) # N.V. -> NV., S.p.A. -> SpA
|
|
71
|
+
s = s.replace("&", " and ")
|
|
72
|
+
words = re.sub(r"[^a-z0-9]+", " ", s).split()
|
|
73
|
+
while len(words) > 1 and (words[-1] in MFR_SUFFIXES or words[-1] == "and"):
|
|
74
|
+
words.pop()
|
|
75
|
+
return " ".join(words)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def alias_table(rules=None):
|
|
79
|
+
"""{cleaned alias: cleaned canonical} from [agent] manufacturer_aliases (defaults: MFR_ALIASES)."""
|
|
80
|
+
raw = ((rules or {}).get("agent") or {}).get("manufacturer_aliases")
|
|
81
|
+
raw = MFR_ALIASES if raw is None else raw
|
|
82
|
+
out = {_clean_maker(k): _clean_maker(v) for k, v in raw.items() if _clean_maker(k) and _clean_maker(v)}
|
|
83
|
+
return {k: out.get(v, v) for k, v in out.items()} # one level of chaining: A -> B -> C
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_ALIASES = {"table": alias_table(), "sig": None, "rules_file": None}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def aliases_sig():
|
|
90
|
+
if _ALIASES["sig"] is None:
|
|
91
|
+
import hashlib
|
|
92
|
+
_ALIASES["sig"] = hashlib.sha256(json.dumps(sorted(_ALIASES["table"].items())).encode()).hexdigest()[:16]
|
|
93
|
+
return _ALIASES["sig"]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def use_rules(rules):
|
|
97
|
+
"""Make part keys use these rules' [agent] manufacturer_aliases (the agent, the watch and the queue call it with
|
|
98
|
+
the workspace rules; keys made under another table are migrated once, see Queue.migrate_keys)."""
|
|
99
|
+
table = alias_table(rules)
|
|
100
|
+
if table != _ALIASES["table"]:
|
|
101
|
+
_ALIASES.update(table=table, sig=None)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def use_workspace_rules(root):
|
|
105
|
+
"""use_rules() with <root>'s rules file, re-read only when that file changes; bad rules keep the current table."""
|
|
106
|
+
from . import rules as RU
|
|
107
|
+
try:
|
|
108
|
+
f = RU.find(root)
|
|
109
|
+
sig = (str(f), f.stat().st_mtime_ns) if f else None
|
|
110
|
+
if sig != _ALIASES["rules_file"]:
|
|
111
|
+
use_rules(RU.load(None, start=root))
|
|
112
|
+
_ALIASES["rules_file"] = sig
|
|
113
|
+
except (OSError, ValueError):
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def norm_manufacturer(name):
|
|
118
|
+
"""The manufacturer as a part key uses it: _clean_maker, then the alias table ("ST" -> "stmicroelectronics").
|
|
119
|
+
"" stays ""."""
|
|
120
|
+
s = _clean_maker(name)
|
|
121
|
+
return _ALIASES["table"].get(s, s)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def part_key(manufacturer, mpn):
|
|
125
|
+
mpn = str(mpn or "").strip().upper()
|
|
126
|
+
if not mpn:
|
|
127
|
+
raise ValueError("a queue item needs an MPN")
|
|
128
|
+
return f"{norm_manufacturer(manufacturer)}|{mpn}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def renorm_key(key):
|
|
132
|
+
"""An old or foreign "manufacturer|MPN" key in today's form."""
|
|
133
|
+
m, _, n = str(key or "").partition("|")
|
|
134
|
+
return f"{norm_manufacturer(m)}|{n.strip().upper()}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check_id(i):
|
|
138
|
+
if not isinstance(i, str) or not ID.match(i):
|
|
139
|
+
raise ValueError(f"bad queue id {str(i)[:80]!r}")
|
|
140
|
+
return i
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------- locking
|
|
144
|
+
_LOCKS, _LOCKS_GUARD = {}, threading.Lock()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class _Lock:
|
|
148
|
+
"""Re-entrant across threads (RLock) and exclusive across processes (an OS lock on <queue>/.lock, taken by the
|
|
149
|
+
outermost holder only)."""
|
|
150
|
+
def __init__(self, path):
|
|
151
|
+
self.path, self.rlock, self.depth, self.fh = path, threading.RLock(), 0, None
|
|
152
|
+
self.cache = None # {file name: item} read while the lock is held; nobody else writes meanwhile
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def of(cls, folder):
|
|
156
|
+
key = str(Path(folder).resolve())
|
|
157
|
+
with _LOCKS_GUARD:
|
|
158
|
+
return _LOCKS.setdefault(key, cls(Path(key) / ".lock"))
|
|
159
|
+
|
|
160
|
+
def __enter__(self):
|
|
161
|
+
self.rlock.acquire()
|
|
162
|
+
try:
|
|
163
|
+
if self.depth == 0:
|
|
164
|
+
self.fh = _os_lock(self.path)
|
|
165
|
+
self.cache = None
|
|
166
|
+
except BaseException:
|
|
167
|
+
self.rlock.release()
|
|
168
|
+
raise
|
|
169
|
+
self.depth += 1
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def __exit__(self, *exc):
|
|
173
|
+
self.depth -= 1
|
|
174
|
+
try:
|
|
175
|
+
if self.depth == 0:
|
|
176
|
+
fh, self.fh, self.cache = self.fh, None, None
|
|
177
|
+
_os_unlock(fh)
|
|
178
|
+
finally:
|
|
179
|
+
self.rlock.release()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _os_lock(path):
|
|
183
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
fh = open(path, "a+b")
|
|
185
|
+
try:
|
|
186
|
+
try:
|
|
187
|
+
import fcntl
|
|
188
|
+
fcntl.flock(fh, fcntl.LOCK_EX)
|
|
189
|
+
except ImportError: # Windows
|
|
190
|
+
import msvcrt
|
|
191
|
+
fh.seek(0)
|
|
192
|
+
msvcrt.locking(fh.fileno(), msvcrt.LK_LOCK, 1) # retries for ~10 s, then OSError
|
|
193
|
+
except BaseException:
|
|
194
|
+
fh.close()
|
|
195
|
+
raise
|
|
196
|
+
return fh
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _os_unlock(fh):
|
|
200
|
+
try:
|
|
201
|
+
try:
|
|
202
|
+
import fcntl
|
|
203
|
+
fcntl.flock(fh, fcntl.LOCK_UN)
|
|
204
|
+
except ImportError:
|
|
205
|
+
import msvcrt
|
|
206
|
+
fh.seek(0)
|
|
207
|
+
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
|
|
208
|
+
finally:
|
|
209
|
+
fh.close()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _write(path, data):
|
|
213
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
|
|
214
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
215
|
+
json.dump(data, f, indent=2, sort_keys=True)
|
|
216
|
+
f.flush()
|
|
217
|
+
os.fsync(f.fileno())
|
|
218
|
+
os.replace(tmp, path)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@contextlib.contextmanager
|
|
222
|
+
def plan_lock(workspace_root, wait=True):
|
|
223
|
+
"""<workspace>/agent/plan.lock: the agent holds it while the librarian rewrites its plan.json (minutes), and every
|
|
224
|
+
approval path (the queue, the Approvals page / approve_part, `ecadlib approve`) takes it while it records into a
|
|
225
|
+
plan, so neither overwrites the other. wait=False yields False at once when it is held. Not re-entrant: the
|
|
226
|
+
queue's decide() holds it and calls Workspace._approve_part, the unlocked body. Released by the OS if the holder
|
|
227
|
+
dies."""
|
|
228
|
+
d = Path(workspace_root).resolve() / "agent"
|
|
229
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
f = open(d / "plan.lock", "a+b")
|
|
231
|
+
msvcrt = None
|
|
232
|
+
try:
|
|
233
|
+
try:
|
|
234
|
+
try:
|
|
235
|
+
import fcntl
|
|
236
|
+
fcntl.flock(f, fcntl.LOCK_EX | (0 if wait else fcntl.LOCK_NB))
|
|
237
|
+
except ImportError: # Windows
|
|
238
|
+
import msvcrt
|
|
239
|
+
_win_lock(msvcrt, f, wait)
|
|
240
|
+
except OSError:
|
|
241
|
+
if wait:
|
|
242
|
+
raise
|
|
243
|
+
got = False
|
|
244
|
+
else:
|
|
245
|
+
got = True
|
|
246
|
+
try:
|
|
247
|
+
yield got
|
|
248
|
+
finally:
|
|
249
|
+
if msvcrt is not None and got: # Windows frees a lock left on a closed file late
|
|
250
|
+
try:
|
|
251
|
+
f.seek(0)
|
|
252
|
+
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
|
|
253
|
+
except OSError:
|
|
254
|
+
pass
|
|
255
|
+
finally:
|
|
256
|
+
f.close()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _win_lock(msvcrt, f, wait, poll=0.5):
|
|
260
|
+
"""msvcrt's blocking LK_LOCK gives up with OSError after 10 one-second tries, but the librarian holds the plan lock
|
|
261
|
+
for minutes: wait by polling the non-blocking lock instead."""
|
|
262
|
+
while True:
|
|
263
|
+
f.seek(0)
|
|
264
|
+
try:
|
|
265
|
+
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
|
|
266
|
+
return
|
|
267
|
+
except OSError:
|
|
268
|
+
if not wait:
|
|
269
|
+
raise
|
|
270
|
+
time.sleep(poll)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def build_token(item):
|
|
274
|
+
"""Which build of an item a person looked at: its files' hash and build time. A decision sent with the token it
|
|
275
|
+
was made on (decide(expect=...)) is refused once the item was rebuilt."""
|
|
276
|
+
b = (item or {}).get("build") or {}
|
|
277
|
+
return f"{b.get('hash')}@{b.get('built')}"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------- the store
|
|
281
|
+
class Queue:
|
|
282
|
+
def __init__(self, workspace):
|
|
283
|
+
root = workspace if isinstance(workspace, (str, os.PathLike)) else workspace.root # a path or a Workspace
|
|
284
|
+
self.workspace = Path(root).resolve()
|
|
285
|
+
self.dir = self.workspace / "agent" / "queue"
|
|
286
|
+
self.items_dir, self.batches_dir = self.dir / "items", self.dir / "batches"
|
|
287
|
+
for d in (self.items_dir, self.batches_dir):
|
|
288
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
289
|
+
from . import migrate as MG # a queue from a newer ecadlib is refused; ours is stamped
|
|
290
|
+
MG.open_folder(self.dir, ["queue"])
|
|
291
|
+
self.lock = _Lock.of(self.dir)
|
|
292
|
+
use_workspace_rules(self.workspace) # [agent] manufacturer_aliases for part keys
|
|
293
|
+
|
|
294
|
+
# -- files
|
|
295
|
+
def _item_path(self, i):
|
|
296
|
+
return self.items_dir / f"{check_id(i)}.json"
|
|
297
|
+
|
|
298
|
+
def _load(self, path, what):
|
|
299
|
+
try:
|
|
300
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
301
|
+
except FileNotFoundError:
|
|
302
|
+
raise KeyError(f"no {what} {path.stem}") from None
|
|
303
|
+
except json.JSONDecodeError as e:
|
|
304
|
+
raise ValueError(f"{path}: not valid JSON ({e})") from None
|
|
305
|
+
|
|
306
|
+
def _new_id(self, d):
|
|
307
|
+
while True:
|
|
308
|
+
i = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d-%H%M%S-") + secrets.token_hex(3)
|
|
309
|
+
if not (d / f"{i}.json").exists():
|
|
310
|
+
return i
|
|
311
|
+
|
|
312
|
+
# -- items
|
|
313
|
+
def get(self, i):
|
|
314
|
+
with self.lock:
|
|
315
|
+
return self._load(self._item_path(i), "queue item")
|
|
316
|
+
|
|
317
|
+
def list(self, status=None, batch=None):
|
|
318
|
+
"""Items, newest first. status: one state or a list of them."""
|
|
319
|
+
want = ({status} if status else set()) if isinstance(status, str) else set(status or ())
|
|
320
|
+
bad = want - set(STATES)
|
|
321
|
+
if bad:
|
|
322
|
+
raise ValueError(f"status must be one of {', '.join(STATES)}")
|
|
323
|
+
with self.lock:
|
|
324
|
+
out = [copy.deepcopy(it) for it in self._all().values()
|
|
325
|
+
if (not want or it.get("status") in want) and (batch is None or it.get("batch") == batch)]
|
|
326
|
+
return sorted(out, key=lambda it: (it.get("created") or "", it["id"]), reverse=True)
|
|
327
|
+
|
|
328
|
+
def _all(self):
|
|
329
|
+
"""{file name: item} of every readable item (call with the lock held). Read once per lock hold: a caller that
|
|
330
|
+
does many upserts under one `with q.lock:` (a scan of a huge BOM) reads the folder once, not once per part."""
|
|
331
|
+
if self.lock.cache is None:
|
|
332
|
+
cache = {}
|
|
333
|
+
for f in self.items_dir.glob("*.json"):
|
|
334
|
+
try:
|
|
335
|
+
cache[f.name] = json.loads(f.read_text(encoding="utf-8"))
|
|
336
|
+
except (OSError, json.JSONDecodeError):
|
|
337
|
+
continue # a damaged file must not hide the rest
|
|
338
|
+
self.lock.cache = cache
|
|
339
|
+
return self.lock.cache
|
|
340
|
+
|
|
341
|
+
def _put(self, it):
|
|
342
|
+
_write(self._item_path(it["id"]), it)
|
|
343
|
+
if self.lock.cache is not None:
|
|
344
|
+
self.lock.cache[f"{it['id']}.json"] = copy.deepcopy(it)
|
|
345
|
+
|
|
346
|
+
def find(self, key, open_only=False):
|
|
347
|
+
"""The newest item for a part key (open ones first when open_only is False), or None. `created` is to the
|
|
348
|
+
second and ids sort at random within one, so of two items made in the same second a pushed one counts as the
|
|
349
|
+
older: a part gets a new item only once its last one was pushed (else upsert would make a new item beside a
|
|
350
|
+
failed / rejected one, and a retry of that one would leave two open)."""
|
|
351
|
+
with self.lock:
|
|
352
|
+
items = sorted((it for it in self._all().values() if it.get("key") == key),
|
|
353
|
+
key=lambda it: (it.get("created") or "", it.get("status") != "pushed", it["id"]), reverse=True)
|
|
354
|
+
live = [it for it in items if it.get("status") in OPEN]
|
|
355
|
+
hit = (live or ([] if open_only else items) or [None])[0]
|
|
356
|
+
return copy.deepcopy(hit)
|
|
357
|
+
|
|
358
|
+
def upsert(self, fields, user=None):
|
|
359
|
+
"""Create or update the item for a part. fields: a watch Change ({"key","mpn","manufacturer","kind","source",
|
|
360
|
+
...}) or item fields ({"mpn","manufacturer","origin","change", ...}). Returns the item."""
|
|
361
|
+
if not isinstance(fields, dict):
|
|
362
|
+
raise ValueError("upsert needs a dict")
|
|
363
|
+
fields = dict(fields)
|
|
364
|
+
is_change = "kind" in fields and "source" in fields and "change" not in fields
|
|
365
|
+
change = fields if is_change else fields.get("change")
|
|
366
|
+
mpn = fields.get("mpn") or (change or {}).get("mpn")
|
|
367
|
+
mfr = fields.get("manufacturer") if "manufacturer" in fields else (change or {}).get("manufacturer")
|
|
368
|
+
key = part_key(mfr, mpn)
|
|
369
|
+
if fields.get("key") and renorm_key(fields["key"]) != key:
|
|
370
|
+
raise ValueError(f"key {fields['key']!r} does not match manufacturer/MPN ({key})")
|
|
371
|
+
origin = fields.get("origin") or ("watch" if is_change else "manual")
|
|
372
|
+
if origin not in ORIGINS:
|
|
373
|
+
raise ValueError(f"origin must be one of {', '.join(ORIGINS)}")
|
|
374
|
+
extra = {} if is_change else {k: v for k, v in fields.items()
|
|
375
|
+
if k not in FIXED + ("mpn", "manufacturer", "origin", "change")}
|
|
376
|
+
t = now()
|
|
377
|
+
with self.lock:
|
|
378
|
+
self.migrate_keys()
|
|
379
|
+
key = self._resolve_blank(key, mfr, t, user)
|
|
380
|
+
it = self.find(key)
|
|
381
|
+
if it and it["status"] != "pushed":
|
|
382
|
+
same = _strip_time(it.get("change")) == _strip_time(change)
|
|
383
|
+
if change is not None:
|
|
384
|
+
it["change"] = change
|
|
385
|
+
it.update(extra)
|
|
386
|
+
if it["status"] in OPEN:
|
|
387
|
+
it["history"].append({"at": t, "from": it["status"], "to": it["status"], "user": user,
|
|
388
|
+
"note": "re-detected" + ("" if same else " with a different change")})
|
|
389
|
+
elif not same:
|
|
390
|
+
it["history"].append({"at": t, "from": it["status"], "to": "detected", "user": user,
|
|
391
|
+
"note": "re-detected with a different change: retry"})
|
|
392
|
+
it["status"] = "detected"
|
|
393
|
+
it["updated"] = t
|
|
394
|
+
self._put(it)
|
|
395
|
+
return it
|
|
396
|
+
i = self._new_id(self.items_dir)
|
|
397
|
+
it = {"id": i, "key": key, "mpn": str(mpn).strip(), "manufacturer": str(mfr or "").strip(), "origin": origin,
|
|
398
|
+
"change": change, "status": "detected", "build": {}, "proof": None,
|
|
399
|
+
"pictures": {"before": [], "after": []}, "batch": None, "pr_url": None, "created": t, "updated": t,
|
|
400
|
+
"history": [{"at": t, "from": None, "to": "detected", "user": user,
|
|
401
|
+
"note": f"{(change or {}).get('kind') or 'added'} ({origin})"}]}
|
|
402
|
+
it.update(extra)
|
|
403
|
+
self._put(it)
|
|
404
|
+
return it
|
|
405
|
+
|
|
406
|
+
def _makers(self, mpn_part):
|
|
407
|
+
"""{manufacturer key part: [items]} of the items for one MPN."""
|
|
408
|
+
out = {}
|
|
409
|
+
for it in self._all().values():
|
|
410
|
+
m, _, n = str(it.get("key") or "").partition("|")
|
|
411
|
+
if n == mpn_part:
|
|
412
|
+
out.setdefault(m, []).append(it)
|
|
413
|
+
return out
|
|
414
|
+
|
|
415
|
+
def _resolve_blank(self, key, mfr, t, user):
|
|
416
|
+
"""A blank manufacturer matches the one known maker of that MPN (and an item first seen without one is
|
|
417
|
+
re-keyed once its maker is known), so "|LM358" and "ti|LM358" are one part. Two known makers: kept apart."""
|
|
418
|
+
m, _, n = key.partition("|")
|
|
419
|
+
makers = self._makers(n)
|
|
420
|
+
known = [x for x in makers if x]
|
|
421
|
+
if not m:
|
|
422
|
+
return f"{known[0]}|{n}" if len(known) == 1 else key
|
|
423
|
+
if "" in makers and set(known) <= {m}:
|
|
424
|
+
for it in makers[""]:
|
|
425
|
+
it = copy.deepcopy(it)
|
|
426
|
+
it["history"].append({"at": t, "from": it["status"], "to": it["status"], "user": user,
|
|
427
|
+
"note": f"key {it['key']} -> {key}: manufacturer now known"})
|
|
428
|
+
it["key"], it["updated"] = key, t
|
|
429
|
+
it["manufacturer"] = it.get("manufacturer") or str(mfr or "").strip()
|
|
430
|
+
self._put(it)
|
|
431
|
+
return key
|
|
432
|
+
|
|
433
|
+
def migrate_keys(self):
|
|
434
|
+
"""Once per queue: re-key items made before manufacturer names were normalised (and blank makers that
|
|
435
|
+
match one known maker). Items that become the same part are merged: the one furthest along stays open,
|
|
436
|
+
the other open ones are rejected by the agent as duplicates (their history says of what)."""
|
|
437
|
+
marker = self.dir / "keys.json"
|
|
438
|
+
if getattr(self.lock, "migrated", None) == (aliases_sig(), marker.exists()):
|
|
439
|
+
return 0 # checked in this process already
|
|
440
|
+
with self.lock:
|
|
441
|
+
with contextlib.suppress(OSError, ValueError):
|
|
442
|
+
if json.loads(marker.read_text()).get("aliases") == aliases_sig():
|
|
443
|
+
self.lock.migrated = (aliases_sig(), True)
|
|
444
|
+
return 0 # done for this alias table
|
|
445
|
+
t, moved = now(), 0
|
|
446
|
+
items = list(self._all().values())
|
|
447
|
+
for it in items:
|
|
448
|
+
m, _, n = str(it.get("key") or "").partition("|")
|
|
449
|
+
new = f"{norm_manufacturer(it.get('manufacturer') or m)}|{n}" # the name as written, if known
|
|
450
|
+
if new != it.get("key"):
|
|
451
|
+
it = copy.deepcopy(it)
|
|
452
|
+
it["history"].append({"at": t, "from": it["status"], "to": it["status"], "user": "ecadlib-agent",
|
|
453
|
+
"note": f"key {it['key']} -> {new}: manufacturer names normalised"})
|
|
454
|
+
it["key"], it["updated"] = new, t
|
|
455
|
+
self._put(it)
|
|
456
|
+
moved += 1
|
|
457
|
+
for it in list(self._all().values()): # blank makers matching one known maker
|
|
458
|
+
m, _, n = it["key"].partition("|")
|
|
459
|
+
if not m:
|
|
460
|
+
known = [x for x in self._makers(n) if x]
|
|
461
|
+
if len(known) == 1:
|
|
462
|
+
it = copy.deepcopy(it)
|
|
463
|
+
it["history"].append({"at": t, "from": it["status"], "to": it["status"], "user": "ecadlib-agent",
|
|
464
|
+
"note": f"key {it['key']} -> {known[0]}|{n}: one known manufacturer"})
|
|
465
|
+
it["key"], it["updated"] = f"{known[0]}|{n}", t
|
|
466
|
+
self._put(it)
|
|
467
|
+
moved += 1
|
|
468
|
+
rank = {"approved": 5, "review": 4, "proving": 3, "building": 2, "detected": 1}
|
|
469
|
+
groups = {}
|
|
470
|
+
for it in self._all().values():
|
|
471
|
+
if it["status"] in OPEN:
|
|
472
|
+
groups.setdefault(it["key"], []).append(it)
|
|
473
|
+
for key, its in groups.items():
|
|
474
|
+
if len(its) < 2:
|
|
475
|
+
continue
|
|
476
|
+
its.sort(key=lambda x: (rank[x["status"]], x.get("created") or "", x["id"]), reverse=True)
|
|
477
|
+
for dup in its[1:]:
|
|
478
|
+
self.transition(dup["id"], "rejected", user="ecadlib-agent",
|
|
479
|
+
note=f"duplicate of {its[0]['id']} (same part once manufacturer names are normalised)")
|
|
480
|
+
_write(marker, {"version": 2, "migrated": t, "rekeyed": moved, "aliases": aliases_sig()})
|
|
481
|
+
self.lock.migrated = (aliases_sig(), True)
|
|
482
|
+
return moved
|
|
483
|
+
|
|
484
|
+
def update(self, i, **fields):
|
|
485
|
+
"""Change data fields (build, proof, pictures, pr_url, ...). The state only changes through transition()."""
|
|
486
|
+
bad = set(fields) & set(FIXED)
|
|
487
|
+
if bad:
|
|
488
|
+
raise ValueError(f"{', '.join(sorted(bad))} cannot be changed with update(); use transition()")
|
|
489
|
+
with self.lock:
|
|
490
|
+
it = self.get(i)
|
|
491
|
+
it.update(fields)
|
|
492
|
+
it["updated"] = now()
|
|
493
|
+
self._put(it)
|
|
494
|
+
return it
|
|
495
|
+
|
|
496
|
+
def allowed(self, i_or_item):
|
|
497
|
+
it = i_or_item if isinstance(i_or_item, dict) else self.get(i_or_item)
|
|
498
|
+
return list(NEXT.get(it["status"], ()))
|
|
499
|
+
|
|
500
|
+
def transition(self, i, to, user=None, note="", **fields):
|
|
501
|
+
"""Move an item to state `to` (ValueError on an illegal move); appends to its history. fields are stored too."""
|
|
502
|
+
if to not in STATES:
|
|
503
|
+
raise ValueError(f"state must be one of {', '.join(STATES)}")
|
|
504
|
+
bad = set(fields) & set(FIXED)
|
|
505
|
+
if bad:
|
|
506
|
+
raise ValueError(f"{', '.join(sorted(bad))} cannot be set here")
|
|
507
|
+
with self.lock:
|
|
508
|
+
it = self.get(i)
|
|
509
|
+
if to not in NEXT[it["status"]]:
|
|
510
|
+
raise ValueError(f"{it['id']}: cannot go from {it['status']} to {to}"
|
|
511
|
+
+ (f" (allowed: {', '.join(NEXT[it['status']])})" if NEXT[it["status"]] else ""))
|
|
512
|
+
if to in ("approved", "rejected") and not (isinstance(user, str) and user.strip()):
|
|
513
|
+
raise ValueError(f"{to}: say who decides (user)")
|
|
514
|
+
if to == "approved" and _who_key(user) in {_who_key(b) for b in builders(it)}:
|
|
515
|
+
raise PermissionError(f"{user} started this build and cannot approve it (no self-approval)")
|
|
516
|
+
t = now()
|
|
517
|
+
it["history"].append({"at": t, "from": it["status"], "to": to, "user": user, "note": str(note or "")})
|
|
518
|
+
it.update(fields)
|
|
519
|
+
it["status"], it["updated"] = to, t
|
|
520
|
+
self._put(it)
|
|
521
|
+
return it
|
|
522
|
+
|
|
523
|
+
# -- batches
|
|
524
|
+
def batch(self, ids, **fields):
|
|
525
|
+
"""Group items (one PR per batch). Each item's `batch` is set; an item may be in one batch at a time."""
|
|
526
|
+
ids = [check_id(i) for i in ids]
|
|
527
|
+
if not ids:
|
|
528
|
+
raise ValueError("a batch needs at least one item")
|
|
529
|
+
if set(fields) & {"id", "items", "created"}:
|
|
530
|
+
raise ValueError("id, items and created are set by batch()")
|
|
531
|
+
with self.lock:
|
|
532
|
+
items = [self.get(i) for i in ids] # all exist before anything is written
|
|
533
|
+
t = now()
|
|
534
|
+
b = {"id": self._new_id(self.batches_dir), "items": ids, "created": t, "updated": t, "pr_url": None,
|
|
535
|
+
"status": "open", **fields}
|
|
536
|
+
_write(self.batches_dir / f"{b['id']}.json", b)
|
|
537
|
+
for it in items:
|
|
538
|
+
it["batch"], it["updated"] = b["id"], t
|
|
539
|
+
if b.get("pr_url"):
|
|
540
|
+
it["pr_url"] = b["pr_url"]
|
|
541
|
+
self._put(it)
|
|
542
|
+
return b
|
|
543
|
+
|
|
544
|
+
def get_batch(self, b):
|
|
545
|
+
with self.lock:
|
|
546
|
+
return self._load(self.batches_dir / f"{check_id(b)}.json", "batch")
|
|
547
|
+
|
|
548
|
+
def update_batch(self, b, **fields):
|
|
549
|
+
"""Change a batch (status, pr_url, ...); a new pr_url is copied to its items."""
|
|
550
|
+
if set(fields) & {"id", "items", "created"}:
|
|
551
|
+
raise ValueError("id, items and created cannot be changed")
|
|
552
|
+
with self.lock:
|
|
553
|
+
bt = self.get_batch(b)
|
|
554
|
+
bt.update(fields)
|
|
555
|
+
bt["updated"] = now()
|
|
556
|
+
_write(self.batches_dir / f"{bt['id']}.json", bt)
|
|
557
|
+
if "pr_url" in fields:
|
|
558
|
+
for i in bt["items"]:
|
|
559
|
+
with contextlib.suppress(KeyError):
|
|
560
|
+
self.update(i, pr_url=fields["pr_url"])
|
|
561
|
+
return bt
|
|
562
|
+
|
|
563
|
+
def batches(self):
|
|
564
|
+
out = []
|
|
565
|
+
with self.lock:
|
|
566
|
+
for f in self.batches_dir.glob("*.json"):
|
|
567
|
+
with contextlib.suppress(OSError, json.JSONDecodeError):
|
|
568
|
+
out.append(json.loads(f.read_text(encoding="utf-8")))
|
|
569
|
+
return sorted(out, key=lambda b: (b.get("created") or "", b["id"]), reverse=True)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _strip_time(change):
|
|
573
|
+
"""A change without its detection time and the part identity (equal anyway once the key matches)."""
|
|
574
|
+
return {k: v for k, v in change.items() if k not in ("detected", "key", "mpn", "manufacturer")} \
|
|
575
|
+
if isinstance(change, dict) else change
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# ---------------------------------------------------------------- front-end helpers (Workspace methods call these)
|
|
579
|
+
def _who_key(who):
|
|
580
|
+
from .approvals import user_key
|
|
581
|
+
return user_key(who) if who else ""
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def builders(item):
|
|
585
|
+
b = item.get("build") or {}
|
|
586
|
+
return [x for x in (b.get("user"), b.get("requested_by")) if isinstance(x, str) and x.strip()]
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def summary(it):
|
|
590
|
+
p = it.get("proof") or {}
|
|
591
|
+
ch = it.get("change") or {}
|
|
592
|
+
return {"id": it["id"], "key": it["key"], "mpn": it.get("mpn"), "manufacturer": it.get("manufacturer"),
|
|
593
|
+
"origin": it.get("origin"), "status": it["status"], "kind": ch.get("kind") or ch.get("check") or "",
|
|
594
|
+
"source": (ch.get("source") or {}).get("name") or (ch.get("source") or {}).get("ref") or "",
|
|
595
|
+
"verdict": p.get("verdict"), "score": p.get("score"), "findings": len(p.get("findings") or []),
|
|
596
|
+
"cross_check": ((it.get("build") or {}).get("cross_check") or {}).get("status", ""),
|
|
597
|
+
"batch": it.get("batch"), "pr_url": it.get("pr_url"), "created": it.get("created"), "updated": it.get("updated")}
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def human_summary(it):
|
|
601
|
+
"""One plain line about a queue item for people (shared voice): what it is, its state, the independent check."""
|
|
602
|
+
from . import messages as M
|
|
603
|
+
s = summary(it)
|
|
604
|
+
what = {"new": "new part", "changed": "changed part", "removed": "removed part"}.get(s["kind"], s["kind"] or "part")
|
|
605
|
+
line = f"{s['mpn'] or s['key']}: {what}" + (f" from {s['source']}" if s["source"] else "") + f", {M.status_word(s['status'])}"
|
|
606
|
+
if s["verdict"]:
|
|
607
|
+
line += f"; independent check: {s['verdict']}" + (f" ({M.plural(s['findings'], 'finding')})" if s["findings"] else "")
|
|
608
|
+
if s["cross_check"] in ("disagreed", "unchecked"):
|
|
609
|
+
line += f"; cross-check {s['cross_check']}"
|
|
610
|
+
return line
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def picture_path(workspace_root, path):
|
|
614
|
+
"""A picture the review page may show: an image file inside <workspace>/agent/ (after resolving links), else
|
|
615
|
+
ValueError. path: workspace-relative, or absolute."""
|
|
616
|
+
root = Path(workspace_root).resolve()
|
|
617
|
+
agent = (root / "agent").resolve()
|
|
618
|
+
if not isinstance(path, str) or not path.strip() or "\0" in path or len(path) > 1000:
|
|
619
|
+
raise ValueError("no picture path given")
|
|
620
|
+
p = (root / path).resolve()
|
|
621
|
+
if agent not in p.parents:
|
|
622
|
+
raise ValueError("pictures are served from the workspace's agent/ folder only")
|
|
623
|
+
if p.suffix.lower() not in PICTURE_TYPES:
|
|
624
|
+
raise ValueError("not a picture (png, jpg, gif or webp)")
|
|
625
|
+
if not p.is_file():
|
|
626
|
+
raise ValueError("no such picture")
|
|
627
|
+
return p
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _pictures(root, it):
|
|
631
|
+
"""Workspace-relative paths of the item's pictures that the page can show (inside agent/), per group."""
|
|
632
|
+
groups = {"before": (it.get("pictures") or {}).get("before"), "after": (it.get("pictures") or {}).get("after"),
|
|
633
|
+
"ours": ((it.get("proof") or {}).get("images") or {}).get("ours"),
|
|
634
|
+
"datasheet": ((it.get("proof") or {}).get("images") or {}).get("datasheet")}
|
|
635
|
+
out = {}
|
|
636
|
+
for g, paths in groups.items():
|
|
637
|
+
out[g] = []
|
|
638
|
+
for p in paths or []:
|
|
639
|
+
try:
|
|
640
|
+
out[g].append(picture_path(root, str(p)).relative_to(Path(root).resolve()).as_posix())
|
|
641
|
+
except ValueError:
|
|
642
|
+
out[g].append(None) # listed but not showable: the page says so
|
|
643
|
+
return out
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def ws_list(root, status=None, batch=None):
|
|
647
|
+
q = Queue(root)
|
|
648
|
+
items = q.list(status or None, batch or None)
|
|
649
|
+
counts = {s: 0 for s in STATES}
|
|
650
|
+
for it in q.list():
|
|
651
|
+
counts[it["status"]] = counts.get(it["status"], 0) + 1
|
|
652
|
+
items.sort(key=lambda it: it["status"] != "review") # stable: waiting for a person first, then newest first
|
|
653
|
+
out = {"items": [summary(it) for it in items], "counts": counts, "states": list(STATES),
|
|
654
|
+
"batches": [{k: b.get(k) for k in ("id", "created", "status", "pr_url")} | {"count": len(b.get("items") or [])}
|
|
655
|
+
for b in q.batches()]}
|
|
656
|
+
if batch:
|
|
657
|
+
out["batch"] = q.get_batch(batch)
|
|
658
|
+
return out
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def ws_get(root, i):
|
|
662
|
+
q = Queue(root)
|
|
663
|
+
it = q.get(i)
|
|
664
|
+
b = None
|
|
665
|
+
if it.get("batch"):
|
|
666
|
+
with contextlib.suppress(KeyError, ValueError):
|
|
667
|
+
b = q.get_batch(it["batch"])
|
|
668
|
+
return {"item": it, "pictures": _pictures(root, it), "allowed": q.allowed(it), "batch": b, "build_token": build_token(it)}
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
ACTIONS = ("approve", "reject", "rebuild", "retry")
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def decide(ws, i, action, user, role=None, note="", via="ecadlib ui/mcp", aliases=(), expect=None):
|
|
675
|
+
"""A person's decision on a queue item: approve (review -> approved), reject (open -> rejected), rebuild
|
|
676
|
+
(review -> building) or retry (failed/rejected -> detected). approve/reject are also recorded through approvals.py
|
|
677
|
+
in the item's librarian plan (build.bom + build.part) so release_check and push see them; a refusal there stops an
|
|
678
|
+
approve. user: who decides (required); aliases: other names of the same person (team user name), used for the
|
|
679
|
+
no-self-approval check against build.user / build.requested_by. expect: the build_token() of the build the person
|
|
680
|
+
looked at; an approve / reject is refused when the item was rebuilt since (the agent rebuilds review items whose
|
|
681
|
+
source changed). Returns {"item", "approval"}."""
|
|
682
|
+
if action not in ACTIONS:
|
|
683
|
+
raise ValueError(f"action must be one of {', '.join(ACTIONS)}")
|
|
684
|
+
user = user.strip() if isinstance(user, str) else ""
|
|
685
|
+
if not user:
|
|
686
|
+
raise ValueError("say who you are: user is required (e.g. \"Ann Lee <ann@example.com>\")")
|
|
687
|
+
if len(user) > 200 or any(c in user for c in "\r\n\0"):
|
|
688
|
+
raise ValueError("user must be one line of at most 200 characters")
|
|
689
|
+
note = note if isinstance(note, str) else ""
|
|
690
|
+
if len(note) > 2000:
|
|
691
|
+
raise ValueError("note: at most 2000 characters")
|
|
692
|
+
if action == "reject" and not note.strip():
|
|
693
|
+
raise ValueError("a reject needs a note saying what is wrong")
|
|
694
|
+
q = Queue(ws.root)
|
|
695
|
+
with q.lock:
|
|
696
|
+
it = q.get(i)
|
|
697
|
+
to = {"approve": "approved", "reject": "rejected", "rebuild": "building", "retry": "detected"}[action]
|
|
698
|
+
if action == "approve" and it["status"] != "review":
|
|
699
|
+
raise ValueError(f"only items in review can be approved; this one is {it['status']}")
|
|
700
|
+
if action == "rebuild" and it["status"] not in ("review",):
|
|
701
|
+
raise ValueError(f"rebuild works on items in review (use retry for failed or rejected); this one is {it['status']}")
|
|
702
|
+
if to not in NEXT[it["status"]]:
|
|
703
|
+
raise ValueError(f"cannot {action} an item that is {it['status']}")
|
|
704
|
+
if expect is not None and action in ("approve", "reject") and str(expect) != build_token(it):
|
|
705
|
+
raise ValueError("this item was rebuilt since you opened it: look at the new build, then decide again")
|
|
706
|
+
if action == "approve":
|
|
707
|
+
me = {_who_key(user)} | {_who_key(a) for a in aliases if a}
|
|
708
|
+
if any(_who_key(b) in me for b in builders(it)):
|
|
709
|
+
raise PermissionError(f"{user} started this build and cannot approve it (no self-approval)")
|
|
710
|
+
approval = None
|
|
711
|
+
b = it.get("build") or {}
|
|
712
|
+
if action in ("approve", "reject"):
|
|
713
|
+
if b.get("bom") and b.get("part"):
|
|
714
|
+
with plan_lock(ws.root, wait=False) as free:
|
|
715
|
+
if not free: # the librarian is rewriting the plan: a decision written now would be lost
|
|
716
|
+
raise ValueError("the library agent is rebuilding the library right now; decide again in a minute")
|
|
717
|
+
try:
|
|
718
|
+
rec_ = getattr(ws, "_approve_part", None) or ws.approve_part # this lock is held here
|
|
719
|
+
r = rec_(b["bom"], b["part"], action, user, role, note, via=via + " (agent queue)")
|
|
720
|
+
approval = {"recorded": True, **r["decision"]}
|
|
721
|
+
except ValueError as e:
|
|
722
|
+
if action == "approve":
|
|
723
|
+
raise ValueError(f"the library plan refused this approval: {e}") from e
|
|
724
|
+
approval = {"recorded": False, "reason": str(e)}
|
|
725
|
+
else:
|
|
726
|
+
approval = {"recorded": False, "reason": "no librarian plan for this item (build.bom / build.part): "
|
|
727
|
+
"decision kept in the queue only"}
|
|
728
|
+
extra = {}
|
|
729
|
+
if action == "approve":
|
|
730
|
+
extra["approval"] = {"by": user, "role": role or "", "at": now(), "build": build_token(it), **(approval or {})}
|
|
731
|
+
if action == "reject":
|
|
732
|
+
extra["rejection"] = {"by": user, "at": now(), "note": note, **(approval or {})}
|
|
733
|
+
if action in ("rebuild", "retry"):
|
|
734
|
+
extra["build"] = {**b, "requested_by": user, "requested_at": now(), "request_note": note}
|
|
735
|
+
it = q.transition(i, to, user=user, note=note, **extra)
|
|
736
|
+
return {"item": it, "approval": approval}
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
# ---------------------------------------------------------------- CLI
|
|
740
|
+
def main(argv=None):
|
|
741
|
+
ap = argparse.ArgumentParser(prog="ecadlib queue", description="the library agent's work queue")
|
|
742
|
+
ap.add_argument("--workspace", default="ecadlib_workspace")
|
|
743
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
744
|
+
p = sub.add_parser("list", help="items, newest first")
|
|
745
|
+
p.add_argument("--status", choices=STATES)
|
|
746
|
+
p.add_argument("--batch")
|
|
747
|
+
p.add_argument("--json", action="store_true", help="print the items' summaries as JSON (for scripts)")
|
|
748
|
+
p = sub.add_parser("show", help="one item as JSON")
|
|
749
|
+
p.add_argument("id")
|
|
750
|
+
for a in ACTIONS:
|
|
751
|
+
p = sub.add_parser(a, help=f"{a} an item")
|
|
752
|
+
p.add_argument("id")
|
|
753
|
+
p.add_argument("--user", help="who you are (default: $ECADLIB_USER, then git config)")
|
|
754
|
+
p.add_argument("--role")
|
|
755
|
+
p.add_argument("-m", "--note", default="")
|
|
756
|
+
a = ap.parse_args(argv)
|
|
757
|
+
root = Path(a.workspace).resolve()
|
|
758
|
+
try:
|
|
759
|
+
if a.cmd == "list":
|
|
760
|
+
items = Queue(root).list(a.status, a.batch)
|
|
761
|
+
if a.json:
|
|
762
|
+
print(json.dumps([summary(i) for i in items], indent=2, default=str))
|
|
763
|
+
return 0
|
|
764
|
+
from .term_views import queue_list
|
|
765
|
+
queue_list(items, summary, Queue(root).dir)
|
|
766
|
+
elif a.cmd == "show":
|
|
767
|
+
print(json.dumps(Queue(root).get(a.id), indent=2))
|
|
768
|
+
else:
|
|
769
|
+
from .app import Workspace
|
|
770
|
+
from .approvals import identity
|
|
771
|
+
r = decide(Workspace(root), a.id, a.cmd, identity(a.user, cwd=root), a.role, a.note, via="ecadlib queue")
|
|
772
|
+
ap_ = r["approval"]
|
|
773
|
+
print(f"{a.id}: {r['item']['status']}" + (f"; sign-off: {'recorded' if ap_['recorded'] else ap_['reason']}" if ap_ else ""))
|
|
774
|
+
except (ValueError, KeyError, PermissionError) as e:
|
|
775
|
+
sys.exit(f"ecadlib queue: {e.args[0] if e.args else e}")
|
|
776
|
+
return 0
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
if __name__ == "__main__":
|
|
780
|
+
sys.exit(main())
|