ecadlib 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/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())