Skill/scripts/fill_templates.py

144 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""Fill the reference template into ONE master workbook (Components_Master.xlsx).
The master contains ONLY the type sheets that actually have parts (the components whose
datasheets were provided) - built from a copy of the template so each kept sheet keeps its
exact headers, styling, widths and freeze - plus a single **Meta** sheet appended at the
END with: Template Version, Total Components, Date, Time, and a per-sheet count breakdown.
Empty template sheets are dropped. Column A = <MPN>_<make>_<typeid>.
Shared helpers (part_tag, part_to_row, template_headers, sheet_rows, read_all_rows,
build_master, resolve_version, MASTER_NAME) are imported by gitea_reconcile.py so the Gitea
master is built and merged the exact same way.
python fill_templates.py parts.json --template <template.xlsx> --dest <dir> [--version v1]
"""
import argparse, json, os, re, datetime
import openpyxl
from openpyxl.styles import Font, PatternFill, Border, Side, Alignment
from openpyxl.utils import get_column_letter
GREEN="B6D7A8"; GRAY="BFBFBF"
MASTER_NAME="Components_Master.xlsx"; META_SHEET="Meta"
def norm(s): return re.sub(r'[^a-z0-9]', '', str(s).lower())
ALIAS={"induactanceuh":"inductanceuh"} # template header typo -> value key
def make_tag(manufacturer):
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 template_headers(template_path, ctype):
"""Canonical data headers for a type sheet, or None if the template has no such sheet."""
wb=openpyxl.load_workbook(template_path)
if ctype not in wb.sheetnames: return None
ws=wb[ctype]; return [ws.cell(1,c).value for c in range(1, ws.max_column+1)]
def template_types(template_path):
return [s for s in openpyxl.load_workbook(template_path).sheetnames if s!=META_SHEET]
def part_to_row(part, headers):
"""Return (tag, {header: value}) for one part, following the column rules."""
vals={norm(k):v for k,v in part.get("values",{}).items()}; tag=part_tag(part); row={}
for c,h in enumerate(headers, start=1):
key=ALIAS.get(norm(h),norm(h))
if c==1: row[h]=tag
elif key=="class": row[h]=part.get("subclass","")
elif key=="manufacturer":row[h]=part.get("manufacturer", vals.get("manufacturer",""))
else: row[h]=vals.get(key,"")
return tag,row
def sheet_rows(ws, width):
"""Existing data rows in a sheet -> (headers, {tag: {header: value}}), bounded to `width`."""
headers=[ws.cell(1,c).value for c in range(1,width+1)]; rows={}
for r in range(2, ws.max_row+1):
tag=ws.cell(r,1).value
if tag in (None,""): continue
rows[tag]={headers[c-1]: ws.cell(r,c).value for c in range(1,width+1)}
return headers, rows
def read_all_rows(master_path, template_path):
"""Read every type sheet of an existing master -> {ctype: {tag: rowdict}} (skips Meta)."""
wb=openpyxl.load_workbook(master_path); types=set(template_types(template_path)); out={}
for name in wb.sheetnames:
if name not in types: continue
th=template_headers(template_path,name); w=len(th) if th else 0
if not w: continue
_,rows=sheet_rows(wb[name], w)
if rows: out[name]=rows
return out
def _thin(): s=Side(style="thin",color=GRAY); return Border(left=s,right=s,top=s,bottom=s)
def _hdr(cell):
cell.font=Font(name="Calibri",bold=True); cell.fill=PatternFill("solid",fgColor=GREEN)
cell.border=_thin(); cell.alignment=Alignment(horizontal="center",vertical="center")
def _write_rows(ws, headers, rows_by_tag):
for r in range(2, ws.max_row+1):
for c in range(1, len(headers)+1): ws.cell(r,c).value=None
r=2
for tag,row in rows_by_tag.items():
for c,h in enumerate(headers, start=1): ws.cell(r,c, row.get(h,""))
r+=1
def _build_meta_sheet(wb, version, counts, when):
if META_SHEET in wb.sheetnames: del wb[META_SHEET]
ws=wb.create_sheet(META_SHEET) # appended at the end
for r,(k,v) in enumerate([("Template Version",version),
("Total Components",sum(counts.values())),
("Date",when.strftime("%Y-%m-%d")),
("Time",when.strftime("%H:%M:%S"))], start=1):
a=ws.cell(r,1,k); a.font=Font(name="Calibri",bold=True)
a.fill=PatternFill("solid",fgColor=GREEN); a.border=_thin()
ws.cell(r,2,v).border=_thin()
r=6
for c,t in ((1,"Sheet"),(2,"Components")): _hdr(ws.cell(r,c,t))
for ctype,n in counts.items():
r+=1; ws.cell(r,1,ctype).border=_thin(); ws.cell(r,2,n).border=_thin()
ws.column_dimensions["A"].width=20; ws.column_dimensions["B"].width=16
return ws
def resolve_version(template_path, version):
if version: return version
vf=os.path.join(os.path.dirname(template_path),"VERSION")
return "v"+open(vf).read().strip() if os.path.exists(vf) else "v1"
def build_master(template_path, rows_by_type, version, dest_dir):
"""Write the single master workbook from a copy of the template. `rows_by_type` =
{ctype: {tag: rowdict}} is the COMPLETE desired contents. Only sheets present (with at
least one row) are kept; all other template sheets are dropped. A Meta sheet is appended
last. Returns the output path."""
os.makedirs(dest_dir, exist_ok=True)
wb=openpyxl.load_workbook(template_path); when=datetime.datetime.now(); counts={}
for ctype in template_types(template_path):
ws=wb[ctype]; rows=rows_by_type.get(ctype) or {}
if rows:
th=template_headers(template_path,ctype); width=len(th) if th else ws.max_column
_write_rows(ws, [ws.cell(1,c).value for c in range(1,width+1)], rows)
counts[ctype]=len(rows)
_build_meta_sheet(wb, version, counts, when) # append first so a sheet always remains
for ctype in template_types(template_path):
if ctype not in counts: del wb[ctype] # drop empty type sheets
out=os.path.join(dest_dir, MASTER_NAME); wb.save(out); return out
def main():
ap=argparse.ArgumentParser()
ap.add_argument("parts_json"); ap.add_argument("--template", required=True)
ap.add_argument("--dest", required=True); ap.add_argument("--version", default=None)
a=ap.parse_args()
data=json.load(open(a.parts_json, encoding="utf-8"))
version=resolve_version(a.template, a.version); by={}
for p in data.get("parts", []):
headers=template_headers(a.template, p["type"])
if headers is None: print(f"! no template sheet for type '{p['type']}' - skipping"); continue
tag,row=part_to_row(p, headers); by.setdefault(p["type"],{})[tag]=row
out=build_master(a.template, by, version, a.dest)
total=sum(len(v) for v in by.values())
print(f"master: {out} ({total} part(s) across {len(by)} sheet(s) + Meta, template {version})")
if __name__=="__main__":
main()