#!/usr/bin/env python3 """Talk to the two Gitea repos of the new library-manager layout. Repos (from config/gitea.env): SKILL_REPO - this skill's own files (pushed with push_to_gitea.sh, not here). LIBRARY_REPO - one folder per Class (Diode, IC, Transistor, ...); inside each Class, one MPN_make_typeid/ folder per part holding { xlsx, datasheet, symbol, footprint }. Subcommands (all accept --local to run against a local folder instead of cloning, for testing or offline work): check-mpn --mpn BAT46WJ --make Nexperia Is any part with this MPN+make already in the library repo? (typeid-agnostic - the early duplicate gate, run BEFORE classifying.) Prints EXISTS/ or ABSENT; exit 0 if absent, 3 if it already exists (so a shell can hard-stop). checkout --dest work/ Clone the library repo to a working dir you can browse and edit in place. list-type --typeid SCH [--root work/] [--json] List every existing part of a typeid and the files in its folder (for backfill: the datasheet to re-read is in there). Needs a checked-out --root (or --local). place-part --folder staging/BAT46WJ_Nexperia_SCH --typeid SCH --root work/ Copy an assembled part folder into work/// (creates the Class folder if it is missing). Then commit-push. commit-push --root work/ [--message "..."] Commit everything in the checked-out clone and push. push-part --folder staging/BAT46WJ_Nexperia_SCH --typeid SCH [--message "..."] Convenience: clone -> place-part -> commit-push in one go (the common single-part path). push-skill [--src ] [--message "..."] Push this skill's files to the SKILL repo (token blanked out) and MERGE the changelog: the new rows in the local CHANGELOG.xlsx are appended onto the repo's existing one, so previous entries in Gitea are preserved, not overwritten. The merged changelog is written back locally so local and Gitea stay in sync. Host unreachable (e.g. sandbox without the domain allowlisted) -> clones fail clearly and nothing is written. """ import argparse, os, re, shutil, subprocess, sys, tempfile sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from common import (class_folder, load_taxonomy, mpn_make_prefix, SKILL_ROOT, TEMPLATE_XLSX, VERSIONS_JSON, CHANGELOG_XLSX, CHANGELOG_HEADERS, resolve_operator) CFG_DEFAULT = os.path.join(os.path.dirname(__file__), "..", "config", "gitea.env") # The skill's stateful files (accumulate over time) — kept in the SKILL repo and pulled at the # start of a run so template/version/changelog changes always build on Gitea's latest state. # Paths are derived from common so the writer (append_parameter) and the sync can never drift. STATE_FILES = [os.path.relpath(p, SKILL_ROOT) for p in (TEMPLATE_XLSX, VERSIONS_JSON, CHANGELOG_XLSX)] CHANGELOG_REL = os.path.relpath(CHANGELOG_XLSX, SKILL_ROOT) # ------------------------------------------------------------------ gitea plumbing 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", "SKILL_REPO", "LIBRARY_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(env, dest, repo=None): repo = repo or env["LIBRARY_REPO"] url = repo_url(env, repo) r = subprocess.run(["git", "clone", url, dest], capture_output=True, text=True) if r.returncode != 0: err = (r.stderr or "").replace(env["GIT_TOKEN"], "***") sys.exit(f"clone {repo} failed (host reachable / token scope / repo exists?):\n{err[:400]}") return dest def commit_push(root, token, msg, author=None): """Commit + push. `author` = (name, email) of the operator; when given, the commit is authored by that real person (visible in git log / Gitea commit view / blame). Without it, a generic bot identity is used.""" name, email = author or (None, None) name = name or "library-manager" email = email or "datasheet-bot@local" for args in (["config", "user.email", email], ["config", "user.name", name], ["checkout", "-B", "main"], ["add", "-A"]): subprocess.run(["git", "-C", root] + args, capture_output=True) if subprocess.run(["git", "-C", root, "diff", "--cached", "--quiet"]).returncode == 0: print("nothing new to push.") return subprocess.run(["git", "-C", root, "commit", "-m", msg], capture_output=True) r = subprocess.run(["git", "-C", root, "push", "-u", "origin", "main"], capture_output=True, text=True) print((r.stdout + r.stderr).replace(token or "", "***").strip() or "pushed.") def _root(env, args): """Return (root_dir, is_temp_clone). Honour --local / --root, else clone.""" if getattr(args, "local", None): return args.local, False if getattr(args, "root", None): return args.root, False tmp = tempfile.mkdtemp(prefix="components_") return clone(env, tmp), True # ------------------------------------------------------------------ folder logic def find_part_dirs(root, prefix="", typeid=None): """Yield (class_folder, part_folder_name, abspath) for part folders under any Class dir that match a name prefix and/or a typeid suffix.""" if not os.path.isdir(root): return for cls in sorted(os.listdir(root)): cdir = os.path.join(root, cls) if not os.path.isdir(cdir) or cls.startswith("."): continue for name in sorted(os.listdir(cdir)): pdir = os.path.join(cdir, name) if not os.path.isdir(pdir): continue if prefix and not name.startswith(prefix): continue if typeid and not name.endswith(f"_{typeid}"): continue yield cls, name, pdir def cmd_check_mpn(env, args): root, _ = _root(env, args) prefix = mpn_make_prefix(args.mpn, args.make) matches = [(cls, name) for cls, name, _ in find_part_dirs(root, prefix=prefix)] if matches: for cls, name in matches: print(f"EXISTS\t{cls}/{name}") sys.exit(3) print("ABSENT") def cmd_checkout(env, args): dest = clone(env, args.dest) print(f"library repo checked out at {dest}") def cmd_list_type(env, args): root, _ = _root(env, args) parts = list(find_part_dirs(root, typeid=args.typeid)) if args.json: import json out = [{"class": cls, "tag": name, "folder": pdir, "files": sorted(os.listdir(pdir))} for cls, name, pdir in parts] print(json.dumps(out, indent=2)) return if not parts: print(f"no existing parts of typeid {args.typeid}") return for cls, name, pdir in parts: files = ", ".join(sorted(os.listdir(pdir))) print(f"{cls}/{name} -> {files}") def place_part(root, folder, typeid): cls = class_folder(typeid) if not cls: sys.exit(f"unknown typeid '{typeid}' (not in taxonomy)") tag = os.path.basename(os.path.normpath(folder)) cls_dir = os.path.join(root, cls) # A repo may seed classes as 0-byte placeholder FILES (git can't store empty dirs). # Replace such a placeholder with a real class directory before adding the part. if os.path.exists(cls_dir) and not os.path.isdir(cls_dir): os.remove(cls_dir) os.makedirs(cls_dir, exist_ok=True) dest = os.path.join(cls_dir, tag) if os.path.isdir(dest): shutil.rmtree(dest) shutil.copytree(folder, dest) return cls, tag, dest def cmd_place_part(env, args): root, _ = _root(env, args) cls, tag, dest = place_part(root, args.folder, args.typeid) print(f"placed {tag} -> {cls}/{tag}") def _operator(args): """(name, email) of who's running this — from --author, else env, else identity file.""" return resolve_operator(getattr(args, "author", None)) def _by(name): return f" (by {name})" if name else "" def cmd_commit_push(env, args): name, email = _operator(args) commit_push(args.root, env.get("GIT_TOKEN", ""), args.message or f"library-manager: update components{_by(name)}", author=(name, email)) def cmd_push_part(env, args): name, email = _operator(args) tmp = tempfile.mkdtemp(prefix="components_") clone(env, tmp) cls, tag, _ = place_part(tmp, args.folder, args.typeid) print(f"placed {tag} -> {cls}/{tag}" + (f" (by {name})" if name else "")) commit_push(tmp, env.get("GIT_TOKEN", ""), args.message or f"library-manager: add {tag}{_by(name)}", author=(name, email)) # ------------------------------------------------------------------ skill repo + changelog def _changelog_rows(path): """(workbook, sheet, [row-tuples]) for a changelog xlsx; ([]) if the file is absent. Rows are padded/truncated to the canonical column count so old 5-column changelogs (no 'By') merge cleanly with new 6-column ones.""" import openpyxl if not os.path.exists(path): return None, None, [] n = len(CHANGELOG_HEADERS) wb = openpyxl.load_workbook(path) ws = wb["Changelog"] if "Changelog" in wb.sheetnames else wb.active rows = [] for r in ws.iter_rows(min_row=2, values_only=True): vals = ["" if c is None else str(c) for c in r] vals = (vals + [""] * n)[:n] if any(v for v in vals): rows.append(tuple(vals)) return wb, ws, rows def merge_changelog(local_path, repo_path): """Union the local changelog's rows into the repo's changelog and keep the result sorted by date (chronological, oldest first). This keeps Gitea's changelog as the growing source of truth — appended, never overwritten — and always in order regardless of which machine pushed when. Writes the merged file to repo_path and returns how many new rows were added.""" import shutil from openpyxl.styles import Border, Side, Alignment if not os.path.exists(local_path): return 0 if not os.path.exists(repo_path): # repo has none yet -> seed from local os.makedirs(os.path.dirname(repo_path), exist_ok=True) shutil.copy(local_path, repo_path) _, _, rows = _changelog_rows(repo_path) return len(rows) from openpyxl.styles import Font, PatternFill repo_wb, repo_ws, repo_rows = _changelog_rows(repo_path) _, _, local_rows = _changelog_rows(local_path) seen = set(repo_rows) merged = list(repo_rows) added = 0 for row in local_rows: if row not in seen: merged.append(row) seen.add(row) added += 1 merged.sort(key=lambda r: r[0]) # chronological by Date (column A) n = len(CHANGELOG_HEADERS) desc_col = CHANGELOG_HEADERS.index("Description") + 1 s = Side(style="thin", color="BFBFBF") border = Border(left=s, right=s, top=s, bottom=s) if repo_ws.max_row > 1: # clear everything, rewrite header + rows repo_ws.delete_rows(1, repo_ws.max_row) for c, h in enumerate(CHANGELOG_HEADERS, start=1): # canonical header (migrates 5->6 cols) cell = repo_ws.cell(1, c, h) cell.font = Font(name="Calibri", bold=True) cell.fill = PatternFill("solid", fgColor="B6D7A8") cell.border = border cell.alignment = Alignment(horizontal="center", vertical="center") for row in merged: rr = repo_ws.max_row + 1 for c in range(1, n + 1): cell = repo_ws.cell(rr, c, row[c - 1] if c - 1 < len(row) else "") cell.border = border cell.alignment = Alignment(vertical="center", wrap_text=(c == desc_col), horizontal="left") repo_wb.freeze_panes = None repo_ws.freeze_panes = "A2" repo_wb.save(repo_path) return added def _copy_skill_files(src, dst): """Copy the skill's own files into a repo clone, minus junk and the changelog (merged separately) and WITHOUT the real token (config/gitea.env is copied with GIT_TOKEN blanked).""" skip = {".git", "__pycache__"} for root, dirs, files in os.walk(src): dirs[:] = [d for d in dirs if d not in skip] rel = os.path.relpath(root, src) for f in files: if f.endswith((".bak.xlsx",)) or "OLD-18class" in f: continue relpath = os.path.normpath(os.path.join(rel, f)) if relpath == CHANGELOG_REL: # changelog handled by merge, not copy continue s = os.path.join(root, f) d = os.path.join(dst, relpath) os.makedirs(os.path.dirname(d), exist_ok=True) if relpath == os.path.join("config", "gitea.env"): text = open(s, encoding="utf-8").read() text = re.sub(r"(?m)^(GIT_TOKEN=).*$", r"\1", text) # blank the secret open(d, "w", encoding="utf-8").write(text) else: shutil.copy2(s, d) def cmd_push_skill(env, args): """Push this skill's files to the SKILL repo and MERGE the changelog into the repo's existing one (append-only, so previous entries are preserved). The merged changelog is also written back to the local skill assets so local and Gitea stay in sync.""" src = os.path.abspath(args.src) tmp = tempfile.mkdtemp(prefix="skill_") clone(env, tmp, env["SKILL_REPO"]) _copy_skill_files(src, tmp) local_cl = os.path.join(src, CHANGELOG_REL) repo_cl = os.path.join(tmp, CHANGELOG_REL) added = merge_changelog(local_cl, repo_cl) if os.path.exists(repo_cl): # sync merged history back to local shutil.copy(repo_cl, local_cl) print(f"changelog: merged (+{added} new row(s)) into the skill repo's CHANGELOG.xlsx") name, email = _operator(args) commit_push(tmp, env.get("GIT_TOKEN", ""), args.message or f"library-manager: sync skill files + changelog{_by(name)}", author=(name, email)) def cmd_pull_skill(env, args): """Pull the skill's stateful files (template.xlsx, versions.json, CHANGELOG.xlsx) from the SKILL repo into the local skill BEFORE making changes. This is what makes template/version changes cumulative: without it, a fresh run starts from the packaged v1 state, so a second change wouldn't build on the first (versions would restart at v1 and the changelog would look like it only has the latest change). Run this at the start of every run.""" src = os.path.abspath(args.src) tmp = tempfile.mkdtemp(prefix="skillpull_") clone(env, tmp, env["SKILL_REPO"]) pulled = [] for rel in STATE_FILES: s = os.path.join(tmp, rel) d = os.path.join(src, rel) if os.path.exists(s): os.makedirs(os.path.dirname(d), exist_ok=True) shutil.copy2(s, d) pulled.append(rel) print("pulled from skill repo: " + (", ".join(pulled) if pulled else "nothing yet (skill repo has no template/version/changelog state)")) def main(): ap = argparse.ArgumentParser() ap.add_argument("--config", default=CFG_DEFAULT) sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("check-mpn"); p.add_argument("--mpn", required=True) p.add_argument("--make", required=True); p.add_argument("--local"); p.add_argument("--root") p = sub.add_parser("checkout"); p.add_argument("--dest", required=True) p = sub.add_parser("list-type"); p.add_argument("--typeid", required=True) p.add_argument("--root"); p.add_argument("--local"); p.add_argument("--json", action="store_true") p = sub.add_parser("place-part"); p.add_argument("--folder", required=True) p.add_argument("--typeid", required=True); p.add_argument("--root"); p.add_argument("--local") p = sub.add_parser("commit-push"); p.add_argument("--root", required=True) p.add_argument("--message"); p.add_argument("--author", help="'Name ' of the operator") p = sub.add_parser("push-part"); p.add_argument("--folder", required=True) p.add_argument("--typeid", required=True); p.add_argument("--message") p.add_argument("--author", help="'Name ' of the operator (who added the part)") p = sub.add_parser("push-skill") p.add_argument("--author", help="'Name ' of the operator (who made the change)") p.add_argument("--src", default=os.path.join(os.path.dirname(__file__), ".."), help="skill directory to push (defaults to this skill)") p.add_argument("--message") p = sub.add_parser("pull-skill") p.add_argument("--src", default=os.path.join(os.path.dirname(__file__), ".."), help="skill directory to sync into (defaults to this skill)") args = ap.parse_args() env = load_env(args.config) needs = {"GIT_HOST", "GIT_TOKEN"} if args.cmd in ("push-skill", "pull-skill"): needs.add("SKILL_REPO") elif args.cmd in ("check-mpn", "checkout", "commit-push", "push-part") or \ (args.cmd in ("list-type", "place-part") and not getattr(args, "local", None) and not getattr(args, "root", None)): needs.add("LIBRARY_REPO") for k in sorted(needs): if not env.get(k): sys.exit(f"missing {k} in env/config") {"check-mpn": cmd_check_mpn, "checkout": cmd_checkout, "list-type": cmd_list_type, "place-part": cmd_place_part, "commit-push": cmd_commit_push, "push-part": cmd_push_part, "push-skill": cmd_push_skill, "pull-skill": cmd_pull_skill}[args.cmd](env, args) if __name__ == "__main__": main()