458 lines
20 KiB
Python
458 lines
20 KiB
Python
#!/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 <dir> 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/<tag> or ABSENT; exit 0
|
|
if absent, 3 if it already exists (so a shell can hard-stop).
|
|
|
|
find-part --mpn BAT46WJ --make Nexperia [--root work/] [--json]
|
|
Locate an existing part so it can be REVISED (the update path — the mirror of
|
|
check-mpn, which gates *new* parts). Reports the part's Class, tag, typeid and the
|
|
files in its folder. Point --root at a `checkout` clone and the folder it returns is
|
|
the real, editable one to change in place before `commit-push`. Exit 0 if found, 4 if
|
|
not (so a shell can branch: found -> update, not found -> fall through to add).
|
|
|
|
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/<Class>/<tag>/ (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 <skill-dir>] [--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 _typeid_of(tag):
|
|
"""Recover the typeid from a part tag <MPN>_<make>_<typeid>. The typeid is the last
|
|
underscore-delimited token, so this is robust even if the MPN itself contains underscores."""
|
|
return tag.rsplit("_", 1)[1] if "_" in tag else ""
|
|
|
|
|
|
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_find_part(env, args):
|
|
"""Locate an existing part by MPN+make so it can be REVISED — the mirror image of
|
|
check-mpn (which gates *new* parts). Reports the folder, its typeid and its files.
|
|
|
|
Point --root at a checkout (from `checkout`) and the folder it returns is the real,
|
|
editable, committable one — that's the update path: checkout -> find-part -> edit in
|
|
place -> commit-push. Without --root/--local it clones a throwaway copy for a read-only
|
|
look. Exit 0 if found, 4 if not (so a shell can branch)."""
|
|
root, _ = _root(env, args)
|
|
prefix = mpn_make_prefix(args.mpn, args.make)
|
|
matches = list(find_part_dirs(root, prefix=prefix))
|
|
if not matches:
|
|
print("[]" if args.json else "NOT FOUND")
|
|
sys.exit(4)
|
|
out = [{"class": cls, "tag": name, "typeid": _typeid_of(name),
|
|
"folder": pdir, "files": sorted(os.listdir(pdir))}
|
|
for cls, name, pdir in matches]
|
|
if args.json:
|
|
import json
|
|
print(json.dumps(out, indent=2))
|
|
else:
|
|
for o in out:
|
|
print(f'{o["class"]}/{o["tag"]} (typeid {o["typeid"]}) -> {", ".join(o["files"])}')
|
|
|
|
|
|
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("find-part"); p.add_argument("--mpn", required=True)
|
|
p.add_argument("--make", required=True); p.add_argument("--local"); p.add_argument("--root")
|
|
p.add_argument("--json", action="store_true")
|
|
|
|
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 <email>' 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 <email>' of the operator (who added the part)")
|
|
|
|
p = sub.add_parser("push-skill")
|
|
p.add_argument("--author", help="'Name <email>' 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", "find-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, "find-part": cmd_find_part, "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()
|