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/upkeep.py
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
"""Weekly upkeep: run the lifecycle, upstream, price/stock and Altium 365 drift checks in one go, report only what is
|
|
2
|
+
NEW since the last run, and open (or update) one GitHub issue or pull request listing it.
|
|
3
|
+
|
|
4
|
+
python -m ecadlib upkeep PLAN|BOM [--checks lifecycle,upstream,sourcing,a365] [--state DIR] [-o REPORT]
|
|
5
|
+
[--manifest models/manifest.json] [--bom BOM] [--workspace URL] [--apply] [--github issue|pr] [--repo OWNER/NAME]
|
|
6
|
+
|
|
7
|
+
Checks (each runs in-process; a check whose credentials are missing is skipped with a line saying so, never failed):
|
|
8
|
+
lifecycle lifecycle.py against Nexar / DigiKey / Mouser (needs at least one of NEXAR_CLIENT_ID+NEXAR_CLIENT_SECRET or
|
|
9
|
+
NEXAR_SUPPLY_TOKEN, DIGIKEY_CLIENT_ID+DIGIKEY_CLIENT_SECRET, MOUSER_API_KEY). New: a part became NRND, last
|
|
10
|
+
time buy or EOL/obsolete, stock fell to zero, a part recovered, or a newly listed part is already risky.
|
|
11
|
+
upstream upstream.check_kicad on the KiCad provenance manifest (kicad_import writes <out>/manifest.json; found next to
|
|
12
|
+
the plan, in <plan dir>/models/, or given with --manifest). GITLAB_TOKEN optional. New: a KiCad source
|
|
13
|
+
changed, was renamed or deleted since the last report. --apply reconverts parts whose sources *changed*
|
|
14
|
+
(not renamed/deleted: those need a person) so a PR carries the real library diff.
|
|
15
|
+
sourcing sourcing.py (Nexar / DigiKey / Mouser with credentials, JLCPCB without). New: the best unit price for a line
|
|
16
|
+
rose by more than the threshold (default 20 %, [upkeep] price_jump_pct), a line went out of stock at
|
|
17
|
+
every distributor, or came back.
|
|
18
|
+
a365 upstream.check_a365 against an Altium 365 workspace (NEXAR_TOKEN and --workspace, or [altium365] workspace
|
|
19
|
+
in the rules). New: a drift row (missing, parameter, folder, symbol/footprint, revision) not reported before.
|
|
20
|
+
|
|
21
|
+
State (--state DIR, default out/upkeep_state): one JSON file per check; what each run saw, so the next run reports only
|
|
22
|
+
transitions. Report: <REPORT>.md and <REPORT>.json (default out/upkeep). Exit codes: 0 nothing new, 1 new changes,
|
|
23
|
+
2 error (a check failed, bad input, or the GitHub step failed; the report is still written, state is not saved, so the
|
|
24
|
+
next run reports the same changes again).
|
|
25
|
+
|
|
26
|
+
GitHub (--github issue|pr; only when something is new): GITHUB_TOKEN + GITHUB_REPOSITORY (or --repo) uses the REST API,
|
|
27
|
+
otherwise the `gh` CLI (`gh api`). issue: one rolling issue labelled `ecadlib-upkeep`; it is found by the label and its
|
|
28
|
+
body replaced (plus a short comment so watchers are notified), never duplicated. pr: needs --apply with real changes;
|
|
29
|
+
commits the changed library files to branch `ecadlib/upkeep-YYYY-MM-DD`, pushes it and opens (or updates) the PR; with
|
|
30
|
+
nothing to commit it falls back to the issue.
|
|
31
|
+
|
|
32
|
+
GitLab (--gitlab issue|pr): the same rolling issue or a merge request through GitLab REST v4 (ecadlib/forge.py); token
|
|
33
|
+
from $GITLAB_TOKEN (or [agent] gitlab_token_env), project from --gitlab-project / CI_PROJECT_PATH / [agent]
|
|
34
|
+
gitlab_project / the origin remote, API from --gitlab-api / CI_API_V4_URL / [agent] gitlab_api (self-hosted works).
|
|
35
|
+
|
|
36
|
+
Notifications: with [notify] targets in the rules, a run that found new changes (and saved its state) posts an
|
|
37
|
+
upkeep_changes event, and a lifecycle event for parts that went EOL / NRND / last time buy (ecadlib/notify.py).
|
|
38
|
+
|
|
39
|
+
Rules (ecadlib.toml, optional): [upkeep] checks = [...], schedule = "weekly", price_jump_pct = 20, build_qty = 1,
|
|
40
|
+
currency = "USD"; [altium365] workspace = "https://..."."""
|
|
41
|
+
import argparse, hashlib, json, os, shutil, subprocess, sys
|
|
42
|
+
from datetime import datetime, timezone
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
CHECKS = ("lifecycle", "upstream", "sourcing", "a365")
|
|
46
|
+
LABEL = "ecadlib-upkeep"
|
|
47
|
+
DEFAULTS = {"schedule": "weekly", "checks": list(CHECKS), "price_jump_pct": 20.0, "build_qty": 1, "currency": "USD"}
|
|
48
|
+
LIFECYCLE_ENV = "NEXAR_CLIENT_ID+NEXAR_CLIENT_SECRET (or NEXAR_SUPPLY_TOKEN), DIGIKEY_CLIENT_ID+DIGIKEY_CLIENT_SECRET, MOUSER_API_KEY"
|
|
49
|
+
BODY_MAX = 60000 # GitHub issue/PR bodies stop at 65536 characters
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _now():
|
|
53
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _fp(*parts):
|
|
57
|
+
return hashlib.sha256("\x1f".join(str(p) for p in parts).encode()).hexdigest()[:20]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------------ rules (ecadlib.rules when present)
|
|
61
|
+
def load_rules(path=None, start="."):
|
|
62
|
+
try:
|
|
63
|
+
from . import rules as RU
|
|
64
|
+
except ImportError:
|
|
65
|
+
RU = None
|
|
66
|
+
if RU is not None:
|
|
67
|
+
return RU.load(path, start=start)
|
|
68
|
+
if not path:
|
|
69
|
+
return {}
|
|
70
|
+
p = Path(path)
|
|
71
|
+
if p.suffix == ".toml":
|
|
72
|
+
import tomllib
|
|
73
|
+
return tomllib.loads(p.read_text(encoding="utf-8"))
|
|
74
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def upkeep_rules(rules):
|
|
78
|
+
u = dict(DEFAULTS)
|
|
79
|
+
u.update((rules or {}).get("upkeep") or {})
|
|
80
|
+
return u
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------ inputs
|
|
84
|
+
class Source:
|
|
85
|
+
"""The input: a plan.json (lifecycle, sourcing, a365) or a BOM (lifecycle, sourcing); plus where the manifest is."""
|
|
86
|
+
def __init__(self, path, manifest=None, bom=None, columns=None):
|
|
87
|
+
self.path, self.columns = Path(path), columns # columns: the rules' [bom] columns
|
|
88
|
+
if not self.path.is_file():
|
|
89
|
+
raise FileNotFoundError(f"input not found: {path}")
|
|
90
|
+
self.kind = "plan" if self.path.suffix.lower() == ".json" else "bom"
|
|
91
|
+
self.items = json.loads(self.path.read_text(encoding="utf-8")) if self.kind == "plan" else None
|
|
92
|
+
if self.items is not None and not isinstance(self.items, list):
|
|
93
|
+
raise ValueError(f"{path}: not an altium365 plan (expected a JSON list of items)")
|
|
94
|
+
self.bom = Path(bom) if bom else (self.path if self.kind == "bom" else None)
|
|
95
|
+
self.manifest = Path(manifest) if manifest else self._find_manifest()
|
|
96
|
+
|
|
97
|
+
def _find_manifest(self):
|
|
98
|
+
d = self.path.parent
|
|
99
|
+
for c in (d / "manifest.json", d / "models" / "manifest.json", d / "altium_models_kicad" / "manifest.json"):
|
|
100
|
+
if c.is_file():
|
|
101
|
+
return c
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def lines(self):
|
|
105
|
+
"""BOM lines for sourcing: the BOM when there is one, else one line per plan item."""
|
|
106
|
+
from .bom_triage import read_bom
|
|
107
|
+
if self.bom:
|
|
108
|
+
return read_bom(self.bom, self.columns)[0]
|
|
109
|
+
out = []
|
|
110
|
+
for it in self.items:
|
|
111
|
+
if it.get("status") == "duplicate":
|
|
112
|
+
continue
|
|
113
|
+
prm = {p["name"]: p["value"] for p in it.get("parameters", [])}
|
|
114
|
+
lcsc = prm.get("Supplier Part Number 1", "") if prm.get("Supplier 1", "").upper() in ("LCSC", "JLCPCB") else ""
|
|
115
|
+
out.append({"designator": "", "qty": "1", "mpn": prm.get("Manufacturer Part Number", ""),
|
|
116
|
+
"manufacturer": prm.get("Manufacturer", ""), "lcsc": lcsc, "value": prm.get("Value", ""),
|
|
117
|
+
"description": it.get("description", ""), "footprint": (it.get("footprint") or {}).get("ref", "")})
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Ctx:
|
|
122
|
+
"""Clients the checks use; tests pass fakes. http: CachedHTTP-like; gitlab: upstream.GitLab; design: Nexar Design
|
|
123
|
+
client with gql(); env: environment mapping (credential checks)."""
|
|
124
|
+
def __init__(self, http=None, gitlab=None, design=None, env=None, cache="out/http_cache", offline=False):
|
|
125
|
+
self._http, self._gitlab, self.design, self.env = http, gitlab, design, os.environ if env is None else env
|
|
126
|
+
self.cache, self.offline = cache, offline
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def http(self):
|
|
130
|
+
if self._http is None:
|
|
131
|
+
from .httpcache import CachedHTTP
|
|
132
|
+
self._http = CachedHTTP(self.cache, min_interval=1.0, ttl_s=12 * 3600, offline=self.offline)
|
|
133
|
+
return self._http
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def gitlab(self):
|
|
137
|
+
if self._gitlab is None:
|
|
138
|
+
from .upstream import GitLab
|
|
139
|
+
self._gitlab = GitLab(token=self.env.get("GITLAB_TOKEN"))
|
|
140
|
+
return self._gitlab
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _section(name, status="ok", reason=""):
|
|
144
|
+
return {"check": name, "status": status, "reason": reason, "changes": [], "resolved": [], "notes": [],
|
|
145
|
+
"applied": [], "_state": None}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _change(kind, item, was="", now="", detail=""):
|
|
149
|
+
return {"kind": kind, "item": item, "was": was, "now": now, "detail": detail}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _load(path, default):
|
|
153
|
+
try:
|
|
154
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
155
|
+
except FileNotFoundError:
|
|
156
|
+
return default
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------ lifecycle
|
|
160
|
+
def check_lifecycle(src, state_dir, ctx, cfg, now):
|
|
161
|
+
from . import lifecycle as L
|
|
162
|
+
sec = _section("lifecycle")
|
|
163
|
+
provs = [c(ctx.http) for c in L.PROVIDERS]
|
|
164
|
+
with _environ(ctx.env):
|
|
165
|
+
creds = [p.name for p in provs if p.name != "jlcpcb_search" and p.configured()]
|
|
166
|
+
if not creds:
|
|
167
|
+
sec.update(status="skipped", reason=f"no lifecycle credentials: set one of {LIFECYCLE_ENV}")
|
|
168
|
+
return sec
|
|
169
|
+
parts, skipped = L.merge(L.read_inputs([src.path] + ([src.bom] if src.bom and src.bom != src.path else []), src.columns))
|
|
170
|
+
if not parts:
|
|
171
|
+
sec.update(status="error", reason="no parts with an MPN or LCSC number in the input")
|
|
172
|
+
return sec
|
|
173
|
+
current, unconf = L.check_parts(parts, provs)
|
|
174
|
+
state = L.load_state(state_dir / "lifecycle_state.json")
|
|
175
|
+
rows = L.diff(state, current)
|
|
176
|
+
if not any(r["answered"] for r in rows):
|
|
177
|
+
sec.update(status="error", reason="no source answered for any part (quota, network or bad credentials)")
|
|
178
|
+
return sec
|
|
179
|
+
sev = lambda s: L.SEVERITY.get(s, -1)
|
|
180
|
+
desc = lambda s, k: f"{L.LABEL.get(s, s or '-')}, stock {'-' if k is None else k}"
|
|
181
|
+
for r in rows:
|
|
182
|
+
item, was, cur = r["mpn"] or r["lcsc"], desc(r["prev_status"], r["prev_stock"]), desc(r["status"], r["stock"])
|
|
183
|
+
if r["change"] == "worse":
|
|
184
|
+
kind = ({"nrnd": "NRND", "ltb": "last time buy", "obsolete": "EOL"}[r["status"]]
|
|
185
|
+
if r["status"] != "unknown" and sev(r["status"]) > max(sev(r["prev_status"]), 0) else "stock-out")
|
|
186
|
+
sec["changes"].append(_change(kind, item, was, cur, r["raw"]))
|
|
187
|
+
elif r["change"] == "better":
|
|
188
|
+
sec["changes"].append(_change("recovered", item, was, cur, r["raw"]))
|
|
189
|
+
elif r["change"] == "new" and L.risky(r):
|
|
190
|
+
sec["changes"].append(_change("new risky part", item, "(not watched)", cur, r["raw"]))
|
|
191
|
+
if unconf:
|
|
192
|
+
sec["notes"].append(f"not configured: {', '.join(unconf)}")
|
|
193
|
+
if skipped:
|
|
194
|
+
sec["notes"].append(f"skipped without MPN or LCSC: {', '.join(skipped)}")
|
|
195
|
+
sec["notes"].append(f"{len(rows)} parts checked" + ("" if state.get("updated") else " (first run: baseline recorded)"))
|
|
196
|
+
sec["_state"] = {"lifecycle_state.json": L.next_state(state, current, now)}
|
|
197
|
+
return sec
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class _environ:
|
|
201
|
+
"""Run provider code against ctx.env (tests pass a dict) without leaking it into os.environ afterwards."""
|
|
202
|
+
def __init__(self, env):
|
|
203
|
+
self.env = env
|
|
204
|
+
|
|
205
|
+
def __enter__(self):
|
|
206
|
+
if self.env is os.environ:
|
|
207
|
+
return
|
|
208
|
+
self.old = dict(os.environ)
|
|
209
|
+
os.environ.clear()
|
|
210
|
+
os.environ.update({k: v for k, v in self.env.items() if v is not None})
|
|
211
|
+
|
|
212
|
+
def __exit__(self, *exc):
|
|
213
|
+
if self.env is not os.environ:
|
|
214
|
+
os.environ.clear()
|
|
215
|
+
os.environ.update(self.old)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------------------ sourcing
|
|
219
|
+
def check_sourcing(src, state_dir, ctx, cfg, now):
|
|
220
|
+
from . import sourcing as S
|
|
221
|
+
from .kicad_index import norm_mpn
|
|
222
|
+
sec = _section("sourcing")
|
|
223
|
+
if src.kind == "plan" and not src.items:
|
|
224
|
+
sec.update(status="skipped", reason="the plan has no items")
|
|
225
|
+
return sec
|
|
226
|
+
cur_ccy, qty, pct = str(cfg["currency"]).upper(), int(cfg["build_qty"]), float(cfg["price_jump_pct"])
|
|
227
|
+
provs = [c(ctx.http) for c in S.PROVIDERS]
|
|
228
|
+
for p in provs:
|
|
229
|
+
p.currency = cur_ccy
|
|
230
|
+
with _environ(ctx.env):
|
|
231
|
+
creds = [p.name for p in provs if p.name != "jlcpcb_search" and p.configured()]
|
|
232
|
+
rows, unconf = S.source_bom(src.lines(), provs, qty, cur_ccy, False)
|
|
233
|
+
fitted = [r for r in rows if "dnp" not in r["flags"] and "merged" not in r["flags"]] # merged: bought on another line
|
|
234
|
+
if fitted and not any(r["answered"] for r in fitted):
|
|
235
|
+
if not creds:
|
|
236
|
+
sec.update(status="skipped", reason=f"no distributor answered and no credentials set ({LIFECYCLE_ENV})")
|
|
237
|
+
else:
|
|
238
|
+
sec.update(status="error", reason="no source answered for any line (quota, network or bad credentials)")
|
|
239
|
+
return sec
|
|
240
|
+
old = _load(state_dir / "sourcing_state.json", {"version": 1, "updated": None, "lines": {}})
|
|
241
|
+
first = old.get("updated") is None
|
|
242
|
+
lines = dict(old["lines"])
|
|
243
|
+
for r in fitted:
|
|
244
|
+
key = norm_mpn(r["mpn"]) if r["mpn"] else f"LCSC:{r['lcsc']}" if r["lcsc"] else ""
|
|
245
|
+
if not key or not r["answered"] or "not_found" in r["flags"]:
|
|
246
|
+
continue
|
|
247
|
+
c = r["chosen"] or {}
|
|
248
|
+
cur = {"mpn": r["mpn"] or r["lcsc"], "unit": c.get("unit"), "currency": cur_ccy, "distributor": c.get("distributor"),
|
|
249
|
+
"in_stock": r["distributors_in_stock"], "checked": now}
|
|
250
|
+
prev = old["lines"].get(key)
|
|
251
|
+
price = lambda d: "-" if d.get("unit") is None else f"{d['unit']:g} {d.get('currency', cur_ccy)} ({d.get('distributor') or '-'})"
|
|
252
|
+
if prev is None:
|
|
253
|
+
if cur["in_stock"] == 0 and not first:
|
|
254
|
+
sec["changes"].append(_change("stock-out", cur["mpn"], "(new line)", "no distributor has stock"))
|
|
255
|
+
elif cur["in_stock"] == 0:
|
|
256
|
+
sec["changes"].append(_change("stock-out", cur["mpn"], "(first run)", "no distributor has stock"))
|
|
257
|
+
else:
|
|
258
|
+
if prev.get("in_stock", 0) > 0 and cur["in_stock"] == 0:
|
|
259
|
+
sec["changes"].append(_change("stock-out", cur["mpn"], f"{prev['in_stock']} distributors", "none in stock"))
|
|
260
|
+
elif prev.get("in_stock") == 0 and cur["in_stock"] > 0:
|
|
261
|
+
sec["changes"].append(_change("back in stock", cur["mpn"], "none in stock", f"{cur['in_stock']} distributors"))
|
|
262
|
+
pu, cu = prev.get("unit"), cur["unit"]
|
|
263
|
+
if pu and cu is not None and prev.get("currency", cur_ccy) == cur_ccy and cu > pu * (1 + pct / 100):
|
|
264
|
+
sec["changes"].append(_change("price jump", cur["mpn"], price(prev), price(cur),
|
|
265
|
+
f"+{(cu / pu - 1) * 100:.0f}% (threshold {pct:g}%) at {r['need']} pcs"))
|
|
266
|
+
if cur["unit"] is None and prev and prev.get("currency", cur_ccy) == cur_ccy: # unpriced: keep the last price
|
|
267
|
+
cur.update(unit=prev.get("unit"), distributor=prev.get("distributor"))
|
|
268
|
+
lines[key] = cur
|
|
269
|
+
if unconf:
|
|
270
|
+
sec["notes"].append(f"not configured: {', '.join(unconf)}")
|
|
271
|
+
sec["notes"].append(f"{len(fitted)} lines priced for {qty} board(s) in {cur_ccy}" + (" (first run: baseline recorded)" if first else ""))
|
|
272
|
+
sec["_state"] = {"sourcing_state.json": {"version": 1, "updated": now, "lines": lines}}
|
|
273
|
+
return sec
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ------------------------------------------------------------------ KiCad upstream
|
|
277
|
+
def check_upstream(src, state_dir, ctx, cfg, now, apply=False):
|
|
278
|
+
from . import upstream as U
|
|
279
|
+
sec = _section("upstream")
|
|
280
|
+
if not src.manifest or not src.manifest.is_file():
|
|
281
|
+
sec.update(status="skipped", reason="no KiCad provenance manifest (kicad_import writes OUT/manifest.json; "
|
|
282
|
+
"pass --manifest)")
|
|
283
|
+
return sec
|
|
284
|
+
man = json.loads(src.manifest.read_text(encoding="utf-8"))
|
|
285
|
+
rows, worst = U.check_kicad(man, ctx.gitlab)
|
|
286
|
+
errs = [r for r in rows if r["status"] == "error"]
|
|
287
|
+
for r in errs:
|
|
288
|
+
sec["notes"].append(f"{r['entry']} {r['source']}: {r['summary']}")
|
|
289
|
+
if errs and len(errs) == len(rows):
|
|
290
|
+
sec.update(status="error", reason="every KiCad source failed: " + errs[0]["summary"])
|
|
291
|
+
return sec
|
|
292
|
+
old = _load(state_dir / "upstream_state.json", {"version": 1, "updated": None, "seen": {}})
|
|
293
|
+
seen, applied = {}, {}
|
|
294
|
+
if apply:
|
|
295
|
+
ch = [r for r in rows if r["status"] == "changed"]
|
|
296
|
+
if ch:
|
|
297
|
+
pins = {r["_src"]["url"]: r["_text"] for r in ch if r["_src"].get("url") and r["_text"] is not None}
|
|
298
|
+
todo = sorted({r["entry"] for r in ch})
|
|
299
|
+
done = U.reconvert([man["entries"][e] for e in todo], src.manifest.parent, U.Pinned(ctx.http, pins))
|
|
300
|
+
for r in rows:
|
|
301
|
+
rep = done.get(json.dumps(r["spec"], sort_keys=True))
|
|
302
|
+
if rep:
|
|
303
|
+
r["update"] = "reconverted" if rep.get("ok") else f"reconvert failed: {rep.get('error')}"
|
|
304
|
+
if rep.get("ok"):
|
|
305
|
+
applied[r["entry"]] = man["entries"][r["entry"]]["output"]
|
|
306
|
+
for r in rows:
|
|
307
|
+
if r["status"] in ("unchanged", "error"):
|
|
308
|
+
continue
|
|
309
|
+
key = _fp(r["entry"], r["source"], r["status"], hashlib.sha256((r["_text"] or r["summary"]).encode()).hexdigest())
|
|
310
|
+
if r.get("update") == "reconverted":
|
|
311
|
+
sec["changes"].append(_change("reconverted", r["entry"], r["source"], r["upstream_commit"] or "upstream", r["summary"]))
|
|
312
|
+
continue # the manifest now matches upstream: nothing left to remember
|
|
313
|
+
seen[key] = {"entry": r["entry"], "source": r["source"], "status": r["status"], "first_seen": old["seen"].get(key, {}).get("first_seen", now)}
|
|
314
|
+
if key not in old["seen"]:
|
|
315
|
+
sec["changes"].append(_change(f"KiCad {r['status']}", r["entry"], r["source"],
|
|
316
|
+
(r["upstream_commit"] + " " + r["upstream_date"]).strip() or r["status"],
|
|
317
|
+
r["summary"] + (f" [{r['update']}]" if r.get("update") else "")))
|
|
318
|
+
failed = {(r["entry"], r["source"]) for r in errs}
|
|
319
|
+
for k, v in old["seen"].items():
|
|
320
|
+
if k not in seen and (v["entry"], v["source"]) in failed:
|
|
321
|
+
seen[k] = v # could not check it this time: still outstanding
|
|
322
|
+
for k, v in old["seen"].items():
|
|
323
|
+
if k not in seen and not any(c["item"] == v["entry"] and c["kind"] == "reconverted" for c in sec["changes"]):
|
|
324
|
+
sec["resolved"].append(_change("resolved", v["entry"], v["source"], "no longer reported", v["status"]))
|
|
325
|
+
sec["applied"] = [str(src.manifest.parent / f) for f in sorted(set(applied.values()))]
|
|
326
|
+
if sec["applied"]:
|
|
327
|
+
sec["applied"].append(str(src.manifest))
|
|
328
|
+
counts = {s: sum(v == s for v in worst.values()) for s in U.RANK}
|
|
329
|
+
sec["notes"].append(f"manifest `{src.manifest}`: " + ", ".join(f"{v} {s}" for s, v in counts.items() if v))
|
|
330
|
+
unsafe = [r["entry"] for r in rows if r["status"] in ("renamed", "deleted")]
|
|
331
|
+
if apply and unsafe:
|
|
332
|
+
sec["notes"].append(f"not applied (renamed/deleted upstream, needs a person): {', '.join(sorted(set(unsafe)))}")
|
|
333
|
+
if errs:
|
|
334
|
+
sec["status"], sec["reason"] = "error", f"{len(errs)} KiCad source(s) could not be checked"
|
|
335
|
+
sec["_state"] = {"upstream_state.json": {"version": 1, "updated": now, "seen": seen}}
|
|
336
|
+
return sec
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ------------------------------------------------------------------ Altium 365 drift
|
|
340
|
+
def check_a365(src, state_dir, ctx, cfg, now, workspace=None):
|
|
341
|
+
from . import upstream as U
|
|
342
|
+
sec = _section("a365")
|
|
343
|
+
if src.kind != "plan":
|
|
344
|
+
sec.update(status="skipped", reason="needs a plan.json (drift compares the plan with the workspace)")
|
|
345
|
+
return sec
|
|
346
|
+
if not workspace:
|
|
347
|
+
sec.update(status="skipped", reason="no workspace: pass --workspace URL or set [altium365] workspace in ecadlib.toml")
|
|
348
|
+
return sec
|
|
349
|
+
client = ctx.design
|
|
350
|
+
if client is None:
|
|
351
|
+
token = ctx.env.get("NEXAR_TOKEN")
|
|
352
|
+
if not token:
|
|
353
|
+
sec.update(status="skipped", reason="NEXAR_TOKEN not set (Nexar user token: design.domain, user.access, openid)")
|
|
354
|
+
return sec
|
|
355
|
+
from .altium365 import Nexar
|
|
356
|
+
client = Nexar(token)
|
|
357
|
+
old = _load(state_dir / "a365_state.json", {"version": 1, "updated": None, "revisions": None, "seen": []})
|
|
358
|
+
rows, revs = U.check_a365(src.items, U.workspace_components(client, workspace), None, old.get("revisions"))
|
|
359
|
+
seen = []
|
|
360
|
+
for r in rows:
|
|
361
|
+
key = _fp(r["name"], r["status"], r["field"], r["plan"], r["workspace"])
|
|
362
|
+
seen.append(key)
|
|
363
|
+
if key not in old["seen"]:
|
|
364
|
+
sec["changes"].append(_change(f"A365 {r['status']}", r["name"], r["plan"], r["workspace"],
|
|
365
|
+
" ".join(x for x in (r["field"], r["detail"]) if x)))
|
|
366
|
+
sec["resolved"] = [_change("resolved", "", "", "", f"{n} drift row(s) from earlier runs no longer seen")] \
|
|
367
|
+
if (n := len(set(old["seen"]) - set(seen))) else []
|
|
368
|
+
sec["notes"].append(f"{len(rows)} drift rows in `{workspace}`, {len(sec['changes'])} new")
|
|
369
|
+
sec["_state"] = {"a365_state.json": {"version": 1, "updated": now, "revisions": revs, "seen": seen}}
|
|
370
|
+
return sec
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ------------------------------------------------------------------ run, report, state
|
|
374
|
+
TITLES = {"lifecycle": "Lifecycle (NRND / EOL / stock)", "upstream": "KiCad upstream", "sourcing": "Price and stock",
|
|
375
|
+
"a365": "Altium 365 drift"}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def run(input_path, checks=None, state_dir="out/upkeep_state", out="out/upkeep", manifest=None, bom=None,
|
|
379
|
+
workspace=None, apply=False, rules=None, ctx=None, now=None, save=True):
|
|
380
|
+
"""Run the checks, write <out>.md and <out>.json, save the state (save=False leaves that to save_state)."""
|
|
381
|
+
cfg = upkeep_rules(rules)
|
|
382
|
+
checks = list(checks or cfg["checks"])
|
|
383
|
+
bad = [c for c in checks if c not in CHECKS]
|
|
384
|
+
if bad:
|
|
385
|
+
raise ValueError(f"unknown check(s) {', '.join(bad)}; choose from {', '.join(CHECKS)}")
|
|
386
|
+
workspace = workspace or ((rules or {}).get("altium365") or {}).get("workspace") or None
|
|
387
|
+
ctx, now, state_dir = ctx or Ctx(), now or _now(), Path(state_dir)
|
|
388
|
+
src = Source(input_path, manifest, bom, dict(((rules or {}).get("bom") or {}).get("columns") or {}) or None)
|
|
389
|
+
prev = _load(state_dir / "upkeep_last.json", {}).get("date")
|
|
390
|
+
sections = []
|
|
391
|
+
for name in checks:
|
|
392
|
+
try:
|
|
393
|
+
if name == "lifecycle":
|
|
394
|
+
sec = check_lifecycle(src, state_dir, ctx, cfg, now)
|
|
395
|
+
elif name == "sourcing":
|
|
396
|
+
sec = check_sourcing(src, state_dir, ctx, cfg, now)
|
|
397
|
+
elif name == "upstream":
|
|
398
|
+
sec = check_upstream(src, state_dir, ctx, cfg, now, apply)
|
|
399
|
+
else:
|
|
400
|
+
sec = check_a365(src, state_dir, ctx, cfg, now, workspace)
|
|
401
|
+
except Exception as e: # one broken check never hides the others
|
|
402
|
+
sec = _section(name, "error", f"{type(e).__name__}: {e}")
|
|
403
|
+
sections.append(sec)
|
|
404
|
+
new = sum(len(s["changes"]) for s in sections)
|
|
405
|
+
error = any(s["status"] == "error" for s in sections)
|
|
406
|
+
rep = {"version": 1, "date": now, "previous_run": prev, "input": str(input_path), "input_kind": src.kind,
|
|
407
|
+
"schedule": cfg["schedule"], "checks": sections, "new_changes": new,
|
|
408
|
+
"applied_paths": [p for s in sections for p in s["applied"]], "exit_code": 2 if error else 1 if new else 0,
|
|
409
|
+
"state_dir": str(state_dir), "report": str(Path(out).with_suffix(".md"))}
|
|
410
|
+
write_report(rep, out)
|
|
411
|
+
if save:
|
|
412
|
+
save_state(rep, state_dir)
|
|
413
|
+
return rep
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def save_state(rep, state_dir):
|
|
417
|
+
"""Write each check's new state (only checks that ran cleanly; an errored check keeps its old state)."""
|
|
418
|
+
d = Path(state_dir)
|
|
419
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
420
|
+
for s in rep.get("checks") or []:
|
|
421
|
+
if s.get("status") == "ok" and s.get("_state"):
|
|
422
|
+
for f, data in s["_state"].items():
|
|
423
|
+
tmp = d / (f + ".tmp")
|
|
424
|
+
tmp.write_text(json.dumps(data, indent=1, sort_keys=True), encoding="utf-8")
|
|
425
|
+
os.replace(tmp, d / f)
|
|
426
|
+
(d / "upkeep_last.json").write_text(json.dumps({"date": rep.get("date"), "new_changes": rep.get("new_changes", 0)},
|
|
427
|
+
indent=1), encoding="utf-8")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _esc(v):
|
|
431
|
+
return str(v if v is not None else "").replace("|", "\\|").replace("\n", " ")
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def markdown(rep, limit=None):
|
|
435
|
+
day = rep["date"][:10]
|
|
436
|
+
md = [f"# Library upkeep {day}", "",
|
|
437
|
+
f"Input `{rep['input']}` ({rep['input_kind']}), run {rep['date']}; previous run: {rep['previous_run'] or 'none'}.", ""]
|
|
438
|
+
md.append(f"**{rep['new_changes']} new change(s)** since the last run." if rep["new_changes"] else "Nothing new since the last run.")
|
|
439
|
+
md += ["", "| Check | Result | New | Notes |", "|---|---|---|---|"]
|
|
440
|
+
for s in rep["checks"]:
|
|
441
|
+
res = s["status"] + (f": {s['reason']}" if s["reason"] else "")
|
|
442
|
+
md.append(f"| {TITLES[s['check']]} | {_esc(res)} | {len(s['changes']) if s['status'] != 'skipped' else '-'} | "
|
|
443
|
+
f"{_esc('; '.join(s['notes'])[:300])} |")
|
|
444
|
+
for s in rep["checks"]:
|
|
445
|
+
if not (s["changes"] or s["resolved"]):
|
|
446
|
+
continue
|
|
447
|
+
md += ["", f"## {TITLES[s['check']]}", "", "| Change | Item | Was | Now | Detail |", "|---|---|---|---|---|"]
|
|
448
|
+
rows = s["changes"] + s["resolved"]
|
|
449
|
+
for c in rows[:limit] if limit else rows:
|
|
450
|
+
md.append(f"| {_esc(c['kind'])} | {_esc(c['item'])} | {_esc(c['was'])} | {_esc(c['now'])} | {_esc(c['detail'])[:400]} |")
|
|
451
|
+
if limit and len(rows) > limit:
|
|
452
|
+
md.append(f"| ... | {len(rows) - limit} more in the JSON report | | | |")
|
|
453
|
+
if rep["applied_paths"]:
|
|
454
|
+
md += ["", "## Applied", "", "KiCad parts whose sources changed were reconverted; changed files:", ""]
|
|
455
|
+
md += [f"- `{p}`" for p in rep["applied_paths"]]
|
|
456
|
+
md += ["", f"<sub>ecadlib upkeep ({rep['schedule']}); state `{rep['state_dir']}`. Only changes not reported by an "
|
|
457
|
+
"earlier run are listed.</sub>"]
|
|
458
|
+
return "\n".join(md) + "\n"
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def write_report(rep, out):
|
|
462
|
+
out = Path(out)
|
|
463
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
464
|
+
out.with_suffix(".md").write_text(markdown(rep), encoding="utf-8")
|
|
465
|
+
clean = {**rep, "checks": [{k: v for k, v in s.items() if not k.startswith("_")} for s in rep["checks"]]}
|
|
466
|
+
out.with_suffix(".json").write_text(json.dumps(clean, indent=2), encoding="utf-8")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# ------------------------------------------------------------------ GitHub
|
|
470
|
+
class GitHubError(Exception):
|
|
471
|
+
pass
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def default_runner(cmd, input=None, cwd=None):
|
|
475
|
+
"""(returncode, stdout, stderr) of a command; tests pass a fake."""
|
|
476
|
+
if shutil.which(cmd[0]) is None:
|
|
477
|
+
return 127, "", f"{cmd[0]}: command not found"
|
|
478
|
+
p = subprocess.run(cmd, input=input, capture_output=True, text=True, cwd=cwd)
|
|
479
|
+
return p.returncode, p.stdout, p.stderr
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
class GitHub:
|
|
483
|
+
"""GitHub REST, sent with requests + GITHUB_TOKEN (session=) or through `gh api` (runner=). Paths are relative to
|
|
484
|
+
repos/<repo>; with gh and no repo, gh fills {owner}/{repo} from the checkout."""
|
|
485
|
+
API = "https://api.github.com"
|
|
486
|
+
|
|
487
|
+
def __init__(self, repo=None, token=None, session=None, runner=None):
|
|
488
|
+
self.repo, self.token, self.session, self.runner = repo, token, session, runner or default_runner
|
|
489
|
+
if token and not repo:
|
|
490
|
+
raise GitHubError("GITHUB_TOKEN is set but the repository is unknown: pass --repo OWNER/NAME or set GITHUB_REPOSITORY")
|
|
491
|
+
self.mode = "rest" if token else "gh"
|
|
492
|
+
|
|
493
|
+
def api(self, method, path, body=None):
|
|
494
|
+
path = f"repos/{self.repo or '{owner}/{repo}'}/{path}"
|
|
495
|
+
if self.mode == "rest":
|
|
496
|
+
if self.session is None:
|
|
497
|
+
import requests
|
|
498
|
+
self.session = requests.Session()
|
|
499
|
+
r = self.session.request(method, f"{self.API}/{path}", json=body, timeout=60, headers={
|
|
500
|
+
"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json",
|
|
501
|
+
"X-GitHub-Api-Version": "2022-11-28"})
|
|
502
|
+
if not 200 <= r.status_code < 300:
|
|
503
|
+
raise GitHubError(f"GitHub {method} /{path}: HTTP {r.status_code} {str(getattr(r, 'text', ''))[:200]}")
|
|
504
|
+
return r.json() if r.status_code != 204 and getattr(r, "content", b"") else None
|
|
505
|
+
code, out, err = self.runner(["gh", "api", "-X", method, path] + (["--input", "-"] if body is not None else []),
|
|
506
|
+
input=json.dumps(body) if body is not None else None)
|
|
507
|
+
if code:
|
|
508
|
+
raise GitHubError(f"gh api {method} {path}: {(err or out).strip()[:300]}")
|
|
509
|
+
return json.loads(out) if out.strip() else None
|
|
510
|
+
|
|
511
|
+
def ensure_label(self):
|
|
512
|
+
try:
|
|
513
|
+
self.api("POST", "labels", {"name": LABEL, "color": "0e8a16", "description": "ecadlib weekly library upkeep"})
|
|
514
|
+
except GitHubError:
|
|
515
|
+
pass # 422: it exists already
|
|
516
|
+
|
|
517
|
+
def rolling_issue(self):
|
|
518
|
+
issues = self.api("GET", f"issues?labels={LABEL}&state=open&per_page=100") or []
|
|
519
|
+
issues = [i for i in issues if "pull_request" not in i]
|
|
520
|
+
return min(issues, key=lambda i: i["number"]) if issues else None
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _title(rep):
|
|
524
|
+
return f"Library upkeep: {rep['new_changes']} new change(s) ({rep['date'][:10]})"
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _body(rep):
|
|
528
|
+
md = markdown(rep)
|
|
529
|
+
return md if len(md) <= BODY_MAX else markdown(rep, limit=40)[:BODY_MAX]
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def publish_issue(rep, gh):
|
|
533
|
+
"""Update the open issue labelled ecadlib-upkeep (body replaced, a comment added) or open it."""
|
|
534
|
+
title, body = _title(rep), _body(rep)
|
|
535
|
+
if getattr(gh, "kind", "") == "gitlab": # forge.GitLab: the same rolling issue on GitLab
|
|
536
|
+
from .forge import upkeep_issue
|
|
537
|
+
return upkeep_issue(gh, LABEL, title, body, rep["new_changes"], rep["date"][:10])
|
|
538
|
+
iss = gh.rolling_issue()
|
|
539
|
+
if iss:
|
|
540
|
+
gh.api("PATCH", f"issues/{iss['number']}", {"title": title, "body": body})
|
|
541
|
+
gh.api("POST", f"issues/{iss['number']}/comments",
|
|
542
|
+
{"body": f"{rep['new_changes']} new change(s) on {rep['date'][:10]}: the description now has this week's report."})
|
|
543
|
+
return {"kind": "issue", "action": "updated", "number": iss["number"], "url": iss.get("html_url")}
|
|
544
|
+
gh.ensure_label()
|
|
545
|
+
new = gh.api("POST", "issues", {"title": title, "body": body, "labels": [LABEL]})
|
|
546
|
+
return {"kind": "issue", "action": "opened", "number": new["number"], "url": new.get("html_url")}
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def publish_pr(rep, gh, runner=None, base=None, cwd=None):
|
|
550
|
+
"""Commit the applied library files to ecadlib/upkeep-YYYY-MM-DD, push, open or update the PR. None when there is
|
|
551
|
+
nothing to commit (the caller falls back to the issue)."""
|
|
552
|
+
run = runner or default_runner
|
|
553
|
+
paths = rep["applied_paths"]
|
|
554
|
+
if not paths:
|
|
555
|
+
return None
|
|
556
|
+
|
|
557
|
+
def git(*args, ok=(0,)):
|
|
558
|
+
code, out, err = run(["git", *args], cwd=cwd)
|
|
559
|
+
if code not in ok:
|
|
560
|
+
raise GitHubError(f"git {' '.join(args[:3])}: {(err or out).strip()[:300]}")
|
|
561
|
+
return out.strip()
|
|
562
|
+
if not git("status", "--porcelain", "--", *paths):
|
|
563
|
+
return None
|
|
564
|
+
base = base or git("rev-parse", "--abbrev-ref", "HEAD")
|
|
565
|
+
branch = f"ecadlib/upkeep-{rep['date'][:10]}"
|
|
566
|
+
ident = [] if git("config", "user.email", ok=(0, 1)) else ["-c", "user.name=ecadlib-upkeep",
|
|
567
|
+
"-c", "user.email=ecadlib-upkeep@users.noreply.github.com"]
|
|
568
|
+
# a same-day re-run replaces the branch: only ever overwrite what upkeep itself pushed there, never a person's
|
|
569
|
+
# commits (a fix pushed to the PR), and only if the remote is still where we looked (lease)
|
|
570
|
+
remote = git("ls-remote", "origin", f"refs/heads/{branch}")
|
|
571
|
+
remote_sha = remote.split()[0] if remote else ""
|
|
572
|
+
if remote_sha:
|
|
573
|
+
git("fetch", "--no-tags", "origin", f"refs/heads/{branch}")
|
|
574
|
+
theirs = [s for s in git("log", "--no-merges", "--format=%s", remote_sha, "--not", "HEAD").splitlines()
|
|
575
|
+
if s.strip() and not s.startswith("ecadlib upkeep ")]
|
|
576
|
+
if theirs:
|
|
577
|
+
raise GitHubError(f"origin/{branch} has commits not made by upkeep ({theirs[0][:80]!r}): not overwriting "
|
|
578
|
+
"them; merge or close that PR and delete the branch")
|
|
579
|
+
git("switch", "-C", branch)
|
|
580
|
+
try:
|
|
581
|
+
git("add", "--", *paths)
|
|
582
|
+
git(*ident, "commit", "-m", f"ecadlib upkeep {rep['date'][:10]}: reconvert KiCad parts changed upstream")
|
|
583
|
+
git("push", f"--force-with-lease=refs/heads/{branch}:{remote_sha}", "-u", "origin", branch)
|
|
584
|
+
finally:
|
|
585
|
+
git("switch", base, ok=(0, 1, 128))
|
|
586
|
+
title, body = _title(rep), _body(rep)
|
|
587
|
+
if getattr(gh, "kind", "") == "gitlab":
|
|
588
|
+
from .forge import upkeep_mr
|
|
589
|
+
return upkeep_mr(gh, LABEL, branch, base, title, body)
|
|
590
|
+
owner = (getattr(gh, "repo", None) or "{owner}").split("/")[0] # by head: not just the newest 100 open PRs
|
|
591
|
+
prs = [p for p in gh.api("GET", f"pulls?state=open&head={owner}:{branch}&per_page=100") or []
|
|
592
|
+
if (p.get("head") or {}).get("ref") == branch
|
|
593
|
+
and ((p["head"].get("repo") or {}).get("full_name") or "") == (((p.get("base") or {}).get("repo") or {}).get("full_name") or "")]
|
|
594
|
+
if prs:
|
|
595
|
+
gh.api("PATCH", f"pulls/{prs[0]['number']}", {"title": title, "body": body})
|
|
596
|
+
return {"kind": "pr", "action": "updated", "number": prs[0]["number"], "url": prs[0].get("html_url"), "branch": branch}
|
|
597
|
+
pr = gh.api("POST", "pulls", {"title": title, "head": branch, "base": base, "body": body})
|
|
598
|
+
gh.ensure_label()
|
|
599
|
+
gh.api("POST", f"issues/{pr['number']}/labels", {"labels": [LABEL]})
|
|
600
|
+
return {"kind": "pr", "action": "opened", "number": pr["number"], "url": pr.get("html_url"), "branch": branch}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def publish(rep, mode, gh, runner=None, base=None, cwd=None):
|
|
604
|
+
if not rep["new_changes"] and not rep["applied_paths"]:
|
|
605
|
+
return {"kind": mode, "action": "none", "detail": f"nothing new: {'GitLab' if getattr(gh, 'kind', '') == 'gitlab' else 'GitHub'} not touched"}
|
|
606
|
+
if mode == "pr":
|
|
607
|
+
res = publish_pr(rep, gh, runner, base, cwd)
|
|
608
|
+
if res:
|
|
609
|
+
return res
|
|
610
|
+
return publish_issue(rep, gh)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# ------------------------------------------------------------------ CLI
|
|
614
|
+
def main(argv=None, ctx=None, gh=None, runner=None, notifier=None):
|
|
615
|
+
ap = argparse.ArgumentParser(description="Weekly library upkeep: lifecycle, KiCad upstream, price/stock and "
|
|
616
|
+
"Altium 365 drift; report what is new; one GitHub issue or PR")
|
|
617
|
+
ap.add_argument("input", help="altium365 plan.json (all checks) or a BOM .csv/.xlsx (lifecycle, sourcing)")
|
|
618
|
+
ap.add_argument("--checks", help=f"comma list from {','.join(CHECKS)} (default: [upkeep] checks, else all)")
|
|
619
|
+
ap.add_argument("--state", default="out/upkeep_state", help="folder with the last run's state")
|
|
620
|
+
ap.add_argument("-o", "--out", default="out/upkeep", help="report path stem (.md and .json)")
|
|
621
|
+
ap.add_argument("--manifest", help="KiCad provenance manifest.json (default: next to the plan, or <plan dir>/models/)")
|
|
622
|
+
ap.add_argument("--bom", help="BOM for the price/stock check when the input is a plan (quantities per board)")
|
|
623
|
+
ap.add_argument("--workspace", help="Altium 365 workspace URL for the a365 check")
|
|
624
|
+
ap.add_argument("--apply", action="store_true", help="reconvert KiCad parts whose sources changed (safe fixes only)")
|
|
625
|
+
ap.add_argument("--github", choices=("issue", "pr"), help="open/update one rolling issue, or a PR with applied changes")
|
|
626
|
+
ap.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY"), help="OWNER/NAME (default GITHUB_REPOSITORY)")
|
|
627
|
+
ap.add_argument("--base", help="PR base branch (default: the current branch)")
|
|
628
|
+
ap.add_argument("--gitlab", choices=("issue", "pr"), help="the same on GitLab: one rolling issue, or a merge request "
|
|
629
|
+
"(token from [agent] gitlab_token_env, default GITLAB_TOKEN)")
|
|
630
|
+
ap.add_argument("--gitlab-project", default=os.environ.get("CI_PROJECT_PATH"),
|
|
631
|
+
help="group/name or id (default CI_PROJECT_PATH, else [agent] gitlab_project, else the origin remote)")
|
|
632
|
+
ap.add_argument("--gitlab-api", default=os.environ.get("CI_API_V4_URL"),
|
|
633
|
+
help="https://host/api/v4 (default CI_API_V4_URL, else [agent] gitlab_api)")
|
|
634
|
+
ap.add_argument("--no-notify", action="store_true", help="do not send [notify] webhooks for this run")
|
|
635
|
+
ap.add_argument("--rules", help="ecadlib.toml (default: searched from the input's folder)")
|
|
636
|
+
ap.add_argument("--qty", type=int, help="boards per build for prices (default [upkeep] build_qty, else 1)")
|
|
637
|
+
ap.add_argument("--currency", help="default [upkeep] currency, else USD")
|
|
638
|
+
ap.add_argument("--price-jump", type=float, help="report a unit price rise above this percent (default 20)")
|
|
639
|
+
ap.add_argument("--cache", default="out/http_cache")
|
|
640
|
+
ap.add_argument("--offline", action="store_true", help="HTTP cache only")
|
|
641
|
+
ap.add_argument("--no-save", action="store_true", help="do not update the state (dry run)")
|
|
642
|
+
a = ap.parse_args(argv)
|
|
643
|
+
try:
|
|
644
|
+
rules = load_rules(a.rules, start=str(Path(a.input).resolve().parent))
|
|
645
|
+
up = dict((rules or {}).get("upkeep") or {})
|
|
646
|
+
for k, v in (("build_qty", a.qty), ("currency", a.currency), ("price_jump_pct", a.price_jump)):
|
|
647
|
+
if v is not None:
|
|
648
|
+
up[k] = v
|
|
649
|
+
rules = {**(rules or {}), "upkeep": up}
|
|
650
|
+
checks = [c.strip() for c in a.checks.split(",") if c.strip()] if a.checks else None
|
|
651
|
+
rep = run(a.input, checks, a.state, a.out, a.manifest, a.bom, a.workspace, a.apply, rules,
|
|
652
|
+
ctx or Ctx(cache=a.cache, offline=a.offline), save=False)
|
|
653
|
+
except Exception as e:
|
|
654
|
+
print(f"upkeep: {type(e).__name__}: {e}", file=sys.stderr)
|
|
655
|
+
return 2
|
|
656
|
+
for s in rep["checks"]:
|
|
657
|
+
print(f"{s['check']:<10} {s['status']:<8} {len(s['changes'])} new" + (f" {s['reason']}" if s["reason"] else ""))
|
|
658
|
+
for c in s["changes"]:
|
|
659
|
+
print(f" {c['kind']:<16} {str(c['item'])[:32]:<32} {str(c['was'])[:28]} -> {str(c['now'])[:28]}")
|
|
660
|
+
print(f"{rep['new_changes']} new change(s) -> {Path(a.out).with_suffix('.md')} and .json")
|
|
661
|
+
code = rep["exit_code"]
|
|
662
|
+
if a.gitlab:
|
|
663
|
+
try:
|
|
664
|
+
res = publish(rep, a.gitlab, gh or _gitlab_client(rules, a, runner), runner, a.base)
|
|
665
|
+
rep["gitlab"] = res
|
|
666
|
+
print(f"gitlab: {res['kind']} {res['action']}" + (f" !{res['number']}" if res.get("number") and res["kind"] == "pr"
|
|
667
|
+
else f" #{res['number']}" if res.get("number") else "") + (f" {res['url']}" if res.get("url") else ""))
|
|
668
|
+
except Exception as e:
|
|
669
|
+
print(f"gitlab: {type(e).__name__}: {e} (state not saved: next run reports these changes again)", file=sys.stderr)
|
|
670
|
+
rep["gitlab"] = {"error": f"{type(e).__name__}: {e}"}
|
|
671
|
+
write_report(rep, a.out)
|
|
672
|
+
return 2
|
|
673
|
+
write_report(rep, a.out)
|
|
674
|
+
if a.github:
|
|
675
|
+
try:
|
|
676
|
+
token = os.environ.get("GITHUB_TOKEN") # REST when token and repo are known, else `gh api`
|
|
677
|
+
g = gh or GitHub(a.repo, token if token and a.repo else None, runner=runner)
|
|
678
|
+
res = publish(rep, a.github, g, runner, a.base)
|
|
679
|
+
rep["github"] = res
|
|
680
|
+
print(f"github: {res['kind']} {res['action']}" + (f" #{res['number']}" if res.get("number") else "")
|
|
681
|
+
+ (f" {res['url']}" if res.get("url") else ""))
|
|
682
|
+
except Exception as e:
|
|
683
|
+
print(f"github: {type(e).__name__}: {e} (state not saved: next run reports these changes again)", file=sys.stderr)
|
|
684
|
+
rep["github"] = {"error": f"{type(e).__name__}: {e}"}
|
|
685
|
+
write_report(rep, a.out)
|
|
686
|
+
return 2
|
|
687
|
+
write_report(rep, a.out)
|
|
688
|
+
if not a.no_save:
|
|
689
|
+
save_state(rep, a.state)
|
|
690
|
+
if not a.no_notify and rep["new_changes"]: # only once per change: a dry run (--no-save) repeats them
|
|
691
|
+
from . import notify as N
|
|
692
|
+
for r in N.send(rules, N.upkeep_events(rep), sender=notifier, log=lambda m: print(m, file=sys.stderr)):
|
|
693
|
+
print(f"notify: {r['target']} {r['event']} {r['result']}")
|
|
694
|
+
return code
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _gitlab_client(rules, a, runner=None):
|
|
698
|
+
"""forge.GitLab for --gitlab: project from --gitlab-project / [agent] gitlab_project / the origin remote."""
|
|
699
|
+
from . import forge
|
|
700
|
+
ag = dict((rules or {}).get("agent") or {})
|
|
701
|
+
api = a.gitlab_api or ag.get("gitlab_api") or forge.GITLAB_API
|
|
702
|
+
proj = a.gitlab_project or ag.get("gitlab_project")
|
|
703
|
+
if not proj:
|
|
704
|
+
code, out, _ = (runner or default_runner)(["git", "remote", "get-url", "origin"])
|
|
705
|
+
proj = forge.project_from_remote(out.strip(), api) if code == 0 else None
|
|
706
|
+
bad = forge.wrong_api_host(api, out.strip()) if proj else None
|
|
707
|
+
if bad:
|
|
708
|
+
raise forge.GitLabError(bad + "; or pass --gitlab-api")
|
|
709
|
+
tok_env = ag.get("gitlab_token_env") or "GITLAB_TOKEN"
|
|
710
|
+
token = os.environ.get(tok_env)
|
|
711
|
+
if not token:
|
|
712
|
+
raise forge.GitLabError(f"no GitLab token: set ${tok_env} (a project or personal access token with api scope)")
|
|
713
|
+
return forge.GitLab(proj, token, api=api)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
if __name__ == "__main__":
|
|
717
|
+
sys.exit(main())
|