#!/usr/bin/env python3 """学習の最後のチェックポイントを Hugging Face の非公開リポジトリへ上げる (Pod で動かす. 2026-10-31 依頼 49). lab_train.sh が学習のあとに Pod で流す: python3 hf_upload_ckpt.py --dir /workspace/openpi/checkpoints/<設定>//<ステップ> --repo PixelstoMotors/<名前> 上げるもの: train_state/ 以外の全部 (params/・assets/・_CHECKPOINT_METADATA). リポジトリの一番上に置く (lab_start.sh の 3c が hf download で /workspace/ckpt/3999 に取る 3999 と同じ形). 鍵は hf が読む ~/.cache/huggingface/token (lab_start.sh / lab_train.sh の 3b が置く). 鍵は出さない. 1. リポジトリを非公開で作る (あればそのまま). 非公開でなければ上げずに止まる 2. リポジトリにもうファイルがあれば, 上げるものと名前・バイト数が全部同じなら上げない (same). 違えば止まる 3. upload_folder で 1 回のコミットで上げる 4. リポジトリの一覧 (名前・バイト数) が手元と全部合うかを確かめる 最後の行: HFUP <結果> <説明>. 終了コード 0 = ok / same, 3 = not_private, 4 = other_content, 5 = mismatch, 6 = error """ import argparse import os import sys SKIP_TOP = ("train_state",) SKIP_REMOTE = (".gitattributes",) def local_files(root): """train_state/ を除くファイルの (パス, バイト数) を名前順に""" out = [] def err(e): raise e # 読めないフォルダを黙って飛ばさない for d, dirs, files in os.walk(root, onerror=err): rel = os.path.relpath(d, root) if rel == ".": dirs[:] = [x for x in dirs if x not in SKIP_TOP] for f in files: p = os.path.normpath(os.path.join(rel, f)) out.append((p, os.path.getsize(os.path.join(d, f)))) return sorted(out, key=lambda x: x[0].encode()) def remote_files(api, repo): """リポジトリのファイルの (パス, バイト数) を名前順に (.gitattributes は数えない)""" out = [] for x in api.list_repo_tree(repo, recursive=True, repo_type="model"): size = getattr(x, "size", None) if size is None or x.path in SKIP_REMOTE: # フォルダには size がない continue out.append((x.path, int(size))) return sorted(out, key=lambda x: x[0].encode()) def diff_lines(a, b, n=10): da, db = dict(a), dict(b) lines = [] for p in sorted(set(da) | set(db), key=str.encode): if da.get(p) != db.get(p): lines.append(f" {p}: 手元 {da.get(p, 'なし')} / リポジトリ {db.get(p, 'なし')}") return lines[:n] + ([f" ほか {len(lines) - n} 件"] if len(lines) > n else []) def done(kind, msg, code): print(f"HFUP {kind} {msg}", flush=True) return code def main(argv=None, api=None): ap = argparse.ArgumentParser() ap.add_argument("--dir", required=True, help="上げるチェックポイントのフォルダ (<ステップ>/)") ap.add_argument("--repo", required=True, help="Hugging Face のリポジトリ (例 PixelstoMotors/pi05-mecharm-trim-corr-lora-v1)") ap.add_argument("--message", default="", help="コミットの文") args = ap.parse_args(argv) if not os.path.isfile(os.path.join(args.dir, "_CHECKPOINT_METADATA")): return done("error", f"{args.dir} に _CHECKPOINT_METADATA がない (書き終えたチェックポイントではない)", 6) try: mine = local_files(args.dir) except OSError as e: return done("error", f"手元の一覧を作れない: {e}", 6) total = sum(s for _, s in mine) print(f"[HF ] 上げるもの: {len(mine)} ファイル {total:,} バイト (train_state/ は上げない)", flush=True) try: if api is None: from huggingface_hub import HfApi api = HfApi() api.create_repo(args.repo, repo_type="model", private=True, exist_ok=True) if not api.model_info(args.repo).private: return done("not_private", f"{args.repo} が非公開になっていない. 上げずに止めた", 3) have = remote_files(api, args.repo) if have: if have == mine: return done("same", f"{args.repo} にはもう同じ中身がある ({len(mine)} ファイル). 上げ直さない", 0) print("\n".join(diff_lines(mine, have)), flush=True) return done("other_content", f"{args.repo} に別の中身がある. 上書きしないで止めた", 4) api.upload_folder(folder_path=args.dir, repo_id=args.repo, repo_type="model", ignore_patterns=[f"{t}/**" for t in SKIP_TOP] + [f"{t}/*" for t in SKIP_TOP], commit_message=args.message or f"upload {os.path.basename(os.path.normpath(args.dir))}") have = remote_files(api, args.repo) except Exception as e: # 鍵は例外の文に入らない (hf はヘッダーの中身を出さない) return done("error", f"{type(e).__name__}: {str(e).splitlines()[0] if str(e) else ''}", 6) if have != mine: print("\n".join(diff_lines(mine, have)), flush=True) return done("mismatch", f"上げたあとのリポジトリの一覧が手元と合わない", 5) return done("ok", f"{args.repo} に {len(mine)} ファイル {total:,} バイトを上げ, 名前・バイト数が全部合うのを確かめた", 0) if __name__ == "__main__": sys.exit(main())