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/rules.py
ADDED
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
"""Library rules: one file (ecadlib.toml) with your naming conventions, required parameters, layer mapping and
|
|
2
|
+
footprint density, which every ecadlib tool follows so new parts match your existing library.
|
|
3
|
+
|
|
4
|
+
ecadlib init [--from DIR ...] [--yes] scan what you already have and write ecadlib.toml (ecadlib/rules_infer.py)
|
|
5
|
+
ecadlib rules check [PATH] validate a rules file and print the effective rules
|
|
6
|
+
ecadlib rules show print the defaults (what ecadlib does with no file)
|
|
7
|
+
|
|
8
|
+
Without a file nothing changes: DEFAULTS is today's behaviour exactly. The file is searched in: an explicit
|
|
9
|
+
--rules PATH, then the folder the tool works in (plan / output folder) and its parents, then the current folder
|
|
10
|
+
and its parents. A workspace (UI / MCP) uses <workspace>/ecadlib.toml. Python 3.10 has no tomllib: there the
|
|
11
|
+
tomli package (installed with ecadlib) reads it, or the same content as JSON in ecadlib_rules.json works.
|
|
12
|
+
|
|
13
|
+
Plans (plan.json) keep ecadlib's own parameter names ("Manufacturer Part Number", "Supplier 1", ...); your house
|
|
14
|
+
names from [parameters.names] are applied where data leaves ecadlib: Altium 365 push, the DbLib export and sync,
|
|
15
|
+
the KiCad export and the Altium 365 drift check. Part names, folders, footprint names, required fields, default
|
|
16
|
+
parameters, layers and density are applied when the plan and the models are made."""
|
|
17
|
+
import argparse, copy, difflib, json, os, re, sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
FILES = ("ecadlib.toml", "ecadlib_rules.json")
|
|
21
|
+
CATEGORIES = ("Resistors", "Capacitors", "Inductors", "Diodes", "Transistors", "Crystals", "Connectors", "ICs", "Misc")
|
|
22
|
+
REF_CLASS = {"R": "Resistors", "C": "Capacitors", "L": "Inductors", "D": "Diodes", "LED": "Diodes", "Q": "Transistors",
|
|
23
|
+
"Y": "Crystals", "X": "Crystals", "J": "Connectors", "P": "Connectors", "CN": "Connectors", "U": "ICs", "IC": "ICs"}
|
|
24
|
+
CAT_REF = {"Resistors": "R", "Capacitors": "C", "Inductors": "L", "Diodes": "D", "Transistors": "Q", "Crystals": "Y",
|
|
25
|
+
"Connectors": "J", "ICs": "U", "Misc": "U"}
|
|
26
|
+
PASSIVES = ("Resistors", "Capacitors", "Inductors") # the categories with a generic symbol and a synthetic name
|
|
27
|
+
SOURCES = ("kicad", "easyeda", "standard", "datasheet") # standard: ecadlib.stdparts (offline, before Claude)
|
|
28
|
+
FIELDS = ("mpn", "manufacturer", "value", "package", "metric", "category", "cat3", "ref", "tolerance", "voltage",
|
|
29
|
+
"dielectric", "power", "description")
|
|
30
|
+
FP_FIELDS = ("kicad", "package", "metric", "ref", "cat3", "density", "pins")
|
|
31
|
+
VALUE_STYLES = ("keep", "rkm", "rkm_lower", "si", "si_upper", "si_unit")
|
|
32
|
+
DENSITY = {"L": "C", "N": "B", "M": "A"} # IPC-7351 least/nominal/most -> footprint_gen A/B/C
|
|
33
|
+
CANONICAL_PARAMS = ("Manufacturer", "Manufacturer Part Number", "Value", "Supplier 1", "Supplier Part Number 1",
|
|
34
|
+
"Supplier 2", "Supplier Part Number 2", "ComponentLink1Description", "ComponentLink1URL",
|
|
35
|
+
"ComponentLink2Description", "ComponentLink2URL")
|
|
36
|
+
KEY_COLUMNS = {"Part Number", "Library Ref", "Library Path", "Footprint Ref", "Footprint Path", "Description", "Comment"}
|
|
37
|
+
|
|
38
|
+
MANUFACTURER_ALIASES = { # [agent] manufacturer_aliases defaults: alias -> canonical maker
|
|
39
|
+
"st": "stmicroelectronics", "stm": "stmicroelectronics", "st micro": "stmicroelectronics",
|
|
40
|
+
"st microelectronics": "stmicroelectronics", "sgs thomson": "stmicroelectronics",
|
|
41
|
+
"ti": "texas instruments", "texas instrument": "texas instruments",
|
|
42
|
+
"adi": "analog devices", "analog device": "analog devices", "linear technology": "analog devices",
|
|
43
|
+
"nxp semiconductors": "nxp", "nxp semiconductor": "nxp",
|
|
44
|
+
"on semiconductor": "onsemi", "on semi": "onsemi", "on": "onsemi",
|
|
45
|
+
"microchip technology": "microchip", "infineon technologies": "infineon",
|
|
46
|
+
"vishay intertechnology": "vishay", "vishay semiconductors": "vishay", "vishay siliconix": "vishay",
|
|
47
|
+
"vishay dale": "vishay", "vishay general semiconductor": "vishay",
|
|
48
|
+
"murata manufacturing": "murata", "murata electronics": "murata", "te": "te connectivity",
|
|
49
|
+
"rohm semiconductor": "rohm", "diodes": "diodes incorporated", "diodes inc": "diodes incorporated",
|
|
50
|
+
"maxim integrated": "maxim", "maxim integrated products": "maxim", "renesas electronics": "renesas",
|
|
51
|
+
"samsung electro mechanics": "samsung electro-mechanics", "semco": "samsung electro-mechanics",
|
|
52
|
+
"we": "wurth elektronik", "wurth": "wurth elektronik", "wuerth elektronik": "wurth elektronik",
|
|
53
|
+
"nexperia usa": "nexperia", "toshiba electronic devices and storage": "toshiba", "kemet electronics": "kemet",
|
|
54
|
+
"yageo": "yageo", "tdk electronics": "tdk", "epcos": "tdk", "panasonic electronic components": "panasonic",
|
|
55
|
+
"littelfuse": "littelfuse", "bourns": "bourns", "molex": "molex", "amphenol": "amphenol",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
DEFAULTS = {
|
|
59
|
+
"naming": {
|
|
60
|
+
"component": "{mpn}", # parts that are not generic passives
|
|
61
|
+
"passive": "{cat3}-{package}-{value}", # resistors / capacitors / inductors with a value (RES-0603-10k)
|
|
62
|
+
"by_category": {}, # category or designator class -> template (overrides the two above)
|
|
63
|
+
"symbol": "{mpn}", # generic passives keep the generic Resistor / Capacitor / Inductor symbol
|
|
64
|
+
"footprint": "kicad", # kicad | ipc7351 | template ({package}, {metric}, {ref}, {cat3}, {kicad})
|
|
65
|
+
"case": "keep", # keep | upper | lower (part names)
|
|
66
|
+
"value_style": "keep", # keep | rkm (4K7, 100N) | rkm_lower (4k7, 100n) | si (4.7k) | si_upper | si_unit (100nF)
|
|
67
|
+
},
|
|
68
|
+
"parameters": {
|
|
69
|
+
"required": ["Manufacturer", "Manufacturer Part Number", "Datasheet", "Description"],
|
|
70
|
+
"names": {}, # ecadlib name -> your name ("Manufacturer Part Number" = "MPN")
|
|
71
|
+
"defaults": {}, # parameter -> value added to every new part when missing
|
|
72
|
+
"supplier_slots": 2,
|
|
73
|
+
},
|
|
74
|
+
"folders": {"root": "Components", "map": {}}, # map: category or designator class -> folder
|
|
75
|
+
"layers": {"courtyard": "Mechanical15", "assembly": "Mechanical13", "body3d": "Mechanical13",
|
|
76
|
+
"silkscreen": "TopOverlay", "designator": "TopOverlay"},
|
|
77
|
+
"footprint": {"density": "N", "pin1": ["1", "A1"]},
|
|
78
|
+
"sources": {"order": list(SOURCES)},
|
|
79
|
+
"bom": {"columns": {}}, # ecadlib field (mpn, manufacturer, value, ...) -> your BOM header
|
|
80
|
+
"release": {"approvals": {}, # same keys as approvals.json; used when there is no approvals.json
|
|
81
|
+
"parity": "hold", # hold | warn | off: KiCad vs Altium files of a part differ (ecadlib.parity)
|
|
82
|
+
"eagle": "warn", # hold | warn | off: the footprint as a Fusion / EAGLE package reads back differently
|
|
83
|
+
"easyeda": "warn"}, # hold | warn | off: the same for an EasyEDA Pro footprint
|
|
84
|
+
"altium365": {"workspace": ""},
|
|
85
|
+
"upkeep": {"schedule": "weekly", "checks": ["lifecycle", "upstream", "sourcing", "a365"], # ecadlib.upkeep
|
|
86
|
+
"price_jump_pct": 20.0, "build_qty": 1, "currency": "USD"},
|
|
87
|
+
"sim": {"capacitor_esr": "", "capacitor_esl": "", "inductor_dcr": ""}, # ecadlib.sim_models ("" = none)
|
|
88
|
+
"datasheet": {"model": "claude-opus-5-5", "effort": "high", # ecadlib.datasheet (Claude datasheet reader)
|
|
89
|
+
"api_key_env": "ANTHROPIC_API_KEY", "max_cost_usd": 0.0, "auto_pages": 40,
|
|
90
|
+
# ecadlib.ds_route: pages and model per part (route = "single": `model` for every part, as before)
|
|
91
|
+
"page_budget": 6, "route": "single", "ladder": ["claude-haiku-5-5", "claude-sonnet-5-5", "claude-opus-5-5"],
|
|
92
|
+
"escalate": True, "max_attempts": 4,
|
|
93
|
+
# ecadlib.crosscheck: a second source (standard table / a second read) before a footprint is accepted
|
|
94
|
+
"cross_check": "auto", "cross_check_model": ""},
|
|
95
|
+
"symbols": { # ecadlib.symbol_layout: IC symbol pins grouped by function
|
|
96
|
+
"layout_pins": 1, # parts with at least this many pins get grouped (0 = never)
|
|
97
|
+
"power_side": "top", # top: power top, ground bottom | left: both on the left side, top and bottom empty
|
|
98
|
+
"split_pins": 0, # parts with more pins than this become multi-part (0 = never)
|
|
99
|
+
"unit_pins": 48, # most pins in one signal part when split
|
|
100
|
+
"stack_power": "stack", # stack | hide | none (same-name power pins)
|
|
101
|
+
"pin_length": 300, # mil
|
|
102
|
+
"grid": 100, # mil (pin pitch, body edges)
|
|
103
|
+
"order": ["power", "analog", "reset", "clock", "debug", "usb", "adc", "other_in", "other", "power_out", "uart", "i2c",
|
|
104
|
+
"spi", "can", "eth", "ports", "other_out", "nc", "ground", "ep"],
|
|
105
|
+
"sides": {}, # group -> left | right | top | bottom (overrides)
|
|
106
|
+
},
|
|
107
|
+
"agent": { # ecadlib.agent: the library agent (off unless enabled)
|
|
108
|
+
"watch": {"sources": [], "interval": 3600, "include": [], "exclude": []},
|
|
109
|
+
"enabled": False, "interval_minutes": 60, "libs": [],
|
|
110
|
+
"library_repo": "", # git repo for the batch PRs ("" = the workspace, if it is one)
|
|
111
|
+
"library_path": "library", # folder in that repo for the library files when it is not the workspace
|
|
112
|
+
"base_branch": "", # "" = the repo's current branch
|
|
113
|
+
"pictures_path": "agent-pictures",
|
|
114
|
+
"github_repo": "", # owner/name ("" = from the origin remote)
|
|
115
|
+
"github_token_env": "GITHUB_TOKEN", "github_api": "https://api.github.com",
|
|
116
|
+
"batch_size": 20, "auto_push": False, "upkeep_schedule": "weekly", "datasheet_batch": True,
|
|
117
|
+
"manufacturer_aliases": dict(MANUFACTURER_ALIASES), # part keys: "ST" = "STMicroelectronics"; add your own
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
WATCH_TYPES = ("bom_file", "folder", "altium_project", "a365_project")
|
|
121
|
+
WATCH_SOURCE_KEYS = ("type", "ref", "name", "workspace", "variant", "include", "exclude")
|
|
122
|
+
# [agent.proof]: ecadlib.visual_check (the Claude visual proof). Empty model / effort / api_key_env = use [datasheet].
|
|
123
|
+
DEFAULTS.setdefault("agent", {})["proof"] = {"model": "", "effort": "", "api_key_env": "", "max_cost_usd": 0.0,
|
|
124
|
+
"fail_on": ["error"], "max_pages": 6, "dpi": 200}
|
|
125
|
+
SUB_RANGE = {("agent", "proof", "max_pages"): (1, 100), ("agent", "proof", "dpi"): (50, 400)}
|
|
126
|
+
# GitLab merge requests instead of GitHub PRs (ecadlib.forge); "auto" picks by the origin remote
|
|
127
|
+
DEFAULTS["agent"].update({"git_host": "auto", "gitlab_project": "", "gitlab_token_env": "GITLAB_TOKEN",
|
|
128
|
+
"gitlab_api": "https://gitlab.com/api/v4"})
|
|
129
|
+
# [notify]: webhooks (ecadlib.notify); [providers]: order and choice of part sources incl. plugins (ecadlib.plugins)
|
|
130
|
+
DEFAULTS["notify"] = {"targets": [], "retries": 3, "timeout_s": 10, "backoff_s": 2.0}
|
|
131
|
+
# [ai]: bring your own model (ecadlib.llm): provider, model, endpoint, key variable, guide rails. Defaults = Claude as before.
|
|
132
|
+
from . import llm as _LL
|
|
133
|
+
DEFAULTS["ai"] = {k: (dict(v) if isinstance(v, dict) else v) for k, v in _LL.DEFAULTS.items()}
|
|
134
|
+
for _k in _LL.SECTION_KEYS: # [datasheet] / [agent.proof] may pick their own ("" = [ai])
|
|
135
|
+
DEFAULTS["datasheet"].setdefault(_k, "")
|
|
136
|
+
DEFAULTS["agent"]["proof"].setdefault(_k, "")
|
|
137
|
+
DEFAULTS["providers"] = {"order": []}
|
|
138
|
+
# [setup]: written by the `ecadlib init` setup questions (ecadlib.firstrun). libs / workspace are the defaults for
|
|
139
|
+
# --lib / --workspace (after the command line and ECADLIB_LIB / ECADLIB_WORKSPACE); empty = as before.
|
|
140
|
+
DEFAULTS["setup"] = {"tool": "both", "libs": [], "workspace": ""}
|
|
141
|
+
# [hub]: the Library page / `ecadlib hub` (ecadlib.hub). libraries are read on top of --lib; projects feed where-used.
|
|
142
|
+
DEFAULTS["hub"] = {"libraries": [], "projects": [], "stale_days": 14}
|
|
143
|
+
# [style]: your house style learned from your library + your written direction (ecadlib.house_style, `ecadlib style`).
|
|
144
|
+
# profile: the learned file, relative to this rules file. apply: auto = new parts are adapted by code wherever a
|
|
145
|
+
# profile exists; off = never. The model for `ecadlib style learn/apply --ai` may be set here ("" = [ai]).
|
|
146
|
+
DEFAULTS["style"] = {"profile": "house_style.json", "apply": "auto", "model": "", "api_key_env": "",
|
|
147
|
+
**{k: "" for k in _LL.SECTION_KEYS}}
|
|
148
|
+
MAP_KEYS = {("naming", "by_category"), ("parameters", "names"), ("parameters", "defaults"), ("folders", "map"),
|
|
149
|
+
("bom", "columns"), ("release", "approvals"), ("symbols", "sides"), ("agent", "manufacturer_aliases")}
|
|
150
|
+
INT_RANGE = {("parameters", "supplier_slots"): (0, 9), ("upkeep", "build_qty"): (1, 1_000_000),
|
|
151
|
+
("symbols", "layout_pins"): (0, 100_000), ("symbols", "split_pins"): (0, 100_000), ("symbols", "unit_pins"): (8, 10_000),
|
|
152
|
+
("symbols", "pin_length"): (50, 1000), ("symbols", "grid"): (50, 100), ("datasheet", "auto_pages"): (0, 600), ("datasheet", "page_budget"): (0, 600),
|
|
153
|
+
("datasheet", "max_attempts"): (1, 10),
|
|
154
|
+
("agent", "interval_minutes"): (1, 10_080), ("agent", "batch_size"): (1, 500),
|
|
155
|
+
("notify", "retries"): (0, 10), ("notify", "timeout_s"): (1, 120), ("hub", "stale_days"): (1, 3650)}
|
|
156
|
+
BOM_FIELDS = ("mpn", "manufacturer", "lcsc", "qty", "designator", "value", "footprint", "description", "libref", "dnp", "datasheet")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class RulesError(ValueError):
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------ load / merge / validate
|
|
164
|
+
def find(start="."):
|
|
165
|
+
"""The rules file in `start` or one of its parents, else in the current folder or its parents; None if none.
|
|
166
|
+
$ECADLIB_CONFIG (a file path), when set, is used instead of the search (an explicit --rules still wins)."""
|
|
167
|
+
env = os.environ.get("ECADLIB_CONFIG", "").strip()
|
|
168
|
+
if env:
|
|
169
|
+
if not Path(env).expanduser().is_file():
|
|
170
|
+
raise RulesError(f"ECADLIB_CONFIG={env}: no such file")
|
|
171
|
+
return Path(env).expanduser().resolve()
|
|
172
|
+
seen = set()
|
|
173
|
+
for base in (Path(start), Path.cwd()):
|
|
174
|
+
try:
|
|
175
|
+
base = base.resolve()
|
|
176
|
+
except OSError:
|
|
177
|
+
continue
|
|
178
|
+
for d in [base, *base.parents]:
|
|
179
|
+
if d in seen:
|
|
180
|
+
break
|
|
181
|
+
seen.add(d)
|
|
182
|
+
for n in FILES:
|
|
183
|
+
if (d / n).is_file():
|
|
184
|
+
return d / n
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def toml_module():
|
|
189
|
+
"""tomllib (Python 3.11+), else tomli (the same API; a dependency on 3.10), else None."""
|
|
190
|
+
try:
|
|
191
|
+
import tomllib
|
|
192
|
+
except ImportError:
|
|
193
|
+
try:
|
|
194
|
+
import tomli as tomllib
|
|
195
|
+
except ImportError:
|
|
196
|
+
return None
|
|
197
|
+
return tomllib
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def parse_text(text, name="ecadlib.toml"):
|
|
201
|
+
if str(name).endswith(".json"):
|
|
202
|
+
try:
|
|
203
|
+
return json.loads(text or "{}")
|
|
204
|
+
except json.JSONDecodeError as e:
|
|
205
|
+
raise RulesError(f"{name}: not valid JSON ({e})") from None
|
|
206
|
+
tomllib = toml_module()
|
|
207
|
+
if tomllib is None: # Python 3.10 without the tomli package
|
|
208
|
+
raise RulesError(f"{name}: reading TOML on Python 3.10 needs the tomli package (pip install tomli), "
|
|
209
|
+
"or put the same content as JSON in ecadlib_rules.json") from None
|
|
210
|
+
try:
|
|
211
|
+
return tomllib.loads(text)
|
|
212
|
+
except tomllib.TOMLDecodeError as e:
|
|
213
|
+
raise RulesError(f"{name}: not valid TOML ({e})") from None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def merge(base, over):
|
|
217
|
+
out = copy.deepcopy(base)
|
|
218
|
+
for k, v in (over or {}).items():
|
|
219
|
+
out[k] = merge(out[k], v) if isinstance(v, dict) and isinstance(out.get(k), dict) else copy.deepcopy(v)
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def load(path=None, start="."):
|
|
224
|
+
"""Rules merged over DEFAULTS. path: an explicit file (must exist), a dict (used as the file's content), or None to
|
|
225
|
+
search `start` and its parents, then the current folder. No file -> a copy of DEFAULTS. Bad content raises RulesError
|
|
226
|
+
naming the key. The result carries `_file` (where it came from, '' for none)."""
|
|
227
|
+
if isinstance(path, dict):
|
|
228
|
+
if "_file" in path and all(k in path for k in DEFAULTS):
|
|
229
|
+
return path # already loaded
|
|
230
|
+
raw, where = path, "rules"
|
|
231
|
+
else:
|
|
232
|
+
f = Path(path) if path else find(start)
|
|
233
|
+
if path and not f.is_file():
|
|
234
|
+
raise RulesError(f"rules file {path} not found")
|
|
235
|
+
if not f:
|
|
236
|
+
return dict(copy.deepcopy(DEFAULTS), _file="")
|
|
237
|
+
raw, where = parse_text(f.read_text(encoding="utf-8"), f.name), str(f)
|
|
238
|
+
if isinstance(raw, dict) and "schema_version" in raw: # ecadlib/migrate.py: a file from a newer ecadlib is refused
|
|
239
|
+
from . import migrate as MG
|
|
240
|
+
try:
|
|
241
|
+
raw = MG.check_key("rules", raw, where)
|
|
242
|
+
except MG.SchemaTooNew as e:
|
|
243
|
+
raise RulesError(str(e)) from None
|
|
244
|
+
validate(raw, where)
|
|
245
|
+
out = merge(DEFAULTS, {k: v for k, v in raw.items() if not k.startswith("_")})
|
|
246
|
+
out["_file"] = "" if isinstance(path, dict) else where
|
|
247
|
+
_normalise(out)
|
|
248
|
+
return out
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def resolve(rules, start="."):
|
|
252
|
+
"""rules argument of the tools: a loaded dict, a path, or None (search from `start`)."""
|
|
253
|
+
return load(rules, start) if not (isinstance(rules, dict) and "_file" in rules) else rules
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _normalise(r):
|
|
257
|
+
pin1 = r["footprint"]["pin1"]
|
|
258
|
+
r["footprint"]["pin1"] = [pin1] if isinstance(pin1, str) else list(pin1)
|
|
259
|
+
d = str(r["footprint"]["density"]).upper()
|
|
260
|
+
r["footprint"]["density"] = {"A": "M", "B": "N", "C": "L"}.get(d, d)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _err(where, key, msg):
|
|
264
|
+
raise RulesError(f"{where}: {key} {msg}")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _template_fields(t):
|
|
268
|
+
return re.findall(r"\{(\w+)\}", t)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def validate(raw, where="ecadlib.toml"):
|
|
272
|
+
"""Raise RulesError naming the first bad key; returns raw unchanged when it is fine."""
|
|
273
|
+
if not isinstance(raw, dict):
|
|
274
|
+
raise RulesError(f"{where}: expected a table of sections")
|
|
275
|
+
for sec, val in raw.items():
|
|
276
|
+
if sec.startswith("_") or sec == "schema_version": # schema_version: the file's format (ecadlib/migrate.py)
|
|
277
|
+
continue
|
|
278
|
+
if sec not in DEFAULTS:
|
|
279
|
+
_err(where, f"[{sec}]", f"is not a known section; known: {', '.join(DEFAULTS)}")
|
|
280
|
+
if not isinstance(val, dict):
|
|
281
|
+
_err(where, f"[{sec}]", "must be a table")
|
|
282
|
+
for k, v in val.items():
|
|
283
|
+
key = f"{sec}.{k}"
|
|
284
|
+
if (sec, k) == ("agent", "watch") and k in DEFAULTS[sec]:
|
|
285
|
+
_check_watch(where, v)
|
|
286
|
+
continue
|
|
287
|
+
if sec == "ai" and k in DEFAULTS["ai"]: # checked by ecadlib.llm.validate below
|
|
288
|
+
continue
|
|
289
|
+
if (sec, k) == ("notify", "targets"):
|
|
290
|
+
from .notify import check_targets
|
|
291
|
+
check_targets(where, v, _err)
|
|
292
|
+
continue
|
|
293
|
+
if k not in DEFAULTS[sec]:
|
|
294
|
+
close = difflib.get_close_matches(k, DEFAULTS[sec], 1)
|
|
295
|
+
_err(where, key, f"is not a known key" + (f" (did you mean {sec}.{close[0]}?)" if close else
|
|
296
|
+
f"; known in [{sec}]: {', '.join(DEFAULTS[sec])}"))
|
|
297
|
+
d = DEFAULTS[sec][k]
|
|
298
|
+
if isinstance(d, bool):
|
|
299
|
+
if not isinstance(v, bool):
|
|
300
|
+
_err(where, key, "must be true or false")
|
|
301
|
+
elif (sec, k) in MAP_KEYS:
|
|
302
|
+
if not isinstance(v, dict):
|
|
303
|
+
_err(where, key, "must be a table of name = value")
|
|
304
|
+
if (sec, k) == ("bom", "columns") and all(isinstance(x, str) or (isinstance(x, list) and x and all(
|
|
305
|
+
isinstance(y, str) for y in x)) for x in v.values()):
|
|
306
|
+
pass # a header, or a list of headers (house aliases)
|
|
307
|
+
elif (sec, k) != ("release", "approvals") and not all(isinstance(x, (str, int, float)) for x in v.values()):
|
|
308
|
+
_err(where, key, "values must be text")
|
|
309
|
+
elif isinstance(d, list):
|
|
310
|
+
if isinstance(v, str) and (sec, k) == ("footprint", "pin1"):
|
|
311
|
+
v = [v]
|
|
312
|
+
if not isinstance(v, list) or not all(isinstance(x, str) for x in v):
|
|
313
|
+
_err(where, key, "must be a list of text values")
|
|
314
|
+
elif isinstance(d, int) and not isinstance(d, bool):
|
|
315
|
+
lo, hi = INT_RANGE.get((sec, k), (0, 9))
|
|
316
|
+
if not isinstance(v, int) or isinstance(v, bool) or not lo <= v <= hi:
|
|
317
|
+
_err(where, key, f"must be a whole number from {lo} to {hi}")
|
|
318
|
+
elif isinstance(d, float):
|
|
319
|
+
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0:
|
|
320
|
+
_err(where, key, "must be a number of 0 or more")
|
|
321
|
+
elif isinstance(d, dict):
|
|
322
|
+
_check_table(where, sec, k, v, d)
|
|
323
|
+
elif sec == "sim":
|
|
324
|
+
if not isinstance(v, (str, int, float)) or isinstance(v, bool):
|
|
325
|
+
_err(where, key, "must be a value such as \"10m\" (ohm / henry)")
|
|
326
|
+
elif not isinstance(v, str):
|
|
327
|
+
_err(where, key, "must be text")
|
|
328
|
+
n = raw.get("naming", {})
|
|
329
|
+
for k in ("component", "passive", "symbol"):
|
|
330
|
+
if k in n:
|
|
331
|
+
_check_template(where, f"naming.{k}", n[k], FIELDS)
|
|
332
|
+
for c, t in (n.get("by_category") or {}).items():
|
|
333
|
+
if c not in CATEGORIES and c not in REF_CLASS:
|
|
334
|
+
_err(where, f"naming.by_category.{c}", f"is not a category ({', '.join(CATEGORIES)}) or designator class ({', '.join(REF_CLASS)})")
|
|
335
|
+
_check_template(where, f"naming.by_category.{c}", t, FIELDS)
|
|
336
|
+
if "footprint" in n and n["footprint"] not in ("kicad", "ipc7351"):
|
|
337
|
+
if "{" not in n["footprint"]:
|
|
338
|
+
_err(where, "naming.footprint", f"= {n['footprint']!r}: use kicad, ipc7351 or a template such as \"{{ref}}{{package}}\"")
|
|
339
|
+
_check_template(where, "naming.footprint", n["footprint"], FP_FIELDS)
|
|
340
|
+
for k, ok in (("case", ("keep", "upper", "lower")), ("value_style", VALUE_STYLES)):
|
|
341
|
+
if k in n and n[k] not in ok:
|
|
342
|
+
_err(where, f"naming.{k}", f"= {n[k]!r}: must be one of {', '.join(ok)}")
|
|
343
|
+
fp = raw.get("footprint", {})
|
|
344
|
+
if "density" in fp and str(fp["density"]).upper() not in ("L", "N", "M", "A", "B", "C"):
|
|
345
|
+
_err(where, "footprint.density", f"= {fp['density']!r}: must be L, N or M (IPC-7351 least / nominal / most)")
|
|
346
|
+
for k, v in (raw.get("layers") or {}).items():
|
|
347
|
+
try:
|
|
348
|
+
layer_id(v)
|
|
349
|
+
except ValueError as e:
|
|
350
|
+
_err(where, f"layers.{k}", f"= {v!r}: {e}")
|
|
351
|
+
for s in (raw.get("sources") or {}).get("order", []):
|
|
352
|
+
if s not in SOURCES:
|
|
353
|
+
_err(where, "sources.order", f"has {s!r}: sources are {', '.join(SOURCES)}")
|
|
354
|
+
for f in (raw.get("bom") or {}).get("columns", {}):
|
|
355
|
+
if f not in BOM_FIELDS:
|
|
356
|
+
_err(where, f"bom.columns.{f}", f"is not a BOM field; fields are {', '.join(BOM_FIELDS)}")
|
|
357
|
+
ds = raw.get("datasheet") or {}
|
|
358
|
+
if "effort" in ds and ds["effort"] not in ("low", "medium", "high", "xhigh", "max"):
|
|
359
|
+
_err(where, "datasheet.effort", f"= {ds['effort']!r}: must be low, medium, high, xhigh or max")
|
|
360
|
+
if "api_key_env" in ds and not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", ds["api_key_env"]):
|
|
361
|
+
_err(where, "datasheet.api_key_env", "must be the NAME of an environment variable (never the key itself)")
|
|
362
|
+
if "model" in ds and not _LL.MODEL_RE.fullmatch(str(ds["model"])):
|
|
363
|
+
_err(where, "datasheet.model", f"= {ds['model']!r}: must be a model id such as claude-opus-5-5 or qwen2.5:7b")
|
|
364
|
+
if "route" in ds and ds["route"] not in ("single", "ladder"):
|
|
365
|
+
_err(where, "datasheet.route", f"= {ds['route']!r}: must be single (one model) or ladder (cheapest model that passes)")
|
|
366
|
+
for m in ds.get("ladder") or [] if isinstance(ds.get("ladder"), list) else []:
|
|
367
|
+
if not _LL.MODEL_RE.fullmatch(str(m)):
|
|
368
|
+
_err(where, "datasheet.ladder", f"has {m!r}: must be model ids such as claude-haiku-5-5 (or endpoint:model "
|
|
369
|
+
"for an [ai.endpoints] entry), cheapest first")
|
|
370
|
+
if "cross_check" in ds and ds["cross_check"] not in ("auto", "always", "off"):
|
|
371
|
+
_err(where, "datasheet.cross_check", f"= {ds['cross_check']!r}: must be auto (standard table, else a second read), "
|
|
372
|
+
"always (a second read for every part) or off")
|
|
373
|
+
if ds.get("cross_check_model") and not _LL.MODEL_RE.fullmatch(str(ds["cross_check_model"])):
|
|
374
|
+
_err(where, "datasheet.cross_check_model", f"= {ds['cross_check_model']!r}: must be a model id (or endpoint:model); "
|
|
375
|
+
"\"\" = the model whose answer is checked")
|
|
376
|
+
if "ladder" in ds and isinstance(ds["ladder"], list) and not ds["ladder"]:
|
|
377
|
+
_err(where, "datasheet.ladder", "must name at least one model")
|
|
378
|
+
st = raw.get("setup") or {}
|
|
379
|
+
if "tool" in st and st["tool"] not in ("altium", "kicad", "both"):
|
|
380
|
+
_err(where, "setup.tool", f"= {st['tool']!r}: must be altium, kicad or both")
|
|
381
|
+
_LL.validate(where, raw.get("ai"), [("datasheet", ds), ("agent.proof", (raw.get("agent") or {}).get("proof")),
|
|
382
|
+
("style", raw.get("style"))], _err)
|
|
383
|
+
sty = raw.get("style") or {}
|
|
384
|
+
if "apply" in sty and sty["apply"] not in ("auto", "off"):
|
|
385
|
+
_err(where, "style.apply", f"= {sty['apply']!r}: must be auto (adapt new parts to house_style.json) or off")
|
|
386
|
+
if sty.get("profile") and not str(sty["profile"]).endswith(".json"):
|
|
387
|
+
_err(where, "style.profile", f"= {sty['profile']!r}: must be a .json file (written by `ecadlib style learn`)")
|
|
388
|
+
for key in ("parity", "eagle", "easyeda"):
|
|
389
|
+
if key in (raw.get("release") or {}) and raw["release"][key] not in ("hold", "warn", "off"):
|
|
390
|
+
_err(where, f"release.{key}", f"= {raw['release'][key]!r}: must be hold, warn or off")
|
|
391
|
+
if (raw.get("release") or {}).get("approvals"):
|
|
392
|
+
from .approvals import normalize_policy
|
|
393
|
+
try:
|
|
394
|
+
normalize_policy(dict(raw["release"]["approvals"]), f"{where}: release.approvals")
|
|
395
|
+
except (ValueError, TypeError) as e:
|
|
396
|
+
raise RulesError(str(e)) from None
|
|
397
|
+
_check_symbols(where, raw.get("symbols") or {})
|
|
398
|
+
ag = raw.get("agent") or {}
|
|
399
|
+
if "upkeep_schedule" in ag and ag["upkeep_schedule"] not in ("off", "daily", "weekly", "monthly"):
|
|
400
|
+
_err(where, "agent.upkeep_schedule", f"= {ag['upkeep_schedule']!r}: must be off, daily, weekly or monthly")
|
|
401
|
+
if ag.get("github_token_env") and not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", ag["github_token_env"]):
|
|
402
|
+
_err(where, "agent.github_token_env", "must be the NAME of an environment variable (never the token itself)")
|
|
403
|
+
if ag.get("github_repo") and not re.fullmatch(r"[\w.-]+/[\w.-]+", ag["github_repo"]):
|
|
404
|
+
_err(where, "agent.github_repo", f"= {ag['github_repo']!r}: must be owner/name")
|
|
405
|
+
if "github_api" in ag and not str(ag["github_api"]).startswith("https://"):
|
|
406
|
+
_err(where, "agent.github_api", "must be an https:// URL")
|
|
407
|
+
if "git_host" in ag and ag["git_host"] not in ("auto", "github", "gitlab"):
|
|
408
|
+
_err(where, "agent.git_host", f"= {ag['git_host']!r}: must be auto, github or gitlab")
|
|
409
|
+
if ag.get("gitlab_token_env") and not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", ag["gitlab_token_env"]):
|
|
410
|
+
_err(where, "agent.gitlab_token_env", "must be the NAME of an environment variable (never the token itself)")
|
|
411
|
+
if ag.get("gitlab_project") and not re.fullmatch(r"\d+|[\w.-]+(/[\w.-]+)+", ag["gitlab_project"]):
|
|
412
|
+
_err(where, "agent.gitlab_project", f"= {ag['gitlab_project']!r}: must be group/name (subgroups allowed) or a numeric id")
|
|
413
|
+
if "gitlab_api" in ag and not re.fullmatch(r"https://[^\s/]+(/[^\s]*)?/api/v4/?", str(ag["gitlab_api"])):
|
|
414
|
+
_err(where, "agent.gitlab_api", "must be an https:// URL ending in /api/v4 (e.g. https://gitlab.example.com/api/v4)")
|
|
415
|
+
nf = raw.get("notify") or {}
|
|
416
|
+
if "backoff_s" in nf and nf["backoff_s"] > 60:
|
|
417
|
+
_err(where, "notify.backoff_s", "must be 60 seconds or less")
|
|
418
|
+
for x in (raw.get("providers") or {}).get("order", []):
|
|
419
|
+
if not re.fullmatch(r"[A-Za-z0-9_.-]+", x):
|
|
420
|
+
_err(where, "providers.order", f"has {x!r}: provider names are letters, digits, _ . -")
|
|
421
|
+
return raw
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _check_watch(where, w):
|
|
425
|
+
"""[agent.watch]: sources = [{type, ref, ...}], interval (seconds), include / exclude globs."""
|
|
426
|
+
if not isinstance(w, dict):
|
|
427
|
+
_err(where, "agent.watch", "must be a table")
|
|
428
|
+
for k, v in w.items():
|
|
429
|
+
key = f"agent.watch.{k}"
|
|
430
|
+
if k not in DEFAULTS["agent"]["watch"]:
|
|
431
|
+
_err(where, key, f"is not a known key; known in [agent.watch]: {', '.join(DEFAULTS['agent']['watch'])}")
|
|
432
|
+
if k == "interval" and (not isinstance(v, int) or isinstance(v, bool) or not 60 <= v <= 30 * 86400):
|
|
433
|
+
_err(where, key, "must be a whole number of seconds from 60 to 2592000")
|
|
434
|
+
if k in ("include", "exclude") and (not isinstance(v, list) or not all(isinstance(x, str) for x in v)):
|
|
435
|
+
_err(where, key, "must be a list of glob patterns")
|
|
436
|
+
if k == "sources":
|
|
437
|
+
if not isinstance(v, list):
|
|
438
|
+
_err(where, key, "must be a list of { type = ..., ref = ... } tables")
|
|
439
|
+
for i, s in enumerate(v):
|
|
440
|
+
sk = f"{key}[{i}]"
|
|
441
|
+
if not isinstance(s, dict):
|
|
442
|
+
_err(where, sk, "must be a table such as { type = \"bom_file\", ref = \"boms/main.csv\" }")
|
|
443
|
+
bad = [x for x in s if x not in WATCH_SOURCE_KEYS]
|
|
444
|
+
if bad:
|
|
445
|
+
_err(where, f"{sk}.{bad[0]}", f"is not a known key; known: {', '.join(WATCH_SOURCE_KEYS)}")
|
|
446
|
+
if s.get("type") not in WATCH_TYPES:
|
|
447
|
+
_err(where, f"{sk}.type", f"= {s.get('type')!r}: must be one of {', '.join(WATCH_TYPES)}")
|
|
448
|
+
if not isinstance(s.get("ref"), str) or not s["ref"].strip():
|
|
449
|
+
_err(where, f"{sk}.ref", "must be a path, folder or Altium 365 project name / id")
|
|
450
|
+
for x in ("name", "workspace", "variant"):
|
|
451
|
+
if x in s and not isinstance(s[x], str):
|
|
452
|
+
_err(where, f"{sk}.{x}", "must be text")
|
|
453
|
+
for x in ("include", "exclude"):
|
|
454
|
+
if x in s and (not isinstance(s[x], list) or not all(isinstance(g, str) for g in s[x])):
|
|
455
|
+
_err(where, f"{sk}.{x}", "must be a list of glob patterns")
|
|
456
|
+
def _check_table(where, sec, k, v, d):
|
|
457
|
+
"""A nested table such as [agent.proof]: known keys, each of its default's type."""
|
|
458
|
+
if not isinstance(v, dict):
|
|
459
|
+
_err(where, f"{sec}.{k}", "must be a table")
|
|
460
|
+
for kk, vv in v.items():
|
|
461
|
+
key = f"{sec}.{k}.{kk}"
|
|
462
|
+
if kk not in d:
|
|
463
|
+
_err(where, key, f"is not a known key; known in [{sec}.{k}]: {', '.join(d)}")
|
|
464
|
+
dd = d[kk]
|
|
465
|
+
if isinstance(dd, list):
|
|
466
|
+
vv = [vv] if isinstance(vv, str) else vv
|
|
467
|
+
if not isinstance(vv, list) or not all(isinstance(x, str) for x in vv):
|
|
468
|
+
_err(where, key, "must be a list of text values")
|
|
469
|
+
elif isinstance(dd, int) and not isinstance(dd, bool):
|
|
470
|
+
lo, hi = SUB_RANGE.get((sec, k, kk), (0, 1_000_000))
|
|
471
|
+
if not isinstance(vv, int) or isinstance(vv, bool) or not lo <= vv <= hi:
|
|
472
|
+
_err(where, key, f"must be a whole number from {lo} to {hi}")
|
|
473
|
+
elif isinstance(dd, float):
|
|
474
|
+
if not isinstance(vv, (int, float)) or isinstance(vv, bool) or vv < 0:
|
|
475
|
+
_err(where, key, "must be a number of 0 or more")
|
|
476
|
+
elif not isinstance(vv, str):
|
|
477
|
+
_err(where, key, "must be text")
|
|
478
|
+
if (sec, k) == ("agent", "proof"):
|
|
479
|
+
if v.get("effort") not in (None, "", "low", "medium", "high", "xhigh", "max"):
|
|
480
|
+
_err(where, "agent.proof.effort", f"= {v['effort']!r}: must be low, medium, high, xhigh or max (empty = [datasheet])")
|
|
481
|
+
if v.get("model") and not _LL.MODEL_RE.fullmatch(str(v["model"])):
|
|
482
|
+
_err(where, "agent.proof.model", f"= {v['model']!r}: must be a model id such as claude-opus-5-5")
|
|
483
|
+
if v.get("api_key_env") and not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", v["api_key_env"]):
|
|
484
|
+
_err(where, "agent.proof.api_key_env", "must be the NAME of an environment variable (never the key itself)")
|
|
485
|
+
fo = [v["fail_on"]] if isinstance(v.get("fail_on"), str) else v.get("fail_on") or []
|
|
486
|
+
if any(x not in ("error", "warning") for x in fo):
|
|
487
|
+
_err(where, "agent.proof.fail_on", f"= {v['fail_on']!r}: severities are error, warning")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _check_symbols(where, sy):
|
|
491
|
+
from .symbol_layout import GROUPS, STACKS, POWER_SIDES
|
|
492
|
+
if "power_side" in sy and sy["power_side"] not in POWER_SIDES:
|
|
493
|
+
_err(where, "symbols.power_side", f"= {sy['power_side']!r}: must be one of {', '.join(POWER_SIDES)}")
|
|
494
|
+
if "stack_power" in sy and sy["stack_power"] not in STACKS:
|
|
495
|
+
_err(where, "symbols.stack_power", f"= {sy['stack_power']!r}: must be one of {', '.join(STACKS)}")
|
|
496
|
+
grid = sy.get("grid", DEFAULTS["symbols"]["grid"])
|
|
497
|
+
if "grid" in sy and grid not in (50, 100):
|
|
498
|
+
_err(where, "symbols.grid", f"= {grid!r}: must be 50 or 100 (mil)")
|
|
499
|
+
if sy.get("pin_length", DEFAULTS["symbols"]["pin_length"]) % grid:
|
|
500
|
+
_err(where, "symbols.pin_length", f"= {sy.get('pin_length')!r}: must be a multiple of the grid ({grid} mil) so pin ends are on it")
|
|
501
|
+
for g in sy.get("order", []):
|
|
502
|
+
if g not in GROUPS:
|
|
503
|
+
_err(where, "symbols.order", f"has {g!r}: groups are {', '.join(GROUPS)}")
|
|
504
|
+
for g, side in (sy.get("sides") or {}).items():
|
|
505
|
+
if g not in GROUPS:
|
|
506
|
+
_err(where, f"symbols.sides.{g}", f"is not a group; groups are {', '.join(GROUPS)}")
|
|
507
|
+
if side not in ("left", "right", "top", "bottom"):
|
|
508
|
+
_err(where, f"symbols.sides.{g}", f"= {side!r}: must be left, right, top or bottom")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _check_template(where, key, t, fields):
|
|
512
|
+
if not isinstance(t, str) or not t.strip():
|
|
513
|
+
_err(where, key, "must be a non-empty template")
|
|
514
|
+
bad = [f for f in _template_fields(t) if f not in fields]
|
|
515
|
+
if bad:
|
|
516
|
+
_err(where, key, f"= {t!r}: unknown field {{{bad[0]}}}; fields are {', '.join('{' + f + '}' for f in fields)}")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def is_default(rules, section, key=None):
|
|
520
|
+
r = (rules or DEFAULTS)[section]
|
|
521
|
+
return (r == DEFAULTS[section]) if key is None else r.get(key) == DEFAULTS[section][key]
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# ------------------------------------------------------------------ layers
|
|
525
|
+
NAMED_LAYERS = {"topoverlay": 33, "bottomoverlay": 34, "toppaste": 35, "bottompaste": 36, "topsolder": 37, "bottomsolder": 38,
|
|
526
|
+
"toplayer": 1, "bottomlayer": 32, "keepout": 56}
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def layer_id(name):
|
|
530
|
+
"""'Mechanical15' / 'Mechanical 15' / 'M15' / 'Top Overlay' / 71 -> Altium layer id (Mechanical n = 56 + n, 1..16)."""
|
|
531
|
+
if isinstance(name, int):
|
|
532
|
+
return name
|
|
533
|
+
s = re.sub(r"[\s_]+", "", str(name)).lower()
|
|
534
|
+
m = re.fullmatch(r"(?:mechanical|mech|m)(\d+)", s)
|
|
535
|
+
if m:
|
|
536
|
+
n = int(m.group(1))
|
|
537
|
+
if not 1 <= n <= 16:
|
|
538
|
+
raise ValueError("mechanical layers 1 to 16 are supported")
|
|
539
|
+
return 56 + n
|
|
540
|
+
if s in NAMED_LAYERS:
|
|
541
|
+
return NAMED_LAYERS[s]
|
|
542
|
+
raise ValueError("not an Altium layer name (e.g. Mechanical15, TopOverlay)")
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def layer_name(i):
|
|
546
|
+
return f"Mechanical{i - 56}" if 57 <= i <= 72 else {v: k for k, v in NAMED_LAYERS.items()}.get(i, str(i)).replace(
|
|
547
|
+
"top", "Top").replace("bottom", "Bottom").replace("overlay", "Overlay").replace("paste", "Paste").replace(
|
|
548
|
+
"solder", "Solder").replace("layer", "Layer").replace("keepout", "KeepOut")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def layers(rules):
|
|
552
|
+
"""{role: layer id} for the PcbLib writer; None when the rules keep ecadlib's layers."""
|
|
553
|
+
r = (rules or DEFAULTS)["layers"]
|
|
554
|
+
return None if r == DEFAULTS["layers"] else {k: layer_id(v) for k, v in r.items()}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def ipc_density(rules):
|
|
558
|
+
"""footprint_gen density letter (A/B/C) for the rules' IPC level; None for N (nominal, ecadlib's default)."""
|
|
559
|
+
d = (rules or DEFAULTS)["footprint"]["density"]
|
|
560
|
+
return None if d == "N" else DENSITY[d]
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def density_for(spec, rules, explicit=None):
|
|
564
|
+
"""Density to build a spec with: an explicit choice, then the rules (when not N), then the spec's own (None = B)."""
|
|
565
|
+
return explicit or ipc_density(rules) or (spec or {}).get("density")
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# ------------------------------------------------------------------ parameters
|
|
569
|
+
def param_name(canonical, rules):
|
|
570
|
+
return ((rules or DEFAULTS)["parameters"]["names"] or {}).get(canonical, canonical)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def canonical_param(house, rules):
|
|
574
|
+
inv = {v: k for k, v in ((rules or DEFAULTS)["parameters"]["names"] or {}).items()}
|
|
575
|
+
return inv.get(house, house)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def house_params(params, rules):
|
|
579
|
+
"""[{name, value}] in ecadlib names -> house names (order kept)."""
|
|
580
|
+
if not (rules or DEFAULTS)["parameters"]["names"]:
|
|
581
|
+
return params
|
|
582
|
+
return [dict(p, name=param_name(p["name"], rules)) for p in params]
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def house_item(item, rules):
|
|
586
|
+
"""A plan item as it should look in the user's library: parameter and cleared names in house names."""
|
|
587
|
+
if not (rules or DEFAULTS)["parameters"]["names"]:
|
|
588
|
+
return item
|
|
589
|
+
return dict(item, parameters=house_params(item.get("parameters", []), rules),
|
|
590
|
+
cleared=[param_name(c, rules) for c in item.get("cleared", [])])
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def house_row(row, rules):
|
|
594
|
+
"""A DbLib row (column -> value) with parameter columns renamed; key and reserved columns stay."""
|
|
595
|
+
if not (rules or DEFAULTS)["parameters"]["names"]:
|
|
596
|
+
return row
|
|
597
|
+
return {(k if k in KEY_COLUMNS else param_name(k, rules)): v for k, v in row.items()}
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def default_params(rules):
|
|
601
|
+
"""[(canonical name, value)] to add when missing."""
|
|
602
|
+
return [(canonical_param(k, rules), str(v)) for k, v in ((rules or DEFAULTS)["parameters"]["defaults"] or {}).items()]
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def bom_columns(rules):
|
|
606
|
+
"""{BOM field: your header} for bom_triage.read_bom, or None."""
|
|
607
|
+
return dict((rules or DEFAULTS)["bom"]["columns"]) or None
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# ------------------------------------------------------------------ naming
|
|
611
|
+
SI_PRE = [(1e-12, "p"), (1e-9, "n"), (1e-6, "u"), (1e-3, "m"), (1.0, ""), (1e3, "k"), (1e6, "M"), (1e9, "G")]
|
|
612
|
+
UNIT = {"Resistors": "", "Capacitors": "F", "Inductors": "H"}
|
|
613
|
+
CAT3 = {"Resistors": "RES", "Capacitors": "CAP", "Inductors": "IND", "Diodes": "DIO", "Transistors": "TRA",
|
|
614
|
+
"Crystals": "CRY", "Connectors": "CON", "ICs": "ICS", "Misc": "MIS"}
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
PREFIXES = {"Resistors": "kMG", "Capacitors": "pnu", "Inductors": "num"}
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _mantissa(v, cat):
|
|
621
|
+
pres = [(f, p) for f, p in SI_PRE if p in PREFIXES[cat] or (cat == "Resistors" and p == "")]
|
|
622
|
+
if v == 0:
|
|
623
|
+
return 0.0, pres[0][1]
|
|
624
|
+
f, p = next(((f, p) for f, p in reversed(pres) if abs(v) >= f * 0.9999), pres[0])
|
|
625
|
+
return round(v / f, 6), p
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _num(m):
|
|
629
|
+
s = f"{m:.6g}"
|
|
630
|
+
return s
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def fmt_value(raw, cat, style):
|
|
634
|
+
"""BOM value -> the rules' value style. Unparseable values and style 'keep' come back unchanged."""
|
|
635
|
+
if style == "keep" or cat not in UNIT or not str(raw or "").strip():
|
|
636
|
+
return raw
|
|
637
|
+
from .kicad_index import parse_value
|
|
638
|
+
v = parse_value(raw)
|
|
639
|
+
return raw if v is None else fmt_number(v, cat, style)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def fmt_number(v, cat, style):
|
|
643
|
+
"""A value in base units (ohm, F, H) written in a value style."""
|
|
644
|
+
m, p = _mantissa(v, cat)
|
|
645
|
+
unit_letter = {"": {"Resistors": "R", "Capacitors": "F", "Inductors": "H"}[cat]} # 1.5 F -> 1F5 (IEC 60062), never 15
|
|
646
|
+
if style in ("rkm", "rkm_lower"):
|
|
647
|
+
L = unit_letter.get(p, p)
|
|
648
|
+
L = (L if L == "m" else L.upper()) if style == "rkm" else (L if L in ("M", "R", "F", "H") else L.lower()) # milli
|
|
649
|
+
if cat == "Resistors" and style == "rkm_lower" and p == "k":
|
|
650
|
+
L = "k"
|
|
651
|
+
ip, _, fp = _num(m).partition(".")
|
|
652
|
+
return f"{ip}{L}{fp}" if fp else f"{ip}{L}"
|
|
653
|
+
s = _num(m)
|
|
654
|
+
if style == "si":
|
|
655
|
+
return f"{s}{p}"
|
|
656
|
+
if style == "si_upper":
|
|
657
|
+
return f"{s}{p.upper() if p not in ('m',) else p}"
|
|
658
|
+
return f"{s}{p}{UNIT[cat]}" # si_unit
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
TOL_RE = re.compile(r"(?<![\d.])±?\s?(\d+(?:\.\d+)?)\s?%")
|
|
662
|
+
VOLT_RE = re.compile(r"(?<![\w.])(\d+(?:\.\d+)?)\s?V(?:DC)?(?![A-Za-z])")
|
|
663
|
+
DIEL_RE = re.compile(r"\b(X[5-8][RSTPV]|C0G|NP0|COG|Y5V|Z5U)\b", re.I)
|
|
664
|
+
POWER_RE = re.compile(r"(?<![\w.])(\d+/\d+|\d+(?:\.\d+)?)\s?W(?![A-Za-z])")
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def line_fields(ln, cat, pkg, rules=None, ref=""):
|
|
668
|
+
"""Template fields for one BOM line (dict from bom_triage.read_bom)."""
|
|
669
|
+
from .kicad_index import METRIC_TO_IMPERIAL
|
|
670
|
+
text = " ".join(str(ln.get(k, "")) for k in ("value", "description"))
|
|
671
|
+
g = lambda rx: (rx.search(text).group(0).replace(" ", "").lstrip("±") if rx.search(text) else "")
|
|
672
|
+
met = {v: k for k, v in METRIC_TO_IMPERIAL.items()}.get(pkg or "", "")
|
|
673
|
+
diel = g(DIEL_RE).upper().replace("COG", "C0G")
|
|
674
|
+
return {"mpn": ln.get("mpn", ""), "manufacturer": ln.get("manufacturer", ""),
|
|
675
|
+
"value": fmt_value(ln.get("value", ""), cat, (rules or DEFAULTS)["naming"]["value_style"]),
|
|
676
|
+
"package": pkg or "", "metric": met, "category": cat, "cat3": CAT3.get(cat, cat[:3].upper()),
|
|
677
|
+
"ref": ref or CAT_REF.get(cat, "U"), "tolerance": g(TOL_RE), "voltage": g(VOLT_RE).replace("VDC", "V"),
|
|
678
|
+
"dielectric": diel, "power": g(POWER_RE), "description": ln.get("description", "")}
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def render(template, fields):
|
|
682
|
+
"""Fill a template; an empty field takes one neighbouring separator with it (RES_{package}_{value} without a
|
|
683
|
+
package gives RES_10k, not RES__10k). Returns '' when every field in it is empty."""
|
|
684
|
+
names = _template_fields(template)
|
|
685
|
+
if names and not any(str(fields.get(n) or "") for n in names):
|
|
686
|
+
return ""
|
|
687
|
+
out = template
|
|
688
|
+
for n in names:
|
|
689
|
+
v = str(fields.get(n) or "")
|
|
690
|
+
if v:
|
|
691
|
+
out = out.replace("{" + n + "}", v, 1)
|
|
692
|
+
else:
|
|
693
|
+
out = re.sub(r"[_\-. ]?\{" + n + r"\}|\{" + n + r"\}[_\-. ]?", "", out, count=1)
|
|
694
|
+
return out.strip("_-. ")
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def template_for(cat, rules, ref=""):
|
|
698
|
+
n = (rules or DEFAULTS)["naming"]
|
|
699
|
+
by = n.get("by_category") or {}
|
|
700
|
+
return by.get(cat) or by.get(ref) or (n["passive"] if cat in PASSIVES else n["component"])
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def component_name(item_or_fields, rules, cat=None):
|
|
704
|
+
"""Part name per the rules. item_or_fields: a fields dict from line_fields (or a BOM line plus cat)."""
|
|
705
|
+
f = item_or_fields if "cat3" in item_or_fields else line_fields(item_or_fields, cat or "Misc", "", rules)
|
|
706
|
+
cat = f.get("category") or cat or "Misc"
|
|
707
|
+
t = template_for(cat, rules, f.get("ref", ""))
|
|
708
|
+
name = render(t, f)
|
|
709
|
+
if ("{value}" in t and not f.get("value")) or ("{mpn}" in t and not f.get("mpn")):
|
|
710
|
+
name = "" # the template's key field is missing: named like any other part
|
|
711
|
+
if not name:
|
|
712
|
+
name = render((rules or DEFAULTS)["naming"]["component"], f) or f.get("mpn") or f.get("value") or ""
|
|
713
|
+
case = (rules or DEFAULTS)["naming"]["case"]
|
|
714
|
+
return name.upper() if case == "upper" else name.lower() if case == "lower" else name
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def symbol_name(fields, rules):
|
|
718
|
+
t = (rules or DEFAULTS)["naming"]["symbol"]
|
|
719
|
+
return render(t, fields) or fields.get("mpn", "")
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
# ------------------------------------------------------------------ footprint names
|
|
723
|
+
CHIP_H = {"R": {"0201": 26, "0402": 40, "0603": 55, "0805": 70, "1206": 70, "1210": 70, "2010": 70, "2512": 70},
|
|
724
|
+
"C": {"0201": 33, "0402": 55, "0603": 90, "0805": 125, "1206": 160, "1210": 250, "1812": 250},
|
|
725
|
+
"L": {"0402": 55, "0603": 90, "0805": 125, "1206": 160}, "LED": {"0402": 55, "0603": 80, "0805": 100, "1206": 120}}
|
|
726
|
+
CHIP_PREFIX = {"R": "RESC", "C": "CAPC", "L": "INDC", "LED": "LEDC", "D": "DIOC"}
|
|
727
|
+
IPC_NAME = re.compile(r"^((RES|CAP|IND|LED|DIO|FUS)(C|M|MP)\d{4}X\d+|(SOIC|SOP|SSOP|TSOP|QFP|QFN|SON|DFN|SOT|SOD|BGA|PQFN|CAPAE)"
|
|
728
|
+
r"\d+(P\d+)?X\d+(X\d+)?(-\d+)?)([LNM])$", re.I)
|
|
729
|
+
GULL = {"SOIC": "SOIC", "SOP": "SOP", "TSSOP": "SOP", "MSOP": "SOP", "SSOP": "SOP", "VSSOP": "SOP"}
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _h(spec, default):
|
|
733
|
+
if spec and spec.get("height_max"):
|
|
734
|
+
from .footprint_gen import dim
|
|
735
|
+
return int(round(dim(spec["height_max"])[2] * 100))
|
|
736
|
+
return default
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def ipc7351_name(kicad, spec=None, density="N"):
|
|
740
|
+
"""KiCad library name -> IPC-7351 land pattern name (RESC1608X55N, SOIC127P600X175-8N, QFN50P300X300X100-17N).
|
|
741
|
+
Heights come from the package spec when there is one, else typical values; None when the family is unknown."""
|
|
742
|
+
from .kicad_index import METRIC_TO_IMPERIAL
|
|
743
|
+
n = str(kicad).split(":")[-1]
|
|
744
|
+
m = re.match(r"^(R|C|L|LED|D)_(\d{4})_(\d{4})Metric", n)
|
|
745
|
+
if m:
|
|
746
|
+
kind, imp, met = m.groups()
|
|
747
|
+
h = _h(spec, CHIP_H.get(kind, CHIP_H["C"]).get(imp, 60))
|
|
748
|
+
return f"{CHIP_PREFIX[kind]}{met}X{h}{density}"
|
|
749
|
+
m = re.match(r"^(SOIC|SOP|TSSOP|MSOP|SSOP|VSSOP)-(\d+)_(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)mm_P(\d+(?:\.\d+)?)mm", n)
|
|
750
|
+
if m:
|
|
751
|
+
fam, pins, bw, _, p = m.groups()
|
|
752
|
+
span = {"3.9": 600, "4.4": 640, "3": 490, "5.3": 780, "7.5": 1030, "5.6": 780}.get(bw, int(round((float(bw) + 2.1) * 100)))
|
|
753
|
+
h = _h(spec, {"SOIC": 175, "SSOP": 200}.get(fam, 120))
|
|
754
|
+
return f"{GULL[fam]}{int(round(float(p) * 100))}P{span}X{h}-{pins}{density}"
|
|
755
|
+
m = re.match(r"^(V|W|U|X)?(QFN|DFN|SON)-(\d+)(-1EP)?_(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)mm_P(\d+(?:\.\d+)?)mm", n)
|
|
756
|
+
if m:
|
|
757
|
+
_, fam, pins, ep, a, b, p = m.groups()
|
|
758
|
+
h = _h(spec, 100)
|
|
759
|
+
fam = "SON" if fam in ("DFN", "SON") else "QFN"
|
|
760
|
+
return f"{fam}{int(round(float(p) * 100))}P{int(round(float(a) * 100))}X{int(round(float(b) * 100))}X{h}-{int(pins) + (1 if ep else 0)}{density}"
|
|
761
|
+
m = re.match(r"^(L|T)QFP-(\d+)_(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)mm_P(\d+(?:\.\d+)?)mm", n)
|
|
762
|
+
if m:
|
|
763
|
+
_, pins, a, b, p = m.groups()
|
|
764
|
+
h = _h(spec, 160)
|
|
765
|
+
return f"QFP{int(round(float(p) * 100))}P{int(round((float(a) + 2) * 100))}X{int(round((float(b) + 2) * 100))}X{h}-{pins}{density}"
|
|
766
|
+
sot = {"SOT-23": "SOT95P240X{h}-3", "SOT-23-5": "SOT95P280X{h}-5", "SOT-23-6": "SOT95P280X{h}-6", "SOT-223-3_TabPin2": "SOT230P700X{h}-4"}
|
|
767
|
+
if n in sot:
|
|
768
|
+
return sot[n].format(h=_h(spec, 180 if "223" in n else 145 if n != "SOT-23" else 120)) + density
|
|
769
|
+
return None
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def footprint_name(name, rules, kind=None, spec=None):
|
|
773
|
+
"""Footprint name per [naming] footprint: 'kicad' keeps it; 'ipc7351' converts KiCad names it knows; a template
|
|
774
|
+
is used when its fields can be filled (chip packages), else the name is kept."""
|
|
775
|
+
mode = (rules or DEFAULTS)["naming"]["footprint"]
|
|
776
|
+
if not name or mode == "kicad":
|
|
777
|
+
return name
|
|
778
|
+
density = (rules or DEFAULTS)["footprint"]["density"]
|
|
779
|
+
if mode == "ipc7351":
|
|
780
|
+
return name if IPC_NAME.match(str(name)) else (ipc7351_name(name, spec, density) or name)
|
|
781
|
+
from .kicad_index import METRIC_TO_IMPERIAL, package_of
|
|
782
|
+
pkg = package_of(name) or ""
|
|
783
|
+
if pkg not in METRIC_TO_IMPERIAL.values():
|
|
784
|
+
return name
|
|
785
|
+
m = re.match(r"^(R|C|L|LED|D|CP)_", str(name).split(":")[-1])
|
|
786
|
+
ref = kind or (m.group(1) if m else "")
|
|
787
|
+
cat = REF_CLASS.get(ref, "")
|
|
788
|
+
f = {"kicad": str(name).split(":")[-1], "package": pkg, "metric": {v: k for k, v in METRIC_TO_IMPERIAL.items()}[pkg],
|
|
789
|
+
"ref": ref, "cat3": CAT3.get(cat, ""), "density": density, "pins": "2"}
|
|
790
|
+
if any(not f.get(x) for x in _template_fields(mode)):
|
|
791
|
+
return name
|
|
792
|
+
return render(mode, f) or name
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
# ------------------------------------------------------------------ folders
|
|
796
|
+
def folder_for(cat, rules, root=None):
|
|
797
|
+
"""Workspace folder for a category (or designator class): [folders] map, else <root>/<category>."""
|
|
798
|
+
r = (rules or DEFAULTS)["folders"]
|
|
799
|
+
m = r.get("map") or {}
|
|
800
|
+
hit = m.get(cat) or next((v for k, v in m.items() if REF_CLASS.get(k) == cat), None)
|
|
801
|
+
if hit:
|
|
802
|
+
return hit
|
|
803
|
+
cat = REF_CLASS.get(cat, cat)
|
|
804
|
+
return f"{root if root is not None else r['root']}/{cat}"
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
# ------------------------------------------------------------------ writing ecadlib.toml
|
|
808
|
+
def _q(s):
|
|
809
|
+
return json.dumps(str(s), ensure_ascii=False).replace("\x7f", "\\u007f") # TOML refuses a raw DEL
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _c(s):
|
|
813
|
+
"""One comment line: notes carry text from scanned libraries (part names, BOM headers), and a newline in one
|
|
814
|
+
would end the comment and put the rest into the file as TOML."""
|
|
815
|
+
return re.sub(r"[\x00-\x1f\x7f\u2028\u2029]+", " ", str(s))
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _key(k):
|
|
819
|
+
return k if re.fullmatch(r"[A-Za-z0-9_-]+", k) else _q(k)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _val(v):
|
|
823
|
+
if isinstance(v, bool):
|
|
824
|
+
return "true" if v else "false"
|
|
825
|
+
if isinstance(v, (int, float)):
|
|
826
|
+
return str(v)
|
|
827
|
+
if isinstance(v, list):
|
|
828
|
+
return "[" + ", ".join(_val(x) for x in v) + "]"
|
|
829
|
+
if isinstance(v, dict):
|
|
830
|
+
return "{ " + ", ".join(f"{_key(k)} = {_val(x)}" for k, x in v.items()) + " }"
|
|
831
|
+
return _q(v)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
HELP = {
|
|
835
|
+
"naming": "How new parts, symbols and footprints are named. Template fields: " + ", ".join("{" + f + "}" for f in FIELDS),
|
|
836
|
+
"naming.component": "parts that are not generic passives",
|
|
837
|
+
"naming.passive": "resistors, capacitors and inductors with a value",
|
|
838
|
+
"naming.footprint": "kicad (KiCad/IPC names as today) | ipc7351 (RESC1608X55N) | a template such as \"{ref}{package}\"",
|
|
839
|
+
"naming.value_style": "keep | rkm (4K7, 100N) | rkm_lower (4k7, 100n) | si (4.7k, 100n) | si_upper (4.7K) | si_unit (100nF)",
|
|
840
|
+
"parameters": "Parameters every part must have, your names for ecadlib's, and values to add when missing",
|
|
841
|
+
"parameters.names": "ecadlib name = your name",
|
|
842
|
+
"folders": "Altium 365 / DbLib folder per category or designator class",
|
|
843
|
+
"layers": "Altium layers for footprint content (Mechanical1..16, TopOverlay)",
|
|
844
|
+
"footprint": "IPC-7351 density L (least) | N (nominal) | M (most); pin-1 pad names accepted",
|
|
845
|
+
"sources": "Where missing symbols and footprints are looked for, in order",
|
|
846
|
+
"bom": "Your BOM column headers, when ecadlib's guess is wrong (field = header, or field = [\"header\", \"alias\"])",
|
|
847
|
+
"release": "Sign-off policy (same keys as approvals.json); an approvals.json next to a plan wins",
|
|
848
|
+
"release.parity": "hold | warn | off: a part whose KiCad and Altium files differ (pads, drills, courtyard, 3D, pins) is held",
|
|
849
|
+
"release.eagle": "hold | warn | off: a footprint written as a Fusion Electronics / EAGLE package that reads back "
|
|
850
|
+
"differently (pads, drills, mask, paste, courtyard) is held or warned about",
|
|
851
|
+
"release.easyeda": "hold | warn | off: the same check for the footprint written as an EasyEDA Pro footprint",
|
|
852
|
+
"sim": "Parasitics for the built-in passive simulation models (sim-models), e.g. capacitor_esr = \"10m\"",
|
|
853
|
+
"datasheet": "Claude datasheet reader (ecadlib datasheet / librarian): model, effort, which environment variable holds the "
|
|
854
|
+
"API key, a cost cap per run (0 = none) and how many pages to send before picking pages by text (0 = all)",
|
|
855
|
+
"datasheet.api_key_env": "name of the environment variable with the API key; the key itself never goes in this file",
|
|
856
|
+
"datasheet.max_cost_usd": "refuse a run whose estimated worst-case cost is higher (0 = no cap; --yes overrides)",
|
|
857
|
+
"datasheet.page_budget": "pages sent on the first try, picked by text (drawing for this package, land pattern, pin table, "
|
|
858
|
+
"ordering info); widened to 3x, then the whole PDF, when the answer is incomplete (0 = whole PDF)",
|
|
859
|
+
"datasheet.route": "single = `model` for every part | ladder = start on the cheapest model of `ladder` that suits the "
|
|
860
|
+
"package (pins, family, pages) and move up when datasheet's checks fail",
|
|
861
|
+
"datasheet.ladder": "models for route = \"ladder\", cheapest first",
|
|
862
|
+
"datasheet.escalate": "retry with more pages / the next model when the answer is incomplete or fails the checks",
|
|
863
|
+
"datasheet.max_attempts": "most answers paid for per part (first try included)",
|
|
864
|
+
"datasheet.cross_check": "auto: compare every datasheet footprint with the standard package table, or (not in the table) "
|
|
865
|
+
"with a second read of only the critical numbers; disagreement holds the part | always: the "
|
|
866
|
+
"second read for every datasheet part | off",
|
|
867
|
+
"datasheet.cross_check_model": "model for the second read (\"\" = the model whose answer is checked; endpoint:model works)",
|
|
868
|
+
"ai.temperature": "auto: temperature 0 (+ seed) where the model takes one; Claude 4.7+/5.x reject it and get nothing | "
|
|
869
|
+
"off | a number 0-2",
|
|
870
|
+
"agent.proof": "Claude visual proof (ecadlib visual-check): model, effort, api_key_env (empty = [datasheet]), max_cost_usd per "
|
|
871
|
+
"check (0 = no cap), fail_on (mismatch severities that fail a part), max_pages, dpi of the datasheet pages",
|
|
872
|
+
"ai": "Bring your own model (ecadlib.llm): provider = anthropic (default) | openai-compatible | openai | openrouter | "
|
|
873
|
+
"gemini | ollama | lmstudio | vllm | llamacpp; model; base_url; api_key_env (the NAME of the variable, never the "
|
|
874
|
+
"key); assist = auto | off | full (guide rails by the model's tier); feedback_retry; detect; [ai.profiles.\"model\"] "
|
|
875
|
+
"tier/pdf/vision/json_schema/context overrides; [ai.endpoints.name] for a mixed ladder (name:model)",
|
|
876
|
+
"ai.assist": "auto: rails by the model's tier (frontier: none beyond support) | off: support only | full: every rail",
|
|
877
|
+
"ai.feedback_retry": "one more turn with datasheet's exact check errors before a failing answer is kept (every model)",
|
|
878
|
+
"datasheet.provider": "\"\" = [ai] provider; set it to read datasheets with another provider than the rest",
|
|
879
|
+
"symbols": "IC symbols: pins grouped by function (inputs left, outputs right, power top, ground bottom); see `ecadlib symbol -h`",
|
|
880
|
+
"symbols.layout_pins": "parts with at least this many pins get the grouped layout; smaller parts keep the plain one (0 = never)",
|
|
881
|
+
"symbols.power_side": "top: power pins on top, ground on the bottom | left: top and bottom empty, power at the top of the left side, ground at its bottom",
|
|
882
|
+
"symbols.split_pins": "more pins than this: a multi-part symbol (power part + signal parts); 0 = one part always",
|
|
883
|
+
"symbols.stack_power": "stack (one pin shown, the rest on top of it) | hide (the rest hidden, tied by name) | none",
|
|
884
|
+
"agent.watch": "sources = [{ type = bom_file|folder|altium_project|a365_project, ref = path or project, name, workspace, "
|
|
885
|
+
"variant, include, exclude }], interval = seconds between scans, include / exclude = globs for folders",
|
|
886
|
+
"agent": "Library agent (`ecadlib agent run|serve`): watches projects/BOMs, builds and proves new parts, one GitHub PR per "
|
|
887
|
+
"batch for review, pushes approved parts to Altium 365, turns upkeep findings into work. Off unless enabled",
|
|
888
|
+
"agent.interval_minutes": "minutes between runs of `ecadlib agent serve`",
|
|
889
|
+
"agent.manufacturer_aliases": "alias = canonical manufacturer names the agent treats as one maker in part keys "
|
|
890
|
+
"(added to the built-in table: ST = STMicroelectronics, TI, ADI, WE, ...)",
|
|
891
|
+
"agent.library_repo": "git repo the batch PRs are made in (\"\" = the workspace when it is a git repo; none: PR text in agent/batches/)",
|
|
892
|
+
"agent.github_token_env": "name of the environment variable with the GitHub token; the token itself never goes in this file",
|
|
893
|
+
"agent.auto_push": "push approved parts to Altium 365 on each run (false: `ecadlib agent push`)",
|
|
894
|
+
"agent.upkeep_schedule": "off | daily | weekly | monthly: how often upkeep findings are turned into queue items",
|
|
895
|
+
"agent.batch_size": "most parts in one review batch (one pull request each)",
|
|
896
|
+
"agent.git_host": "auto (GitLab when origin is on a gitlab* host or the gitlab_api host, else GitHub) | github | gitlab",
|
|
897
|
+
"agent.gitlab_api": "GitLab REST v4 base URL; self-hosted: https://gitlab.example.com/api/v4",
|
|
898
|
+
"agent.gitlab_project": "group/name or numeric id (\"\" = from the origin remote)",
|
|
899
|
+
"agent.gitlab_token_env": "name of the environment variable with the GitLab token (api scope); never the token itself",
|
|
900
|
+
"notify": "Webhooks when the agent or upkeep has news: [[notify.targets]] name, type = slack|teams|json, url_env = NAME "
|
|
901
|
+
"of the env var holding the URL, events = batch_ready|part_held|upkeep_changes|lifecycle (default all), "
|
|
902
|
+
"token_env (json only); retries with backoff, never stops the agent",
|
|
903
|
+
"providers": "Part sources for triage / librarian / UI, in order, including plugins (ecadlib.providers entry points); "
|
|
904
|
+
"[] = each command's built-in default; `ecadlib plugins list` shows what is installed",
|
|
905
|
+
"setup": "Written by `ecadlib init`: the tool you use, your library folders (the default --lib) and the workspace "
|
|
906
|
+
"(the default --workspace); relative paths start at this file's folder",
|
|
907
|
+
"hub": "The Library page (ecadlib hub): libraries = extra library folders or files to show next to --lib (.SchLib, "
|
|
908
|
+
".PcbLib, .DbLib, .kicad_sym, .pretty, .kicad_dbl, Altium 365 lists), projects = Altium project folders for "
|
|
909
|
+
"where-used, stale_days = how much older a differing copy must be to count as behind; relative paths start "
|
|
910
|
+
"at this file's folder",
|
|
911
|
+
"setup.tool": "altium | kicad | both: which outputs `ecadlib init` and `ecadlib demo` point you at",
|
|
912
|
+
"symbols.order": "group order on each side; groups: power analog power_out ground ep reset clock debug usb adc other_in other uart i2c spi can eth ports other_out nc",
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def dumps(rules, notes=None, only=None, header=None):
|
|
917
|
+
"""Commented TOML. notes: {"section.key": [comment lines]} written above each key. only: keys to write
|
|
918
|
+
(others are left out, i.e. stay at their defaults); None writes every section that differs from the defaults
|
|
919
|
+
plus every key that has a note."""
|
|
920
|
+
notes = notes or {}
|
|
921
|
+
lines = [_c(h) for h in header or []]
|
|
922
|
+
for sec, vals in rules.items():
|
|
923
|
+
if sec.startswith("_") or sec not in DEFAULTS:
|
|
924
|
+
continue
|
|
925
|
+
keys = [k for k in vals if (only is None and (vals[k] != DEFAULTS[sec].get(k) or f"{sec}.{k}" in notes))
|
|
926
|
+
or (only is not None and f"{sec}.{k}" in only)]
|
|
927
|
+
if not keys:
|
|
928
|
+
continue
|
|
929
|
+
lines += ["", *(f"# {_c(h)}" for h in [HELP.get(sec)] if h), f"[{sec}]"]
|
|
930
|
+
tables = []
|
|
931
|
+
for k in keys:
|
|
932
|
+
v = vals[k]
|
|
933
|
+
if (sec, k) in MAP_KEYS and isinstance(v, dict) and v and all(not isinstance(x, dict) for x in v.values()):
|
|
934
|
+
tables.append(k)
|
|
935
|
+
continue
|
|
936
|
+
for n in notes.get(f"{sec}.{k}", []) + ([f"({HELP[f'{sec}.{k}']})"] if f"{sec}.{k}" in HELP else []):
|
|
937
|
+
lines.append(f"# {_c(n)}")
|
|
938
|
+
lines.append(f"{_key(k)} = {_val(v)}")
|
|
939
|
+
for k in tables:
|
|
940
|
+
lines += ["", f"[{sec}.{_key(k)}]"]
|
|
941
|
+
for n in notes.get(f"{sec}.{k}", []) + ([f"({HELP[f'{sec}.{k}']})"] if f"{sec}.{k}" in HELP else []):
|
|
942
|
+
lines.append(f"# {_c(n)}")
|
|
943
|
+
for kk, vv in vals[k].items():
|
|
944
|
+
for n in notes.get(f"{sec}.{k}.{kk}", []):
|
|
945
|
+
lines.append(f"# {_c(n)}")
|
|
946
|
+
lines.append(f"{_key(kk)} = {_val(vv)}")
|
|
947
|
+
return "\n".join(lines).strip("\n") + "\n"
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def diff(old, new, name="ecadlib.toml"):
|
|
951
|
+
return "".join(difflib.unified_diff(old.splitlines(True), new.splitlines(True), f"{name} (current)", f"{name} (proposed)"))
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def effective(rules):
|
|
955
|
+
return {k: v for k, v in rules.items() if not k.startswith("_")}
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def main(argv=None):
|
|
959
|
+
ap = argparse.ArgumentParser(prog="ecadlib rules", description="check or show library rules (ecadlib.toml)")
|
|
960
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
961
|
+
c = sub.add_parser("check", help="validate a rules file and print the effective rules")
|
|
962
|
+
c.add_argument("path", nargs="?")
|
|
963
|
+
sub.add_parser("show", help="print the defaults (what ecadlib does without a file)")
|
|
964
|
+
a = ap.parse_args(argv)
|
|
965
|
+
if a.cmd == "show":
|
|
966
|
+
print(dumps(DEFAULTS, only={f"{s}.{k}" for s in DEFAULTS for k in DEFAULTS[s]}))
|
|
967
|
+
return 0
|
|
968
|
+
try:
|
|
969
|
+
r = load(a.path)
|
|
970
|
+
except RulesError as e:
|
|
971
|
+
print(f"error: {e}", file=sys.stderr)
|
|
972
|
+
return 2
|
|
973
|
+
print(f"# {r['_file'] or 'no rules file found: defaults'}")
|
|
974
|
+
print(json.dumps(effective(r), indent=2))
|
|
975
|
+
return 0
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
if __name__ == "__main__":
|
|
979
|
+
sys.exit(main())
|