From 10fc380a0fd0325aff837e5f20c6176ccb4e0dfd Mon Sep 17 00:00:00 2001 From: datasheet-extractor Date: Tue, 7 Jul 2026 08:40:11 +0000 Subject: [PATCH] Sync datasheet-extractor skill assets (template v1) --- SKILL.md | 150 ++++++++++++--- .../fill_templates.cpython-312.pyc | Bin 0 -> 11177 bytes scripts/fill_templates.py | 155 +++++++++------ scripts/gitea_reconcile.py | 178 ++++++++++++++++++ 4 files changed, 393 insertions(+), 90 deletions(-) create mode 100644 scripts/__pycache__/fill_templates.cpython-312.pyc create mode 100644 scripts/gitea_reconcile.py diff --git a/SKILL.md b/SKILL.md index 373ac5d..d76be90 100644 --- a/SKILL.md +++ b/SKILL.md @@ -6,7 +6,7 @@ description: >- transistors, ICs, protection, power modules, connectors, sensors, and more). Column A = MPN_make_typeid (make = first word of manufacturer, typeid from the taxonomy). Writes one Excel file per type with a Meta sheet holding the template - version, assembles a per-part DFS folder (datasheet, footprint, symbol), and pushes + version, assembles a per-part DFS folder (datasheet, Altium footprint, Altium symbol), and pushes design files to the Gitea DFS repo, Excel outputs to the Parameters repo, and skill files plus templates to the Skill_Assets repo. Also appends new parameters to a template as a new version. Use WHENEVER the user uploads component datasheets and @@ -20,6 +20,12 @@ Read component datasheets, fill the customer's master template, assemble each pa design-file folder, and publish everything to three Gitea repos. Be careful and honest: put every value in the right column and unit, and flag what a datasheet does not state. +**Pushing is the default.** At the end of a run, push every output to Gitea without asking +for confirmation — that's the whole job. Only pause when there's a genuine need for the +user: a **login wall** while fetching a footprint/symbol, or an **MPN that already exists** +in Gitea (discard vs replace, because replace overwrites data). Nothing else warrants a +prompt. + ## Inputs - **Datasheet PDFs**, one per part. The filename is the part's MPN (search that exact @@ -54,8 +60,11 @@ Column A of every sheet (header `MPN_make_type`) and each DFS folder name is: version. (See Producing outputs.) 5. **Assemble each part's DFS folder** `MPN_make_typeid/` containing `MPN_data` (the datasheet), `MPN_fp` (footprint), `MPN_sym` (symbol). See Footprint & symbol. -6. **Push to the three Gitea repos** (flat): DFS folders → **DFS**, Excel outputs → - **Parameters**, skill files + templates → **Skill_Assets**. See Pushing to Gitea. +6. **Push everything to Gitea — automatically.** At the end of a run, push all outputs to + all three repos with no confirmation step: DFS folders → **DFS**, per-type sheets → + **Parameters** (merged in), skill files + templates → **Skill_Assets**. The only reasons + to pause are a login wall or an already-present MPN (discard vs replace); otherwise it + all just goes. See Pushing to Gitea. ## Producing outputs @@ -81,47 +90,126 @@ push them to the Parameters repo. ## Footprint & symbol (for the DFS folder) For each part, the DFS folder needs `MPN_fp` (footprint) and `MPN_sym` (symbol) beside -`MPN_data` (the datasheet). Get them like this, in order: +`MPN_data` (the datasheet). **Deliver these as Altium files** — an Altium PCB footprint +(`.PcbLib`) and schematic symbol (`.SchLib`), or a single integrated library (`.IntLib`) +that carries both — because that's what the design team actually consumes. Get them in +this order: -1. **Search the web** for the part's footprint and schematic symbol by MPN (e.g. SnapEDA, - Ultra Librarian, Component Search Engine, the manufacturer's CAD models). If found, - **cross-check** the footprint's pad/pin count and dimensions against the datasheet's - package drawing before using it. -2. **If not found, generate** the footprint/symbol yourself from the datasheet's package - and pinout **only if you can do so reliably**, then cross-check with the user before - trusting it. -3. **If neither is possible**, still create the folder with the datasheet, and **tell the - user that the footprint and/or symbol needs a manual build** for that MPN (leave a - clearly-named placeholder or omit and list it). +1. **Scout which sources actually hold this part first — before any login.** Search + across SnapEDA, Ultra Librarian, Component Search Engine, DigiKey (its "PCB Symbol, + Footprint & 3D Model" links), and the manufacturer's own CAD downloads, and for each + note whether *this exact MPN* has a downloadable **Altium** symbol + footprint, and + whether grabbing it needs an account. The point is to find where the model lives before + asking the user to do anything. -Keep the datasheet's extension on `MPN_data` (e.g. `BAT46WJ_data.pdf`). Name footprint/ -symbol files `MPN_fp` / `MPN_sym` with whatever extension the source/format uses. +2. **Surface what you found, then handle the login.** Tell the user which sources have the + part in Altium form — e.g. "BAT46WJ: SnapEDA and Ultra Librarian both have an Altium + symbol + footprint; DigiKey lists a model too" — so they can pick. Then get the file: + - If you're driving the user's own signed-in browser (e.g. Claude in Chrome) and it's + already logged into that source, just download — no password ever changes hands. + - Otherwise **ask the user to sign in to the source they picked** (or to grab the file + and drop it in), then continue. Never type, store, or ask for their password, and + never write credentials into this skill — it's pushed to Skill_Assets, and a secret in + a repo is a real leak (same reason the `GIT_TOKEN` is blanked before syncing). + + Once you have a candidate, **cross-check** its pad/pin count and key package dimensions + against the datasheet's package drawing before trusting it — library models are + sometimes wrong, or drawn for a different variant of the series. + +3. **If no good model exists, generate the footprint/symbol yourself** as Altium files + from the datasheet's package drawing and pinout — but only when you can do it reliably + — then **ask the user to verify** it against the datasheet before it's trusted, since a + generated land pattern that's slightly off is worse than an obvious blank. + +4. **If none of that is possible**, still create the folder with the datasheet and **tell + the user that MPN needs a manual footprint/symbol build**, leaving a clearly-named + placeholder or listing it so nothing silently goes missing. + +Keep the datasheet's extension on `MPN_data` (e.g. `BAT46WJ_data.pdf`). Name the +footprint/symbol files `MPN_fp` / `MPN_sym` with the Altium extension the model uses +(`.PcbLib` / `.SchLib`, or a single `.IntLib` when the source bundles both). ## Pushing to Gitea Connection + repos are pre-configured in `config/gitea.env` (host, user, token, and the three repos), so runs need no per-session token. Layout is **flat** (no project/version). -Use `scripts/push_to_gitea.sh` (git over HTTPS), once per repo: + +**Every run ends by pushing all three repos — no "shall I push?" step.** The user has +already asked for this to be automatic; don't re-ask. The reconcile step self-guards +against the one risky case (overwriting an existing part), so a straight push is safe by +default. + +The three repos split into two kinds, and they're pushed differently: + +- **DFS** and **Parameters** are **MPN-indexed** — DFS has one `MPN_make_typeid/` folder + per part, Parameters has one row per part (keyed on column A) inside each `.xlsx`. + Because a re-run can collide with a part that's already there, these go through + `scripts/gitea_reconcile.py`, which is dedup-aware and **merge-safe** (it never blows + away parts it isn't touching). See *Handling parts already in Gitea* below. +- **Skill_Assets** holds the skill itself + templates — it is **not** per-part, so there's + nothing MPN-level to reconcile. Push it with `scripts/push_to_gitea.sh` as before. ```bash -# 1) design files -> DFS repo (staging dir holds MPN_make_typeid/ folders) -bash scripts/push_to_gitea.sh --repo "$DFS_REPO" --src --message "Add datasheets/fp/sym" -# 2) Excel outputs -> Parameters repo -bash scripts/push_to_gitea.sh --repo "$PARAMS_REPO" --src --message "Update parameters" -# 3) skill files + templates -> Skill_Assets repo +# Skill files + templates -> Skill_Assets (flat copy; no dedup needed) bash scripts/push_to_gitea.sh --repo "$SKILL_ASSETS_REPO" --src --message "Sync skill assets" ``` -`$DFS_REPO`, `$PARAMS_REPO`, `$SKILL_ASSETS_REPO` come from `config/gitea.env`. The -script clones the repo, copies the source contents in (flat), commits, and pushes; the -repos must already exist. If the host is unreachable (e.g. Cowork without the domain -allowlisted) it fails clearly and leaves the staged files for a manual push. +The push script clones the repo, copies the source contents in (flat), commits, and +pushes; the repo must already exist. If the host is unreachable (e.g. Cowork without the +domain allowlisted) it fails clearly and leaves the staged files for a manual push. **Push the skill files organised** to Skill_Assets: keep the skill's own structure (`SKILL.md`, `scripts/`, `assets/`, `references/`, `config/`) — but do **not** push the real token. Before syncing skill assets, blank the `GIT_TOKEN` line in the copy you push, or push everything except `config/gitea.env`. +> Why not push Parameters with the flat script? `fill_templates.py` writes a `.xlsx` +> holding **only this run's** rows. Copied flat over the repo, it would overwrite the file +> and delete every previously-stored part of that type. `gitea_reconcile.py` merges into +> the repo's existing sheet instead, so only the parts you decided on change. + +## Handling parts already in Gitea (discard vs replace) + +A part's identity is its tag `MPN_make_typeid` (same tag = same part). A re-run that +brings in a *new* MPN is not a conflict — it just gets added, and you push without asking. +The prompt only exists for the case where an MPN **already lives in DFS/Parameters**, +because "replace" overwrites data that's already on the server. + +The simplest path is a single push. It clones the repos, adds everything new, and stops +**only** if it hits an existing MPN it can't resolve on its own: + +```bash +python scripts/gitea_reconcile.py --parts parts.json --dfs-src \ + --template assets/template/template.xlsx --push +``` + +- **No existing MPNs** → it adds all the folders/rows and pushes. No prompt. Done. +- **An MPN already exists** → it refuses (listing which) so you can ask the user, per MPN, + **discard** (keep what's in Gitea, drop the new copy) or **replace** (overwrite that MPN's + DFS folder *and* its Parameters row with the fresh extraction). Every other folder/row is + always left alone — the Parameters sheet is merged, never wholesale-overwritten. + +Feed the user's answers back as a small JSON map (`tag → replace|discard`): + +```bash +# decisions.json e.g. {"BAT46WJ_Nexperia_SCH": "replace", "1N4148_onsemi_SCH": "discard"} +python scripts/gitea_reconcile.py --parts parts.json --dfs-src \ + --template assets/template/template.xlsx --decisions decisions.json --push +``` + +**For fully unattended runs**, skip the prompt entirely with a standing policy — every +conflict resolved the same way, no questions: + +```bash +python scripts/gitea_reconcile.py --parts parts.json --dfs-src \ + --template assets/template/template.xlsx --on-conflict replace --push +``` + +`--on-conflict replace` (or `discard`) applies to all conflicts; an explicit per-MPN entry +in `--decisions` still wins over the blanket policy. Use `--report conflicts.json` (without +`--push`) if you ever want to preview conflicts first. Host unreachable → it fails clearly, +same as the push script. + ## Updating a template (new parameter) When the user wants a new parameter on a type, **append it at the end** of that type's @@ -142,7 +230,13 @@ their Meta sheet automatically. - `assets/template/template.xlsx` — master template, one sheet per type (source of headers). - `assets/template/Type_ID.xlsx` + `references/taxonomy.md` — Class → Subclass → Type ID. - `assets/template/VERSION` — current template version (integer; Meta sheet shows `vN`). -- `scripts/fill_templates.py` — build per-type Excel outputs (+ Meta sheet). -- `scripts/push_to_gitea.sh` — push a folder's contents to a Gitea repo (flat). +- `scripts/fill_templates.py` — build per-type Excel outputs (+ Meta sheet); also exposes + the row/style helpers (`part_to_row`, `read_type_rows`, `write_type_workbook`) that the + reconcile step reuses so merged sheets look identical to freshly generated ones. +- `scripts/gitea_reconcile.py` — add new MPNs and push automatically; only stops on an MPN + that already exists (discard/replace), or run unattended with `--on-conflict replace`. + Merge-safe: other rows/folders untouched. +- `scripts/push_to_gitea.sh` — push a folder's contents to a Gitea repo (flat). Used for + Skill_Assets (not MPN-indexed). - `scripts/append_parameter.py` — append a parameter to a template + bump version. - `config/gitea.env` — host, user, token, and the DFS / Parameters / Skill_Assets repos (**secret**). diff --git a/scripts/__pycache__/fill_templates.cpython-312.pyc b/scripts/__pycache__/fill_templates.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b8ce821bbb78dbe3bb85968be6a49db7c313dc4e GIT binary patch literal 11177 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.xlsx containing the filled type sheet -plus a Meta sheet (template version, date). Column A ("MPN_make_type") is set to -__, where make = first word of the manufacturer and typeid comes -from the taxonomy (Type_ID.xlsx), supplied per part as "typeid". +Per component type present, writes one .xlsx containing the filled type sheet plus +a Meta sheet (template version, date). Column A ("MPN_make_type") = __, +where make = first word of the manufacturer and typeid comes from the taxonomy. -parts.json: -{ - "parts": [ - {"type": "Diode", "mpn": "BAT46WJ", "manufacturer": "Nexperia", - "typeid": "SCH", "subclass": "Schottky", - "values": {"Description": "...", "Forward Voltage(V)": "0.71", ...}} - ] -} -Usage: python fill_templates.py parts.json --template --dest [--version v1] +The row/style helpers (part_tag, part_to_row, read_type_rows, write_type_workbook, +template_headers, resolve_version) are imported by gitea_reconcile.py so the merge path +produces byte-for-byte the same styling as a fresh generate - no duplicated logic. + +Fresh generate: + python fill_templates.py parts.json --template --dest [--version v1] """ import argparse, json, os, re, datetime import openpyxl from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter GREEN="B6D7A8"; GRAY="BFBFBF" def norm(s): return re.sub(r'[^a-z0-9]', '', str(s).lower()) @@ -29,72 +26,106 @@ def make_tag(manufacturer): 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): + """The identity of a part: __. Same MPN tag = same part.""" + make = part.get("make") or make_tag(part.get("manufacturer","")) + return f'{part.get("mpn","")}_{make}_{part.get("typeid","NA")}' + def style_header(cell): cell.font=Font(name="Calibri", bold=True); cell.fill=PatternFill("solid", fgColor=GREEN) s=Side(style="thin", color=GRAY); cell.border=Border(left=s,right=s,top=s,bottom=s) cell.alignment=Alignment(horizontal="center", vertical="center", wrap_text=True) +def template_headers(template_path, ctype): + """Canonical header list for a type sheet, or None if the template has no such sheet.""" + master=openpyxl.load_workbook(template_path) + if ctype not in master.sheetnames: return None + ms=master[ctype] + return [ms.cell(1,c).value for c in range(1, ms.max_column+1)] + +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=norm(h); key=ALIAS.get(key,key) + 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 read_type_rows(path): + """Read an existing .xlsx -> (headers, {tag: {header: value}}); Meta sheet ignored.""" + wb=openpyxl.load_workbook(path) + ws=next((wb[n] for n in wb.sheetnames if n!="Meta"), None) + if ws is None: return [], {} + headers=[ws.cell(1,c).value for c in range(1, ws.max_column+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, len(headers)+1)} + return headers, rows + +def write_type_workbook(ctype, headers, rows_by_tag, version, dest_dir): + """Write one styled .xlsx (type sheet + Meta) from an ordered {tag: rowdict}. + Rows are pulled by header name, so callers can pass the canonical template headers + and any missing field simply comes out blank. Returns the filename written.""" + os.makedirs(dest_dir, exist_ok=True) + out=openpyxl.Workbook(); ws=out.active; ws.title=ctype[:31] + for c,h in enumerate(headers, start=1): + ws.cell(1,c,h); style_header(ws.cell(1,c)) + ws.row_dimensions[1].height=30 + 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 + for c,h in enumerate(headers, start=1): + w=32 if norm(h)=="description" else (24 if c==1 else max(11,min(18,len(str(h))+2))) + ws.column_dimensions[get_column_letter(c)].width=w + ws.freeze_panes="A2" + meta=out.create_sheet("Meta") + mrows=[("Template Version", version), + ("Generated", datetime.date.today().isoformat()), + ("Component Type", ctype), + ("Parts in file", len(rows_by_tag)), + ("Source", "datasheet-extractor skill")] + for i,(k,v) in enumerate(mrows, start=1): + meta.cell(i,1,k).font=Font(bold=True); meta.cell(i,2,v) + meta.column_dimensions["A"].width=20; meta.column_dimensions["B"].width=40 + fn=re.sub(r'\s+','_',ctype)+".xlsx" + out.save(os.path.join(dest_dir, fn)) + return fn + +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 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=a.version - if not version: - vf=os.path.join(os.path.dirname(a.template), "VERSION") - version="v"+open(vf).read().strip() if os.path.exists(vf) else "v1" + version=resolve_version(a.template, a.version) os.makedirs(a.dest, exist_ok=True) - master=openpyxl.load_workbook(a.template) by={} for p in data.get("parts", []): by.setdefault(p["type"], []).append(p) - for ctype, parts in by.items(): - if ctype not in master.sheetnames: + headers=template_headers(a.template, ctype) + if headers is None: print(f"! no template sheet for type '{ctype}' - skipping"); continue - msheet=master[ctype] - headers=[msheet.cell(1,c).value for c in range(1, msheet.max_column+1)] - out=openpyxl.Workbook(); ws=out.active; ws.title=ctype[:31] - for c,h in enumerate(headers, start=1): - ws.cell(1,c,h); style_header(ws.cell(1,c)) - ws.row_dimensions[1].height=30 - r=2 + rows_by_tag={} for p in parts: - vals={norm(k):v for k,v in p.get("values", {}).items()} - make=p.get("make") or make_tag(p.get("manufacturer","")) - tid=p.get("typeid","NA") - colA=f'{p.get("mpn","")}_{make}_{tid}' - for c,h in enumerate(headers, start=1): - key=norm(h); key=ALIAS.get(key,key) - if c==1: - ws.cell(r,c,colA) - elif key=="class": - ws.cell(r,c,p.get("subclass","")) - elif key=="manufacturer": - ws.cell(r,c,p.get("manufacturer", vals.get("manufacturer",""))) - else: - ws.cell(r,c,vals.get(key,"")) - r+=1 - # column widths - from openpyxl.utils import get_column_letter - for c,h in enumerate(headers, start=1): - w=32 if norm(h)=="description" else (24 if c==1 else max(11,min(18,len(str(h))+2))) - ws.column_dimensions[get_column_letter(c)].width=w - ws.freeze_panes="A2" - # Meta sheet - meta=out.create_sheet("Meta") - rows=[("Template Version", version), - ("Generated", datetime.date.today().isoformat()), - ("Component Type", ctype), - ("Parts in file", len(parts)), - ("Source", "datasheet-extractor skill")] - for i,(k,v) in enumerate(rows, start=1): - meta.cell(i,1,k).font=Font(bold=True); meta.cell(i,2,v) - meta.column_dimensions["A"].width=20; meta.column_dimensions["B"].width=40 - fn=re.sub(r'\s+','_',ctype)+".xlsx" - out.save(os.path.join(a.dest, fn)) - print(f"{ctype}: {len(parts)} row(s) -> {fn} (template {version})") + tag,row=part_to_row(p, headers) + rows_by_tag[tag]=row + fn=write_type_workbook(ctype, headers, rows_by_tag, version, a.dest) + print(f"{ctype}: {len(rows_by_tag)} row(s) -> {fn} (template {version})") if __name__=="__main__": main() diff --git a/scripts/gitea_reconcile.py b/scripts/gitea_reconcile.py new file mode 100644 index 0000000..2881c63 --- /dev/null +++ b/scripts/gitea_reconcile.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Reconcile a datasheet-extractor run against the DFS + Parameters Gitea repos. + +Only DFS and Parameters are MPN-indexed (per-part folders / per-part rows). Skill_Assets +holds the skill + templates, not per-part data, so it is never reconciled here - push it +with push_to_gitea.sh as before. + +Identity = the part tag __ (see fill_templates.part_tag). Same tag = +same part, so re-processing an MPN that already lives in Gitea is a conflict. + +Two passes: + 1) CHECK - clone both repos, report which run MPNs already exist. No writes, no push. + python scripts/gitea_reconcile.py --parts parts.json --dfs-src \ + --template assets/template/template.xlsx --report conflicts.json + 2) APPLY - with --decisions (a JSON map tag -> "replace"|"discard") + --push: + python scripts/gitea_reconcile.py --parts parts.json --dfs-src \ + --template assets/template/template.xlsx --decisions decisions.json --push + + replace -> overwrite that MPN's DFS folder AND its Parameters row (a fresh datasheet / + footprint / symbol / values fully supersede the old ones). + discard -> leave the copy already in Gitea untouched; drop the newly-extracted one. + New (non-conflicting) MPNs are always added. Every OTHER row/folder is preserved - + the Parameters sheet is merged, never wholesale-overwritten. + +Refuses to push if any conflict has no decision, so nothing is ever resolved silently. +""" +import argparse, json, os, re, shutil, subprocess, sys, tempfile +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import fill_templates as ft + +def load_env(cfg): + env={} + if os.path.exists(cfg): + for line in open(cfg): + line=line.strip() + if not line or line.startswith("#") or "=" not in line: continue + k,v=line.split("=",1); env[k.strip()]=v.strip() + for k in ("GIT_HOST","GIT_USER","GIT_TOKEN","DFS_REPO","PARAMS_REPO"): + if os.environ.get(k): env[k]=os.environ[k] + return env + +def repo_url(env, repo): + host=re.sub(r'^https?://','',env["GIT_HOST"]).rstrip('/') + cred=(env.get("GIT_USER","")+":" if env.get("GIT_USER") else "")+env["GIT_TOKEN"] + return f"https://{cred}@{host}/{repo}.git" + +def clone(url, dest, token): + r=subprocess.run(["git","clone","--depth","1",url,dest], capture_output=True, text=True) + return r.returncode==0, (r.stderr or "").replace(token,"***") + +def type_filename(ctype): return re.sub(r'\s+','_',ctype)+".xlsx" + +def run_rows(parts): + return [(ft.part_tag(p), p["type"], p.get("mpn",""), p) for p in parts] + +def scan_conflicts(parts, dfs_clone, params_clone): + conflicts=[]; ptags={} + for tag,ctype,mpn,_ in run_rows(parts): + in_dfs=os.path.isdir(os.path.join(dfs_clone, tag)) + if ctype not in ptags: + f=os.path.join(params_clone, type_filename(ctype)) + ptags[ctype]=set(ft.read_type_rows(f)[1].keys()) if os.path.exists(f) else set() + in_params=tag in ptags[ctype] + if in_dfs or in_params: + conflicts.append({"tag":tag,"type":ctype,"mpn":mpn, + "in_dfs":in_dfs,"in_params":in_params}) + return conflicts + +def apply_dfs(parts, dfs_src, dfs_clone, decisions): + for tag,_,_,_ in run_rows(parts): + src=os.path.join(dfs_src, tag) + if not os.path.isdir(src): continue + dst=os.path.join(dfs_clone, tag); dec=decisions.get(tag) + if os.path.isdir(dst): + if dec=="discard": print(f" DFS keep existing : {tag}") + elif dec=="replace": + shutil.rmtree(dst); shutil.copytree(src,dst); print(f" DFS replaced : {tag}") + else: print(f" DFS SKIP (undecided): {tag}") + else: + shutil.copytree(src,dst); print(f" DFS added : {tag}") + +def apply_params(parts, params_clone, template, decisions, version): + by={} + for p in parts: by.setdefault(p["type"], []).append(p) + for ctype, plist in by.items(): + headers=ft.template_headers(template, ctype) + if headers is None: + print(f" ! no template sheet for '{ctype}' - skipping Parameters"); continue + f=os.path.join(params_clone, type_filename(ctype)) + rows=dict(ft.read_type_rows(f)[1]) if os.path.exists(f) else {} + for p in plist: + tag,row=ft.part_to_row(p, headers); dec=decisions.get(tag) + if tag in rows: + if dec=="discard": print(f" PARAMS keep row : {tag}") + elif dec=="replace": rows[tag]=row; print(f" PARAMS replaced : {tag}") + else: print(f" PARAMS SKIP (undecided): {tag}") + else: + rows[tag]=row; print(f" PARAMS added : {tag}") + ft.write_type_workbook(ctype, headers, rows, version, params_clone) + +def commit_push(clone_dir, msg, token): + for args in (["config","user.email","datasheet-bot@local"], + ["config","user.name","datasheet-extractor"], + ["checkout","-B","main"], ["add","-A"]): + subprocess.run(["git","-C",clone_dir]+args, capture_output=True) + if subprocess.run(["git","-C",clone_dir,"diff","--cached","--quiet"]).returncode==0: + print(f" nothing new for {os.path.basename(clone_dir)}"); return + subprocess.run(["git","-C",clone_dir,"commit","-m",msg], capture_output=True) + r=subprocess.run(["git","-C",clone_dir,"push","-u","origin","main"], + capture_output=True, text=True) + print(" "+((r.stdout+r.stderr).replace(token,"***").strip() or "pushed.")) + +def undecided(conflicts, decisions): + return [c["tag"] for c in conflicts if decisions.get(c["tag"]) not in ("replace","discard")] + +def main(): + ap=argparse.ArgumentParser() + ap.add_argument("--parts", required=True) + ap.add_argument("--dfs-src", required=True, help="staging dir of MPN_make_typeid/ folders") + ap.add_argument("--template", required=True) + ap.add_argument("--decisions", default=None, help="JSON map: tag -> replace|discard") + ap.add_argument("--report", default=None, help="write the conflict report here") + ap.add_argument("--push", action="store_true", help="apply decisions and push") + ap.add_argument("--on-conflict", choices=["ask","replace","discard"], default="ask", + help="what to do when an MPN already exists: ask the user (default), or " + "apply one policy to every conflict for an unattended run") + ap.add_argument("--config", + default=os.path.join(os.path.dirname(__file__),"..","config","gitea.env")) + a=ap.parse_args() + + parts=json.load(open(a.parts))["parts"] + env=load_env(a.config) + for k in ("GIT_HOST","GIT_TOKEN","DFS_REPO","PARAMS_REPO"): + if not env.get(k): sys.exit(f"missing {k} in env/config") + token=env["GIT_TOKEN"] + version=ft.resolve_version(a.template, None) + decisions=json.load(open(a.decisions)) if a.decisions else {} + + tmp=tempfile.mkdtemp(prefix="reconcile_") + dfs_clone=os.path.join(tmp,"dfs"); params_clone=os.path.join(tmp,"params") + ok,err=clone(repo_url(env,env["DFS_REPO"]), dfs_clone, token) + if not ok: sys.exit(f"clone DFS failed (host reachable / token scope?):\n{err[:400]}") + ok,err=clone(repo_url(env,env["PARAMS_REPO"]), params_clone, token) + if not ok: sys.exit(f"clone Parameters failed:\n{err[:400]}") + + conflicts=scan_conflicts(parts, dfs_clone, params_clone) + report={"has_conflicts":bool(conflicts),"conflicts":conflicts} + if a.report: json.dump(report, open(a.report,"w"), indent=2) + print(json.dumps(report, indent=2)) + + if not a.push: + if conflicts: + print(f"\n{len(conflicts)} existing MPN(s) found — ask the user discard/replace, " + "then re-run with --decisions/--push (or set --on-conflict for unattended).") + else: + print("\nNo conflicts — re-run with --push to add everything (no prompt needed).") + return + + # A standing policy resolves every conflict without a prompt; otherwise conflicts must + # each carry an explicit decision so nothing gets overwritten behind the user's back. + if a.on_conflict in ("replace","discard"): + for c in conflicts: + decisions.setdefault(c["tag"], a.on_conflict) + miss=undecided(conflicts, decisions) + if miss: + sys.exit("Refusing to push — need a discard/replace decision (or --on-conflict) for: " + +", ".join(miss)) + print("Applying to DFS:") + apply_dfs(parts, a.dfs_src, dfs_clone, decisions) + print("Applying to Parameters:") + apply_params(parts, params_clone, a.template, decisions, version) + print("Pushing:") + commit_push(dfs_clone, "datasheet-extractor: reconcile DFS", token) + commit_push(params_clone, "datasheet-extractor: reconcile Parameters", token) + print("\nDone — DFS + Parameters reconciled and pushed.") + +if __name__=="__main__": + main()