Skill/scripts/common.py

191 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""Shared helpers for the library-manager skill.
One place for the three things every script needs to agree on:
- the taxonomy (typeid -> Class -> library-repo folder name), read from
``assets/template/Type_ID.xlsx`` so there is a single source of truth;
- the **per-typeid version store** (``assets/template/versions.json``) that holds each
typeid's own ``template_version`` and ``skill_version`` - both start at 1 and both bump
together the moment a parameter is added to that typeid (and nothing else moves);
- the part **tag** ``<MPN>_<make>_<typeid>`` that names every folder and column-A cell.
Keeping these here means fill_templates / append_parameter / gitea_components can't drift
apart on how a version is read or a folder is named.
"""
import json, os, re
import openpyxl
SKILL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMPLATE_DIR = os.path.join(SKILL_ROOT, "assets", "template")
TEMPLATE_XLSX= os.path.join(TEMPLATE_DIR, "template.xlsx")
TYPE_ID_XLSX = os.path.join(TEMPLATE_DIR, "Type_ID.xlsx")
VERSIONS_JSON= os.path.join(TEMPLATE_DIR, "versions.json")
CHANGELOG_XLSX = os.path.join(SKILL_ROOT, "assets", "CHANGELOG.xlsx")
# Fixed template columns that live on every sheet and are NOT extracted from a datasheet.
COL_TAG = "MPN_make_type" # column A header (the part tag lives here)
COL_SKILL_VER = "Skill Version" # column B (per-typeid skill version, stamped by us)
COL_TEMPLATE_VER = "Template Version" # column C (per-typeid template version, stamped by us)
DESIGN_COLS = ("Library Ref", "Library Path", "Footprint Ref", "Footprint Path")
# Raw taxonomy Class name -> clean, filesystem-safe folder name for the library repo.
# These match Vecmocon's established sheet-name convention (the old 18-class template).
CLASS_FOLDER = {
"Resistor": "Resistor",
"Capacitor": "Capacitor",
"Inductor / Magnetics": "Inductor_Magnetics",
"Diode": "Diode",
"Transistor": "Transistor",
"Integrated Circuit (IC)": "IC",
"Protection Device": "Protection",
"Power Conversion Module": "Power Conversion Module",
"Relay / Contactor": "Relay_Contactor",
"Switch / Button": "Switch_Button",
"Connector": "Connector",
"Antenna / RF": "Antenna_RF",
"Crystal / Oscillator / Timing": "Crystal_Oscillator",
"Battery / Cell": "Battery_Cell",
"Audible / Indicator": "Audible_Indicator",
"Display / HMI": "Display_HMI",
"Sensor (discrete / module)": "Sensor",
"Thermal / Cooling": "Thermal_Cooling",
}
def norm(s):
return re.sub(r"[^a-z0-9]", "", str(s).lower())
# ---------------------------------------------------------------- taxonomy
def load_taxonomy(type_id_xlsx=TYPE_ID_XLSX):
"""Return {typeid: {"class": rawclass, "subclass": name, "folder": foldername}}.
Reads the Type_ID.xlsx. A real mapping row has all three of Class / Subclass / Type ID;
the group-header rows (only a Class label like 'Resistor (12 types)') are skipped
because they have no Type ID.
"""
wb = openpyxl.load_workbook(type_id_xlsx, read_only=True, data_only=True)
ws = wb[wb.sheetnames[0]]
out = {}
for row in ws.iter_rows(min_row=1, values_only=True):
cells = (list(row) + [None, None, None])[:3]
cls, sub, tid = (str(c).strip() if c is not None else "" for c in cells)
if not tid or tid.lower() in ("type id",):
continue
folder = CLASS_FOLDER.get(cls, re.sub(r"\s*/\s*", "_", re.sub(r"\s*\(.*?\)", "", cls)).strip().replace(" ", "_"))
out[tid] = {"class": cls, "subclass": sub, "folder": folder}
return out
def class_folder(typeid, taxonomy=None):
"""The library-repo top-level folder name for a typeid (e.g. SCH -> 'Diode')."""
tax = taxonomy or load_taxonomy()
info = tax.get(typeid)
return info["folder"] if info else None
# ---------------------------------------------------------------- part tag
def make_tag(manufacturer):
"""The 'make' token = first word of the manufacturer, stripped to alphanumerics."""
w = re.split(r"\s+", str(manufacturer).strip())
first = w[0] if w and w[0] else "NA"
return re.sub(r"[^A-Za-z0-9]", "", first) or "NA"
def part_tag(part):
"""Identity of a part: <MPN>_<make>_<typeid>. Same tag = same part."""
make = part.get("make") or make_tag(part.get("manufacturer", ""))
return f'{part.get("mpn","")}_{make}_{part.get("typeid","NA")}'
def mpn_make_prefix(mpn, make):
"""The <MPN>_<make>_ prefix used for the early duplicate check (typeid not yet known)."""
return f"{mpn}_{make}_"
# ---------------------------------------------------------------- version store
def _load_versions():
if os.path.exists(VERSIONS_JSON):
try:
return json.load(open(VERSIONS_JSON, encoding="utf-8"))
except Exception:
pass
return {}
def get_versions(typeid):
"""Return {'template_version': int, 'skill_version': int} for a typeid (default 1/1)."""
v = _load_versions().get(typeid) or {}
return {"template_version": int(v.get("template_version", 1)),
"skill_version": int(v.get("skill_version", 1))}
def version_labels(typeid):
"""Return ('vN', 'vN') = (skill_version, template_version) strings for stamping cells."""
v = get_versions(typeid)
return f'v{v["skill_version"]}', f'v{v["template_version"]}'
def bump_versions(typeid):
"""Increment BOTH template_version and skill_version for one typeid; persist and return
(old, new) each as {'template_version','skill_version'}. Only this typeid changes."""
data = _load_versions()
cur = data.get(typeid) or {"template_version": 1, "skill_version": 1}
old = {"template_version": int(cur.get("template_version", 1)),
"skill_version": int(cur.get("skill_version", 1))}
new = {"template_version": old["template_version"] + 1,
"skill_version": old["skill_version"] + 1}
data[typeid] = new
json.dump(data, open(VERSIONS_JSON, "w", encoding="utf-8"), indent=2, sort_keys=True)
return old, new
def init_versions(force=False):
"""Seed versions.json with 1/1 for every typeid in the taxonomy (idempotent)."""
data = {} if force else _load_versions()
for tid in load_taxonomy():
data.setdefault(tid, {"template_version": 1, "skill_version": 1})
json.dump(data, open(VERSIONS_JSON, "w", encoding="utf-8"), indent=2, sort_keys=True)
return data
# ---------------------------------------------------------------- changelog (read)
CHANGELOG_HEADERS = ["Date", "Typeid", "Skill Version", "Template Version", "Description"]
def read_changelog():
"""All changelog rows as dicts (empty list if the changelog doesn't exist yet)."""
if not os.path.exists(CHANGELOG_XLSX):
return []
wb = openpyxl.load_workbook(CHANGELOG_XLSX)
ws = wb["Changelog"] if "Changelog" in wb.sheetnames else wb.active
out = []
for r in ws.iter_rows(min_row=2, values_only=True):
if not r or all(c is None for c in r):
continue
r = list(r) + [None] * (5 - len(r))
out.append({"date": r[0], "typeid": r[1], "skill": r[2],
"template": r[3], "description": r[4]})
return out
def changelog_for_typeid(typeid):
"""Every recorded version change for one typeid, oldest first — the cumulative history
(v1->v2, v2->v3, ...) that each part sheet of that typeid embeds as its Version History."""
return [r for r in read_changelog() if str(r["typeid"]) == str(typeid)]
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "init":
d = init_versions(force="--force" in sys.argv)
print(f"versions.json seeded with {len(d)} typeids at {VERSIONS_JSON}")
else:
tax = load_taxonomy()
print(f"taxonomy: {len(tax)} typeids, {len(set(v['folder'] for v in tax.values()))} class folders")