Spaces:
Runtime error
Runtime error
features: independant from docfinder, eol creds, input ts mode
Browse files- app.py +330 -44
- scripts/build_cr_index.py +169 -0
- scripts/fetch_crs.py +114 -0
- scripts/hf_cr_index.py +58 -0
- scripts/orchestrate_cr.py +378 -242
app.py
CHANGED
|
@@ -9,6 +9,7 @@ Three-step UI:
|
|
| 9 |
4. DONE/ERROR β download ZIP of results
|
| 10 |
"""
|
| 11 |
|
|
|
|
| 12 |
import io
|
| 13 |
import json
|
| 14 |
import os
|
|
@@ -18,11 +19,19 @@ import threading
|
|
| 18 |
import time
|
| 19 |
import uuid
|
| 20 |
import zipfile
|
|
|
|
| 21 |
from datetime import datetime
|
| 22 |
from pathlib import Path
|
| 23 |
|
| 24 |
import streamlit as st
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
# ββ EOL credential verification βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
|
| 28 |
def verify_eol_credentials(username: str, password: str) -> bool:
|
|
@@ -108,6 +117,14 @@ def new_state(sid: str) -> dict:
|
|
| 108 |
"started_at": None,
|
| 109 |
"completed_at": None,
|
| 110 |
"return_code": None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
}
|
| 112 |
|
| 113 |
|
|
@@ -235,6 +252,18 @@ def make_zip(output_dir: Path) -> bytes:
|
|
| 235 |
return buf.read()
|
| 236 |
|
| 237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
# ββ Page config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 239 |
st.set_page_config(
|
| 240 |
page_title="CR Application Tool",
|
|
@@ -326,31 +355,264 @@ if status == "login":
|
|
| 326 |
elif status == "upload":
|
| 327 |
st.subheader("Step 1 β Upload contribution list")
|
| 328 |
|
| 329 |
-
|
| 330 |
-
"
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
person_name = st.text_input(
|
| 334 |
-
"Contributor name (must match SubmittedBy column)",
|
| 335 |
-
value=state.get("person_name", "Ly Thanh PHAN"),
|
| 336 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
|
| 355 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½ββ
|
| 356 |
# PREVIEW
|
|
@@ -386,10 +648,15 @@ elif status == "preview":
|
|
| 386 |
with col2:
|
| 387 |
if cr_list and st.button("βΆ Start Pipeline", type="primary"):
|
| 388 |
excel_path = session_dir(sid) / state["excel_filename"]
|
| 389 |
-
|
|
|
|
|
|
|
| 390 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 391 |
-
|
|
|
|
|
|
|
| 392 |
rc_path = _rc_path(sid)
|
|
|
|
| 393 |
|
| 394 |
cmd = [
|
| 395 |
sys.executable,
|
|
@@ -397,6 +664,7 @@ elif status == "preview":
|
|
| 397 |
str(excel_path),
|
| 398 |
state["person_name"],
|
| 399 |
"--output-dir", str(output_dir),
|
|
|
|
| 400 |
]
|
| 401 |
|
| 402 |
env = os.environ.copy()
|
|
@@ -425,6 +693,7 @@ elif status == "preview":
|
|
| 425 |
state["pid"] = proc.pid
|
| 426 |
state["output_dir"] = str(output_dir)
|
| 427 |
state["log_path"] = str(log_path)
|
|
|
|
| 428 |
state["started_at"] = datetime.now().isoformat()
|
| 429 |
save_state(sid, state)
|
| 430 |
st.rerun()
|
|
@@ -478,12 +747,11 @@ elif status in ("done", "error"):
|
|
| 478 |
else:
|
| 479 |
st.error(f"β Pipeline finished with errors (return code: {rc})")
|
| 480 |
|
| 481 |
-
# Per-TS results table β merge
|
| 482 |
-
#
|
| 483 |
-
# ones (pipeline.log) for the same TS key.
|
| 484 |
_merged: dict[str, dict] = {}
|
| 485 |
-
for _lf in
|
| 486 |
-
for _r in parse_log_results(
|
| 487 |
_merged[_r["TS"]] = _r
|
| 488 |
results = list(_merged.values())
|
| 489 |
if results:
|
|
@@ -604,7 +872,7 @@ elif status in ("done", "error"):
|
|
| 604 |
st.success(f"All {len(ready_entries)} TS(s) ready.")
|
| 605 |
|
| 606 |
if st.button("βΆ Apply CRs to recovered TSs", type="primary"):
|
| 607 |
-
retry_log = str(session_dir(sid) / "
|
| 608 |
_rc_path(sid).unlink(missing_ok=True) # clear old returncode
|
| 609 |
|
| 610 |
cmd = [
|
|
@@ -630,23 +898,41 @@ elif status in ("done", "error"):
|
|
| 630 |
).start()
|
| 631 |
st.session_state.proc = proc
|
| 632 |
|
| 633 |
-
state["status"]
|
| 634 |
-
state["pid"]
|
| 635 |
-
state["log_path"]
|
| 636 |
-
state["
|
|
|
|
| 637 |
save_state(sid, state)
|
| 638 |
st.rerun()
|
| 639 |
else:
|
| 640 |
st.warning("No TSs available yet β retry download or upload DOCX files above.")
|
| 641 |
|
| 642 |
-
#
|
| 643 |
st.divider()
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
st.
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
4. DONE/ERROR β download ZIP of results
|
| 10 |
"""
|
| 11 |
|
| 12 |
+
import hashlib
|
| 13 |
import io
|
| 14 |
import json
|
| 15 |
import os
|
|
|
|
| 19 |
import time
|
| 20 |
import uuid
|
| 21 |
import zipfile
|
| 22 |
+
from collections import defaultdict
|
| 23 |
from datetime import datetime
|
| 24 |
from pathlib import Path
|
| 25 |
|
| 26 |
import streamlit as st
|
| 27 |
|
| 28 |
+
# ββ Load .env from the same directory as app.py βββββββββββββββββββββββββββββββ
|
| 29 |
+
try:
|
| 30 |
+
from dotenv import load_dotenv
|
| 31 |
+
load_dotenv(Path(__file__).parent / ".env")
|
| 32 |
+
except ImportError:
|
| 33 |
+
pass # python-dotenv not installed; rely on environment variables
|
| 34 |
+
|
| 35 |
# ββ EOL credential verification βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 36 |
|
| 37 |
def verify_eol_credentials(username: str, password: str) -> bool:
|
|
|
|
| 117 |
"started_at": None,
|
| 118 |
"completed_at": None,
|
| 119 |
"return_code": None,
|
| 120 |
+
# TS mode fields
|
| 121 |
+
"mode": "contributor", # "contributor" | "ts"
|
| 122 |
+
"excel_hash": "",
|
| 123 |
+
"hf_repo": "OrganizedProgrammers/CR_Index",
|
| 124 |
+
"index_log": "",
|
| 125 |
+
"ts_id": "",
|
| 126 |
+
# Logs for the current pipeline run (main + retry); reset on new run
|
| 127 |
+
"run_log_paths": [],
|
| 128 |
}
|
| 129 |
|
| 130 |
|
|
|
|
| 252 |
return buf.read()
|
| 253 |
|
| 254 |
|
| 255 |
+
def load_hf_index_cached(hf_token: str, hf_repo: str) -> list[dict]:
|
| 256 |
+
"""Load HF CR index, caching result in session_state to avoid redundant fetches."""
|
| 257 |
+
key = f"hf_index_{hf_repo}"
|
| 258 |
+
if key not in st.session_state:
|
| 259 |
+
from hf_cr_index import load_hf_index
|
| 260 |
+
try:
|
| 261 |
+
st.session_state[key] = load_hf_index(hf_token, hf_repo)
|
| 262 |
+
except Exception:
|
| 263 |
+
st.session_state[key] = []
|
| 264 |
+
return st.session_state[key]
|
| 265 |
+
|
| 266 |
+
|
| 267 |
# ββ Page config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 268 |
st.set_page_config(
|
| 269 |
page_title="CR Application Tool",
|
|
|
|
| 355 |
elif status == "upload":
|
| 356 |
st.subheader("Step 1 β Upload contribution list")
|
| 357 |
|
| 358 |
+
mode_label = st.radio(
|
| 359 |
+
"Pipeline mode",
|
| 360 |
+
["By contributor name", "By TS (all CRs for a spec)"],
|
| 361 |
+
key="mode_radio",
|
|
|
|
|
|
|
|
|
|
| 362 |
)
|
| 363 |
+
pipeline_mode = "contributor" if mode_label.startswith("By contributor") else "ts"
|
| 364 |
+
state["mode"] = pipeline_mode
|
| 365 |
+
|
| 366 |
+
# ββ Resolve active Excel (saved from a previous run, or freshly uploaded) ββ
|
| 367 |
+
saved_name = state.get("excel_filename")
|
| 368 |
+
saved_path = session_dir(sid) / saved_name if saved_name else None
|
| 369 |
+
has_saved = saved_path is not None and saved_path.exists()
|
| 370 |
+
|
| 371 |
+
if has_saved:
|
| 372 |
+
st.success(f"Using: **{saved_name}**")
|
| 373 |
+
with st.expander("Replace Excel file"):
|
| 374 |
+
uploaded = st.file_uploader(
|
| 375 |
+
"Upload a different Excel file (.xlsx or .xls)",
|
| 376 |
+
type=["xlsx", "xls"],
|
| 377 |
+
key="excel_replace",
|
| 378 |
+
)
|
| 379 |
+
else:
|
| 380 |
+
uploaded = st.file_uploader(
|
| 381 |
+
"Excel contribution list (.xlsx or .xls)",
|
| 382 |
+
type=["xlsx", "xls"],
|
| 383 |
+
)
|
| 384 |
|
| 385 |
+
# When a new file is dropped, save it immediately and update state
|
| 386 |
+
if uploaded:
|
| 387 |
+
active_path = session_dir(sid) / uploaded.name
|
| 388 |
+
active_bytes = bytes(uploaded.getbuffer())
|
| 389 |
+
active_path.write_bytes(active_bytes)
|
| 390 |
+
state["excel_filename"] = uploaded.name
|
| 391 |
+
state["excel_hash"] = hashlib.sha256(active_bytes).hexdigest()[:16]
|
| 392 |
+
save_state(sid, state)
|
| 393 |
+
elif has_saved:
|
| 394 |
+
active_path = saved_path
|
| 395 |
+
else:
|
| 396 |
+
active_path = None
|
| 397 |
|
| 398 |
+
if pipeline_mode == "contributor":
|
| 399 |
+
person_name = st.text_input(
|
| 400 |
+
"Contributor name (must match SubmittedBy column)",
|
| 401 |
+
value=state.get("person_name", "Ly Thanh PHAN"),
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
if active_path and st.button("Parse CR list β", type="primary"):
|
| 405 |
+
with st.spinner("Parsing Excelβ¦"):
|
| 406 |
+
try:
|
| 407 |
+
from fetch_crs import parse_excel
|
| 408 |
+
cr_list = parse_excel(str(active_path), person_name)
|
| 409 |
+
state["status"] = "preview"
|
| 410 |
+
state["person_name"] = person_name
|
| 411 |
+
state["cr_list"] = [list(row) for row in cr_list]
|
| 412 |
+
save_state(sid, state)
|
| 413 |
+
st.rerun()
|
| 414 |
+
except Exception as exc:
|
| 415 |
+
st.error(f"Failed to parse Excel: {exc}")
|
| 416 |
+
|
| 417 |
+
else: # TS mode
|
| 418 |
+
if active_path:
|
| 419 |
+
excel_hash = state.get("excel_hash") or hashlib.sha256(active_path.read_bytes()).hexdigest()[:16]
|
| 420 |
+
state["excel_hash"] = excel_hash
|
| 421 |
+
|
| 422 |
+
hf_token = os.environ.get("HF_TOKEN", "")
|
| 423 |
+
hf_repo = state.get("hf_repo", "OrganizedProgrammers/CR_Index")
|
| 424 |
+
|
| 425 |
+
# Check whether this Excel is already indexed
|
| 426 |
+
existing = load_hf_index_cached(hf_token, hf_repo)
|
| 427 |
+
already_indexed = any(r.get("excel_hash") == excel_hash for r in existing)
|
| 428 |
+
|
| 429 |
+
if already_indexed:
|
| 430 |
+
st.success(f"This Excel (`{excel_hash}`) is already indexed in HF.")
|
| 431 |
+
if st.button("Select TS β", type="primary"):
|
| 432 |
+
state["status"] = "ts_select"
|
| 433 |
+
save_state(sid, state)
|
| 434 |
+
st.rerun()
|
| 435 |
+
else:
|
| 436 |
+
st.info(f"Excel hash: `{excel_hash}` β not yet indexed.")
|
| 437 |
+
if st.button("Build CR Index", type="primary"):
|
| 438 |
+
# CRs downloaded during indexing go to the session-level cache
|
| 439 |
+
cr_cache_dir = session_dir(sid) / "CRs"
|
| 440 |
+
cr_cache_dir.mkdir(parents=True, exist_ok=True)
|
| 441 |
+
index_log = str(session_dir(sid) / "index.log")
|
| 442 |
+
rc_path = _rc_path(sid)
|
| 443 |
+
rc_path.unlink(missing_ok=True)
|
| 444 |
+
|
| 445 |
+
cmd = [
|
| 446 |
+
sys.executable,
|
| 447 |
+
str(SCRIPTS_DIR / "build_cr_index.py"),
|
| 448 |
+
str(active_path),
|
| 449 |
+
"--output-dir", str(session_dir(sid)),
|
| 450 |
+
"--hf-repo", hf_repo,
|
| 451 |
+
]
|
| 452 |
+
env = os.environ.copy()
|
| 453 |
+
env["EOL_USER"] = st.session_state.eol_user
|
| 454 |
+
env["EOL_PASSWORD"] = st.session_state.eol_password
|
| 455 |
+
# HF_TOKEN is already in env via os.environ
|
| 456 |
+
|
| 457 |
+
log_file = open(index_log, "w")
|
| 458 |
+
proc = subprocess.Popen(
|
| 459 |
+
cmd,
|
| 460 |
+
stdout=log_file,
|
| 461 |
+
stderr=subprocess.STDOUT,
|
| 462 |
+
env=env,
|
| 463 |
+
)
|
| 464 |
+
log_file.close()
|
| 465 |
+
|
| 466 |
+
threading.Thread(
|
| 467 |
+
target=_run_and_save_rc,
|
| 468 |
+
args=(proc, rc_path),
|
| 469 |
+
daemon=True,
|
| 470 |
+
).start()
|
| 471 |
+
st.session_state.proc = proc
|
| 472 |
+
|
| 473 |
+
state["status"] = "indexing"
|
| 474 |
+
state["pid"] = proc.pid
|
| 475 |
+
state["index_log"] = index_log
|
| 476 |
+
state["output_dir"] = "" # no pipeline output yet
|
| 477 |
+
state["started_at"] = datetime.now().isoformat()
|
| 478 |
+
save_state(sid, state)
|
| 479 |
+
st.rerun()
|
| 480 |
+
|
| 481 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 482 |
+
# INDEXING (build_cr_index.py running)
|
| 483 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 484 |
+
elif status == "indexing":
|
| 485 |
+
pid = state["pid"]
|
| 486 |
+
index_log = state.get("index_log", "")
|
| 487 |
+
|
| 488 |
+
proc = st.session_state.get("proc")
|
| 489 |
+
alive = False
|
| 490 |
+
if proc is not None:
|
| 491 |
+
alive = proc.poll() is None
|
| 492 |
+
else:
|
| 493 |
+
rc = read_return_code(sid)
|
| 494 |
+
if rc is None:
|
| 495 |
+
alive = is_process_alive(pid)
|
| 496 |
+
|
| 497 |
+
if alive:
|
| 498 |
+
st.subheader("β³ Building CR Indexβ¦")
|
| 499 |
+
st.info(f"PID {pid} β started {state.get('started_at', '')[:19]}")
|
| 500 |
+
log_text = tail_log(index_log, 50)
|
| 501 |
+
st.text_area("Live log (last 50 lines)", value=log_text, height=400)
|
| 502 |
+
time.sleep(2)
|
| 503 |
+
st.rerun()
|
| 504 |
+
else:
|
| 505 |
+
rc = read_return_code(sid)
|
| 506 |
+
if rc is None and proc is not None:
|
| 507 |
+
rc = proc.returncode
|
| 508 |
+
state["return_code"] = rc
|
| 509 |
+
state["completed_at"] = datetime.now().isoformat()
|
| 510 |
+
if rc == 0:
|
| 511 |
+
# Invalidate cached HF index so ts_select gets fresh data
|
| 512 |
+
st.session_state.pop(f"hf_index_{state.get('hf_repo', '')}", None)
|
| 513 |
+
state["status"] = "ts_select"
|
| 514 |
+
else:
|
| 515 |
+
# Expose the index log as log_path so the error state can display it
|
| 516 |
+
state["log_path"] = state.get("index_log", "")
|
| 517 |
+
state["status"] = "error"
|
| 518 |
+
save_state(sid, state)
|
| 519 |
+
st.rerun()
|
| 520 |
+
|
| 521 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 522 |
+
# TS_SELECT (index ready β pick a spec to process)
|
| 523 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 524 |
+
elif status == "ts_select":
|
| 525 |
+
st.subheader("Step 2 β Select target TS spec")
|
| 526 |
+
|
| 527 |
+
hf_token = os.environ.get("HF_TOKEN", "")
|
| 528 |
+
hf_repo = state.get("hf_repo", "OrganizedProgrammers/CR_Index")
|
| 529 |
+
excel_hash = state.get("excel_hash", "")
|
| 530 |
+
|
| 531 |
+
with st.spinner("Loading CR index from HuggingFaceβ¦"):
|
| 532 |
+
records = load_hf_index_cached(hf_token, hf_repo)
|
| 533 |
+
|
| 534 |
+
records_for_excel = [r for r in records if r.get("excel_hash") == excel_hash]
|
| 535 |
+
|
| 536 |
+
if not records_for_excel:
|
| 537 |
+
st.error(f"No records found for excel_hash `{excel_hash}`. Try rebuilding the index.")
|
| 538 |
+
if st.button("β Back to upload"):
|
| 539 |
+
state["status"] = "upload"
|
| 540 |
+
save_state(sid, state)
|
| 541 |
+
st.rerun()
|
| 542 |
+
else:
|
| 543 |
+
by_spec = defaultdict(list)
|
| 544 |
+
for r in records_for_excel:
|
| 545 |
+
by_spec[r["spec_number"]].append(r)
|
| 546 |
+
|
| 547 |
+
spec_options = sorted(by_spec.keys())
|
| 548 |
+
selected_spec = st.selectbox("Select target TS spec", spec_options)
|
| 549 |
+
|
| 550 |
+
if selected_spec:
|
| 551 |
+
versions = defaultdict(list)
|
| 552 |
+
for r in by_spec[selected_spec]:
|
| 553 |
+
versions[r["version"]].append(r["uid"])
|
| 554 |
+
st.write("**Versions found:**")
|
| 555 |
+
for ver, uids in sorted(versions.items()):
|
| 556 |
+
st.write(f" v{ver}: {len(uids)} CR(s) β {', '.join(uids)}")
|
| 557 |
+
|
| 558 |
+
col1, col2 = st.columns(2)
|
| 559 |
+
with col1:
|
| 560 |
+
if st.button("β Back"):
|
| 561 |
+
state["status"] = "upload"
|
| 562 |
+
save_state(sid, state)
|
| 563 |
+
st.rerun()
|
| 564 |
+
with col2:
|
| 565 |
+
if st.button("βΆ Apply CRs for this TS", type="primary"):
|
| 566 |
+
# Per-run directory so each pipeline's outputs are isolated
|
| 567 |
+
run_id = int(time.time())
|
| 568 |
+
output_dir = session_dir(sid) / f"run_{run_id}"
|
| 569 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 570 |
+
cr_cache_dir = session_dir(sid) / "CRs"
|
| 571 |
+
cr_cache_dir.mkdir(parents=True, exist_ok=True)
|
| 572 |
+
log_path = session_dir(sid) / f"pipeline_{run_id}.log"
|
| 573 |
+
rc_path = _rc_path(sid)
|
| 574 |
+
rc_path.unlink(missing_ok=True)
|
| 575 |
+
|
| 576 |
+
cmd = [
|
| 577 |
+
sys.executable,
|
| 578 |
+
str(SCRIPTS_DIR / "orchestrate_cr.py"),
|
| 579 |
+
"--output-dir", str(output_dir),
|
| 580 |
+
"--cr-cache-dir", str(cr_cache_dir),
|
| 581 |
+
"--ts-mode",
|
| 582 |
+
"--ts-id", selected_spec,
|
| 583 |
+
"--excel-hash", excel_hash,
|
| 584 |
+
"--hf-repo", hf_repo,
|
| 585 |
+
]
|
| 586 |
+
env = os.environ.copy()
|
| 587 |
+
env["EOL_USER"] = st.session_state.eol_user
|
| 588 |
+
env["EOL_PASSWORD"] = st.session_state.eol_password
|
| 589 |
+
# HF_TOKEN already in env via os.environ
|
| 590 |
+
|
| 591 |
+
log_file = open(str(log_path), "w")
|
| 592 |
+
proc = subprocess.Popen(
|
| 593 |
+
cmd,
|
| 594 |
+
stdout=log_file,
|
| 595 |
+
stderr=subprocess.STDOUT,
|
| 596 |
+
env=env,
|
| 597 |
+
)
|
| 598 |
+
log_file.close()
|
| 599 |
+
|
| 600 |
+
threading.Thread(
|
| 601 |
+
target=_run_and_save_rc,
|
| 602 |
+
args=(proc, rc_path),
|
| 603 |
+
daemon=True,
|
| 604 |
+
).start()
|
| 605 |
+
st.session_state.proc = proc
|
| 606 |
+
|
| 607 |
+
state["ts_id"] = selected_spec
|
| 608 |
+
state["status"] = "running"
|
| 609 |
+
state["pid"] = proc.pid
|
| 610 |
+
state["output_dir"] = str(output_dir)
|
| 611 |
+
state["log_path"] = str(log_path)
|
| 612 |
+
state["run_log_paths"] = [str(log_path)]
|
| 613 |
+
state["started_at"] = datetime.now().isoformat()
|
| 614 |
+
save_state(sid, state)
|
| 615 |
+
st.rerun()
|
| 616 |
|
| 617 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½ββ
|
| 618 |
# PREVIEW
|
|
|
|
| 648 |
with col2:
|
| 649 |
if cr_list and st.button("βΆ Start Pipeline", type="primary"):
|
| 650 |
excel_path = session_dir(sid) / state["excel_filename"]
|
| 651 |
+
# Per-run directory so each pipeline's outputs are isolated
|
| 652 |
+
run_id = int(time.time())
|
| 653 |
+
output_dir = session_dir(sid) / f"run_{run_id}"
|
| 654 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 655 |
+
cr_cache_dir = session_dir(sid) / "CRs"
|
| 656 |
+
cr_cache_dir.mkdir(parents=True, exist_ok=True)
|
| 657 |
+
log_path = session_dir(sid) / f"pipeline_{run_id}.log"
|
| 658 |
rc_path = _rc_path(sid)
|
| 659 |
+
rc_path.unlink(missing_ok=True)
|
| 660 |
|
| 661 |
cmd = [
|
| 662 |
sys.executable,
|
|
|
|
| 664 |
str(excel_path),
|
| 665 |
state["person_name"],
|
| 666 |
"--output-dir", str(output_dir),
|
| 667 |
+
"--cr-cache-dir", str(cr_cache_dir),
|
| 668 |
]
|
| 669 |
|
| 670 |
env = os.environ.copy()
|
|
|
|
| 693 |
state["pid"] = proc.pid
|
| 694 |
state["output_dir"] = str(output_dir)
|
| 695 |
state["log_path"] = str(log_path)
|
| 696 |
+
state["run_log_paths"] = [str(log_path)]
|
| 697 |
state["started_at"] = datetime.now().isoformat()
|
| 698 |
save_state(sid, state)
|
| 699 |
st.rerun()
|
|
|
|
| 747 |
else:
|
| 748 |
st.error(f"β Pipeline finished with errors (return code: {rc})")
|
| 749 |
|
| 750 |
+
# Per-TS results table β merge this run's logs so retry results supersede
|
| 751 |
+
# the original failed entries for the same TS key.
|
|
|
|
| 752 |
_merged: dict[str, dict] = {}
|
| 753 |
+
for _lf in state.get("run_log_paths", []):
|
| 754 |
+
for _r in parse_log_results(_lf):
|
| 755 |
_merged[_r["TS"]] = _r
|
| 756 |
results = list(_merged.values())
|
| 757 |
if results:
|
|
|
|
| 872 |
st.success(f"All {len(ready_entries)} TS(s) ready.")
|
| 873 |
|
| 874 |
if st.button("βΆ Apply CRs to recovered TSs", type="primary"):
|
| 875 |
+
retry_log = str(session_dir(sid) / f"pipeline_{int(time.time())}_retry.log")
|
| 876 |
_rc_path(sid).unlink(missing_ok=True) # clear old returncode
|
| 877 |
|
| 878 |
cmd = [
|
|
|
|
| 898 |
).start()
|
| 899 |
st.session_state.proc = proc
|
| 900 |
|
| 901 |
+
state["status"] = "running"
|
| 902 |
+
state["pid"] = proc.pid
|
| 903 |
+
state["log_path"] = retry_log
|
| 904 |
+
state["run_log_paths"] = state.get("run_log_paths", []) + [retry_log]
|
| 905 |
+
state["started_at"] = datetime.now().isoformat()
|
| 906 |
save_state(sid, state)
|
| 907 |
st.rerun()
|
| 908 |
else:
|
| 909 |
st.warning("No TSs available yet β retry download or upload DOCX files above.")
|
| 910 |
|
| 911 |
+
# Navigation
|
| 912 |
st.divider()
|
| 913 |
+
col_restart, col_new = st.columns(2)
|
| 914 |
+
with col_restart:
|
| 915 |
+
if st.button("β Run another pipeline", type="primary"):
|
| 916 |
+
# Reset pipeline fields, keep session ID and credentials
|
| 917 |
+
for _k in ("cr_list", "pid", "output_dir", "log_path", "index_log",
|
| 918 |
+
"started_at", "completed_at", "return_code", "ts_id"):
|
| 919 |
+
state[_k] = None if _k != "cr_list" else []
|
| 920 |
+
# excel_filename, excel_hash and person_name are intentionally kept
|
| 921 |
+
# so the user does not have to re-upload on the next run.
|
| 922 |
+
state["run_log_paths"] = []
|
| 923 |
+
state["status"] = "upload"
|
| 924 |
+
if "proc" in st.session_state:
|
| 925 |
+
del st.session_state.proc
|
| 926 |
+
_rc_path(sid).unlink(missing_ok=True)
|
| 927 |
+
save_state(sid, state)
|
| 928 |
+
st.rerun()
|
| 929 |
+
with col_new:
|
| 930 |
+
if st.button("Start new session"):
|
| 931 |
+
new_sid = str(uuid.uuid4())
|
| 932 |
+
st.session_state.sid = new_sid
|
| 933 |
+
st.session_state.state = new_state(new_sid)
|
| 934 |
+
if "proc" in st.session_state:
|
| 935 |
+
del st.session_state.proc
|
| 936 |
+
st.query_params["sid"] = new_sid
|
| 937 |
+
save_state(new_sid, st.session_state.state)
|
| 938 |
+
st.rerun()
|
scripts/build_cr_index.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
build_cr_index.py β Build and push a CR index to a HuggingFace dataset.
|
| 4 |
+
|
| 5 |
+
Parses all Accepted CRs from the Excel, downloads each one (reusing cached
|
| 6 |
+
files), parses the cover page to get (spec_number, version), then pushes a
|
| 7 |
+
JSONL index to the HF dataset.
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
python3 build_cr_index.py <excel_path> --output-dir DIR --hf-repo ORG/REPO
|
| 11 |
+
|
| 12 |
+
Environment variables:
|
| 13 |
+
EOL_USER, EOL_PASSWORD β ETSI EOL credentials for download_cr
|
| 14 |
+
HF_TOKEN β HuggingFace token (write access to hf-repo)
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import datetime
|
| 19 |
+
import hashlib
|
| 20 |
+
import os
|
| 21 |
+
import sys
|
| 22 |
+
import time
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
# ββ sys.path setup ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
SCRIPT_DIR = Path(__file__).parent
|
| 27 |
+
sys.path.insert(0, str(SCRIPT_DIR))
|
| 28 |
+
|
| 29 |
+
from fetch_crs import parse_excel_all_accepted, download_cr, parse_cr_cover, wsl_path
|
| 30 |
+
from hf_cr_index import load_hf_index, push_hf_index
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def main():
|
| 34 |
+
ap = argparse.ArgumentParser(
|
| 35 |
+
description="Build and push CR index to HuggingFace dataset.",
|
| 36 |
+
)
|
| 37 |
+
ap.add_argument("excel_path", help="Path to .xls or .xlsx contribution list")
|
| 38 |
+
ap.add_argument(
|
| 39 |
+
"--output-dir",
|
| 40 |
+
default=str(Path.home() / "CR_Processing"),
|
| 41 |
+
help="Base output directory (default: ~/CR_Processing)",
|
| 42 |
+
)
|
| 43 |
+
ap.add_argument(
|
| 44 |
+
"--hf-repo",
|
| 45 |
+
default="OrganizedProgrammers/CR_Index",
|
| 46 |
+
help="HuggingFace dataset repo (default: OrganizedProgrammers/CR_Index)",
|
| 47 |
+
)
|
| 48 |
+
args = ap.parse_args()
|
| 49 |
+
|
| 50 |
+
eol_user = os.environ.get("EOL_USER", "")
|
| 51 |
+
eol_password = os.environ.get("EOL_PASSWORD", "")
|
| 52 |
+
hf_token = os.environ.get("HF_TOKEN", "")
|
| 53 |
+
|
| 54 |
+
if not eol_user or not eol_password:
|
| 55 |
+
sys.exit("ERROR: EOL_USER and EOL_PASSWORD must be set")
|
| 56 |
+
if not hf_token:
|
| 57 |
+
sys.exit("ERROR: HF_TOKEN must be set")
|
| 58 |
+
|
| 59 |
+
excel_path = Path(wsl_path(args.excel_path))
|
| 60 |
+
if not excel_path.exists():
|
| 61 |
+
sys.exit(f"ERROR: Excel file not found: {excel_path}")
|
| 62 |
+
|
| 63 |
+
output_dir = Path(wsl_path(args.output_dir)).expanduser()
|
| 64 |
+
cr_dir = output_dir / "CRs"
|
| 65 |
+
cr_dir.mkdir(parents=True, exist_ok=True)
|
| 66 |
+
|
| 67 |
+
# ββ 1. Compute Excel hash βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 68 |
+
excel_hash = hashlib.sha256(excel_path.read_bytes()).hexdigest()[:16]
|
| 69 |
+
meeting_label = excel_path.stem
|
| 70 |
+
print(f"Excel: {excel_path.name}")
|
| 71 |
+
print(f"Excel hash: {excel_hash}")
|
| 72 |
+
print(f"Meeting: {meeting_label}")
|
| 73 |
+
print(f"HF repo: {args.hf_repo}")
|
| 74 |
+
print()
|
| 75 |
+
|
| 76 |
+
# ββ 2. Parse all Accepted CRs βββββββββββββββββββββββββββββββββββββββββββββ
|
| 77 |
+
print("Parsing Excel for all Accepted CRs...")
|
| 78 |
+
try:
|
| 79 |
+
cr_list = parse_excel_all_accepted(str(excel_path))
|
| 80 |
+
except Exception as e:
|
| 81 |
+
sys.exit(f"ERROR parsing Excel: {e}")
|
| 82 |
+
print(f"Found {len(cr_list)} Accepted CR(s)\n")
|
| 83 |
+
|
| 84 |
+
if not cr_list:
|
| 85 |
+
print("Nothing to index.")
|
| 86 |
+
sys.exit(0)
|
| 87 |
+
|
| 88 |
+
# ββ 3. Load existing HF index βββββββββββββββββββββββββββββββββββββββββββββ
|
| 89 |
+
print("Loading existing HF index...")
|
| 90 |
+
try:
|
| 91 |
+
existing = load_hf_index(hf_token, args.hf_repo)
|
| 92 |
+
except Exception as e:
|
| 93 |
+
print(f" WARNING: could not load existing index: {e}")
|
| 94 |
+
existing = []
|
| 95 |
+
existing_keys = {(r["excel_hash"], r["uid"]) for r in existing}
|
| 96 |
+
print(f" {len(existing)} existing record(s), {len(existing_keys)} unique keys\n")
|
| 97 |
+
|
| 98 |
+
# ββ 4. Download and parse each new CR βββββββββββββββββββββββββββββββββββββ
|
| 99 |
+
new_records = []
|
| 100 |
+
skipped = 0
|
| 101 |
+
failed = []
|
| 102 |
+
|
| 103 |
+
print("Processing CRs...")
|
| 104 |
+
for uid, title, submitted_by in cr_list:
|
| 105 |
+
if (excel_hash, uid) in existing_keys:
|
| 106 |
+
print(f" [{uid}] already indexed β skipping")
|
| 107 |
+
skipped += 1
|
| 108 |
+
continue
|
| 109 |
+
|
| 110 |
+
# Retry loop (3 attempts)
|
| 111 |
+
docx_path = None
|
| 112 |
+
note = ""
|
| 113 |
+
for attempt in range(1, 4):
|
| 114 |
+
docx_path, note = download_cr(uid, cr_dir, eol_user, eol_password)
|
| 115 |
+
if docx_path:
|
| 116 |
+
break
|
| 117 |
+
if attempt < 3:
|
| 118 |
+
print(f" [{uid}] attempt {attempt}/3 failed ({note}) β retrying in 5s")
|
| 119 |
+
time.sleep(5)
|
| 120 |
+
|
| 121 |
+
if not docx_path:
|
| 122 |
+
print(f" [{uid}] FAILED β {note}")
|
| 123 |
+
failed.append((uid, note))
|
| 124 |
+
continue
|
| 125 |
+
|
| 126 |
+
spec_number, version = parse_cr_cover(docx_path)
|
| 127 |
+
if not spec_number or not version:
|
| 128 |
+
print(f" [{uid}] WARNING: could not parse cover page β skipping")
|
| 129 |
+
failed.append((uid, "cover page parse failed"))
|
| 130 |
+
continue
|
| 131 |
+
|
| 132 |
+
print(f" [{uid}] -> TS {spec_number} v{version}")
|
| 133 |
+
new_records.append({
|
| 134 |
+
"excel_hash": excel_hash,
|
| 135 |
+
"meeting_label": meeting_label,
|
| 136 |
+
"uid": uid,
|
| 137 |
+
"title": title,
|
| 138 |
+
"submitted_by": submitted_by,
|
| 139 |
+
"spec_number": spec_number,
|
| 140 |
+
"version": version,
|
| 141 |
+
"parsed_at": datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
|
| 142 |
+
})
|
| 143 |
+
|
| 144 |
+
print()
|
| 145 |
+
print(f"Summary: {len(new_records)} new, {skipped} skipped, {len(failed)} failed")
|
| 146 |
+
if failed:
|
| 147 |
+
print("Failed CRs:")
|
| 148 |
+
for uid, reason in failed:
|
| 149 |
+
print(f" [{uid}] {reason}")
|
| 150 |
+
print()
|
| 151 |
+
|
| 152 |
+
# ββ 5. Merge and push βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 153 |
+
if new_records:
|
| 154 |
+
all_records = existing + new_records
|
| 155 |
+
print(f"Pushing {len(all_records)} record(s) to {args.hf_repo}...")
|
| 156 |
+
try:
|
| 157 |
+
push_hf_index(all_records, hf_token, args.hf_repo)
|
| 158 |
+
print(" Push successful")
|
| 159 |
+
except Exception as e:
|
| 160 |
+
sys.exit(f"ERROR pushing to HF: {e}")
|
| 161 |
+
else:
|
| 162 |
+
print("No new records to push.")
|
| 163 |
+
|
| 164 |
+
# ββ 6. Sentinel line (watched by app.py) ββββββββββββββββββββββββββββββββββ
|
| 165 |
+
print(f"INDEX_COMPLETE excel_hash={excel_hash}")
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
if __name__ == "__main__":
|
| 169 |
+
main()
|
scripts/fetch_crs.py
CHANGED
|
@@ -57,6 +57,23 @@ def parse_excel(excel_path: str, person_name: str):
|
|
| 57 |
raise ValueError(f"Unsupported file extension: {ext!r}. Expected .xls or .xlsx")
|
| 58 |
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
def _name_pattern(name: str) -> re.Pattern:
|
| 61 |
return re.compile(r"\b" + re.escape(name) + r"\b", re.IGNORECASE)
|
| 62 |
|
|
@@ -169,6 +186,103 @@ def _parse_xlsx(path: Path, person_name: str):
|
|
| 169 |
return results
|
| 170 |
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
# ---------------------------------------------------------------------------
|
| 173 |
# Step 2 β Download CR DOCXs
|
| 174 |
# ---------------------------------------------------------------------------
|
|
|
|
| 57 |
raise ValueError(f"Unsupported file extension: {ext!r}. Expected .xls or .xlsx")
|
| 58 |
|
| 59 |
|
| 60 |
+
def parse_excel_all_accepted(excel_path: str):
|
| 61 |
+
"""
|
| 62 |
+
Return list of (uid, title, submitted_by) for ALL Accepted CRs
|
| 63 |
+
regardless of who submitted them. Used by build_cr_index.py.
|
| 64 |
+
Handles both .xls and .xlsx.
|
| 65 |
+
"""
|
| 66 |
+
path = Path(wsl_path(excel_path))
|
| 67 |
+
ext = path.suffix.lower()
|
| 68 |
+
|
| 69 |
+
if ext == ".xls":
|
| 70 |
+
return _parse_xls_all(path)
|
| 71 |
+
elif ext == ".xlsx":
|
| 72 |
+
return _parse_xlsx_all(path)
|
| 73 |
+
else:
|
| 74 |
+
raise ValueError(f"Unsupported file extension: {ext!r}. Expected .xls or .xlsx")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
def _name_pattern(name: str) -> re.Pattern:
|
| 78 |
return re.compile(r"\b" + re.escape(name) + r"\b", re.IGNORECASE)
|
| 79 |
|
|
|
|
| 186 |
return results
|
| 187 |
|
| 188 |
|
| 189 |
+
def _parse_xls_all(path: Path):
|
| 190 |
+
"""Return (uid, title, submitted_by) for all Accepted CRs (no person filter)."""
|
| 191 |
+
try:
|
| 192 |
+
import xlrd
|
| 193 |
+
except ImportError:
|
| 194 |
+
sys.exit("ERROR: xlrd is not installed. Run: pip install xlrd")
|
| 195 |
+
|
| 196 |
+
wb = xlrd.open_workbook(str(path))
|
| 197 |
+
try:
|
| 198 |
+
ws = wb.sheet_by_name("Contributions")
|
| 199 |
+
except xlrd.XLRDError:
|
| 200 |
+
ws = wb.sheet_by_index(0)
|
| 201 |
+
|
| 202 |
+
headers = [str(ws.cell_value(0, c)).strip() for c in range(ws.ncols)]
|
| 203 |
+
col = {h: i for i, h in enumerate(headers)}
|
| 204 |
+
|
| 205 |
+
uid_col = col.get("Uid") or col.get("UID") or col.get("uid")
|
| 206 |
+
type_col = col.get("Type") or col.get("type")
|
| 207 |
+
status_col = col.get("Status") or col.get("status")
|
| 208 |
+
by_col = col.get("SubmittedBy") or col.get("Submitted By") or col.get("submittedby")
|
| 209 |
+
title_col = col.get("Title") or col.get("title")
|
| 210 |
+
|
| 211 |
+
for name, c in [("Uid", uid_col), ("Type", type_col),
|
| 212 |
+
("Status", status_col), ("SubmittedBy", by_col)]:
|
| 213 |
+
if c is None:
|
| 214 |
+
raise ValueError(f"Column {name!r} not found. Available: {list(col.keys())}")
|
| 215 |
+
|
| 216 |
+
results = []
|
| 217 |
+
for r in range(2, ws.nrows):
|
| 218 |
+
uid = str(ws.cell_value(r, uid_col)).strip()
|
| 219 |
+
doc_type = str(ws.cell_value(r, type_col)).strip()
|
| 220 |
+
status = str(ws.cell_value(r, status_col)).strip()
|
| 221 |
+
submitted_by = str(ws.cell_value(r, by_col)).strip()
|
| 222 |
+
title = str(ws.cell_value(r, title_col)).strip() if title_col is not None else ""
|
| 223 |
+
|
| 224 |
+
if doc_type != "CR":
|
| 225 |
+
continue
|
| 226 |
+
if status != "Accepted":
|
| 227 |
+
continue
|
| 228 |
+
|
| 229 |
+
results.append((uid, title, submitted_by))
|
| 230 |
+
|
| 231 |
+
return results
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _parse_xlsx_all(path: Path):
|
| 235 |
+
"""Return (uid, title, submitted_by) for all Accepted CRs (no person filter)."""
|
| 236 |
+
try:
|
| 237 |
+
import openpyxl
|
| 238 |
+
except ImportError:
|
| 239 |
+
sys.exit("ERROR: openpyxl is not installed. Run: pip install openpyxl")
|
| 240 |
+
|
| 241 |
+
wb = openpyxl.load_workbook(str(path), read_only=True, data_only=True)
|
| 242 |
+
ws = wb["Contributions"] if "Contributions" in wb.sheetnames else wb.active
|
| 243 |
+
|
| 244 |
+
rows = iter(ws.iter_rows(values_only=True))
|
| 245 |
+
header_row = next(rows)
|
| 246 |
+
headers = [str(h).strip() if h is not None else "" for h in header_row]
|
| 247 |
+
col = {h: i for i, h in enumerate(headers)}
|
| 248 |
+
|
| 249 |
+
next(rows, None) # skip empty duplicate row
|
| 250 |
+
|
| 251 |
+
uid_col = col.get("Uid") or col.get("UID") or col.get("uid")
|
| 252 |
+
type_col = col.get("Type") or col.get("type")
|
| 253 |
+
status_col = col.get("Status") or col.get("status")
|
| 254 |
+
by_col = col.get("SubmittedBy") or col.get("Submitted By") or col.get("submittedby")
|
| 255 |
+
title_col = col.get("Title") or col.get("title")
|
| 256 |
+
|
| 257 |
+
for name, c in [("Uid", uid_col), ("Type", type_col),
|
| 258 |
+
("Status", status_col), ("SubmittedBy", by_col)]:
|
| 259 |
+
if c is None:
|
| 260 |
+
raise ValueError(f"Column {name!r} not found. Available: {list(col.keys())}")
|
| 261 |
+
|
| 262 |
+
results = []
|
| 263 |
+
for row in rows:
|
| 264 |
+
def cell(c):
|
| 265 |
+
v = row[c] if c < len(row) else None
|
| 266 |
+
return str(v).strip() if v is not None else ""
|
| 267 |
+
|
| 268 |
+
uid = cell(uid_col)
|
| 269 |
+
doc_type = cell(type_col)
|
| 270 |
+
status = cell(status_col)
|
| 271 |
+
submitted_by = cell(by_col)
|
| 272 |
+
title = cell(title_col) if title_col is not None else ""
|
| 273 |
+
|
| 274 |
+
if not uid:
|
| 275 |
+
continue
|
| 276 |
+
if doc_type != "CR":
|
| 277 |
+
continue
|
| 278 |
+
if status != "Accepted":
|
| 279 |
+
continue
|
| 280 |
+
|
| 281 |
+
results.append((uid, title, submitted_by))
|
| 282 |
+
|
| 283 |
+
return results
|
| 284 |
+
|
| 285 |
+
|
| 286 |
# ---------------------------------------------------------------------------
|
| 287 |
# Step 2 β Download CR DOCXs
|
| 288 |
# ---------------------------------------------------------------------------
|
scripts/hf_cr_index.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
hf_cr_index.py β Shared HuggingFace dataset index helpers.
|
| 3 |
+
|
| 4 |
+
Used by both build_cr_index.py and orchestrate_cr.py.
|
| 5 |
+
Depends only on huggingface_hub (pip install huggingface_hub).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def load_hf_index(hf_token: str, hf_repo: str) -> list[dict]:
|
| 13 |
+
"""
|
| 14 |
+
Download cr_index.jsonl from the HF dataset and return parsed records.
|
| 15 |
+
Returns an empty list if the file does not exist yet.
|
| 16 |
+
"""
|
| 17 |
+
from huggingface_hub import hf_hub_download
|
| 18 |
+
from huggingface_hub.errors import EntryNotFoundError
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
path = hf_hub_download(
|
| 22 |
+
repo_id=hf_repo,
|
| 23 |
+
filename="cr_index.jsonl",
|
| 24 |
+
repo_type="dataset",
|
| 25 |
+
token=hf_token,
|
| 26 |
+
)
|
| 27 |
+
text = Path(path).read_text(encoding="utf-8")
|
| 28 |
+
return [json.loads(line) for line in text.splitlines() if line.strip()]
|
| 29 |
+
except EntryNotFoundError:
|
| 30 |
+
return []
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def push_hf_index(records: list[dict], hf_token: str, hf_repo: str) -> None:
|
| 34 |
+
"""
|
| 35 |
+
Push all records as cr_index.jsonl to the HF dataset repo.
|
| 36 |
+
Creates the repo if it does not exist.
|
| 37 |
+
"""
|
| 38 |
+
from huggingface_hub import HfApi
|
| 39 |
+
|
| 40 |
+
api = HfApi()
|
| 41 |
+
api.create_repo(
|
| 42 |
+
repo_id=hf_repo,
|
| 43 |
+
repo_type="dataset",
|
| 44 |
+
exist_ok=True,
|
| 45 |
+
token=hf_token,
|
| 46 |
+
)
|
| 47 |
+
tmp = Path("/tmp/cr_index.jsonl")
|
| 48 |
+
tmp.write_text(
|
| 49 |
+
"\n".join(json.dumps(r, ensure_ascii=False) for r in records),
|
| 50 |
+
encoding="utf-8",
|
| 51 |
+
)
|
| 52 |
+
api.upload_file(
|
| 53 |
+
path_or_fileobj=str(tmp),
|
| 54 |
+
path_in_repo="cr_index.jsonl",
|
| 55 |
+
repo_id=hf_repo,
|
| 56 |
+
repo_type="dataset",
|
| 57 |
+
token=hf_token,
|
| 58 |
+
)
|
scripts/orchestrate_cr.py
CHANGED
|
@@ -16,6 +16,11 @@ Arguments:
|
|
| 16 |
Options:
|
| 17 |
--output-dir Base output folder (default: ~/CR_Processing)
|
| 18 |
--author Tracked-change author name (default: "CR Application")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
"""
|
| 20 |
|
| 21 |
import argparse
|
|
@@ -25,6 +30,7 @@ import io
|
|
| 25 |
import json
|
| 26 |
import os
|
| 27 |
import re
|
|
|
|
| 28 |
import sys
|
| 29 |
import time
|
| 30 |
from pathlib import Path
|
|
@@ -75,6 +81,249 @@ class _TeeWriter:
|
|
| 75 |
self._real.flush()
|
| 76 |
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
# ββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 79 |
|
| 80 |
def main():
|
|
@@ -86,7 +335,7 @@ def main():
|
|
| 86 |
'excel_path',
|
| 87 |
nargs='?',
|
| 88 |
default=None,
|
| 89 |
-
help='Path to .xls or .xlsx contribution list (not required in --retry-mode)',
|
| 90 |
)
|
| 91 |
ap.add_argument(
|
| 92 |
'person_name',
|
|
@@ -109,10 +358,38 @@ def main():
|
|
| 109 |
action='store_true',
|
| 110 |
help='Skip steps 1-4; apply CRs to TSs listed in failed_ts.json that now have their DOCX on disk',
|
| 111 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
args = ap.parse_args()
|
| 113 |
|
| 114 |
-
if
|
| 115 |
-
ap.error('
|
|
|
|
|
|
|
| 116 |
|
| 117 |
eol_user = os.environ.get("EOL_USER", "")
|
| 118 |
eol_password = os.environ.get("EOL_PASSWORD", "")
|
|
@@ -120,8 +397,9 @@ def main():
|
|
| 120 |
sys.exit("ERROR: EOL_USER and EOL_PASSWORD must be set")
|
| 121 |
|
| 122 |
output_dir = Path(wsl_path(args.output_dir)).expanduser()
|
| 123 |
-
|
| 124 |
-
|
|
|
|
| 125 |
cr_dir.mkdir(parents=True, exist_ok=True)
|
| 126 |
ts_dir.mkdir(parents=True, exist_ok=True)
|
| 127 |
|
|
@@ -159,7 +437,6 @@ def main():
|
|
| 159 |
print(f' [TS {spec_number} v{version}] DOCX found β will apply')
|
| 160 |
else:
|
| 161 |
print(f' [TS {spec_number} v{version}] DOCX missing β skipping')
|
| 162 |
-
# Reconstruct cr_paths for each UID
|
| 163 |
cr_entry_dir = Path(entry['cr_dir'])
|
| 164 |
for uid in entry['cr_uids']:
|
| 165 |
extracted = cr_entry_dir / f'{uid}_extracted.docx'
|
|
@@ -169,7 +446,7 @@ def main():
|
|
| 169 |
elif plain.exists():
|
| 170 |
cr_paths[uid] = plain
|
| 171 |
|
| 172 |
-
# ββ Steps 5 & 6 (retry mode
|
| 173 |
report = []
|
| 174 |
|
| 175 |
for (spec_number, version), uids in ts_groups.items():
|
|
@@ -246,7 +523,6 @@ def main():
|
|
| 246 |
|
| 247 |
for line in log_lines:
|
| 248 |
print(f' {line}')
|
| 249 |
-
# Bubble every un-applied change into the warnings list
|
| 250 |
for line in log_lines:
|
| 251 |
if line.strip().startswith('ERROR'):
|
| 252 |
errors.append(line.strip())
|
|
@@ -332,6 +608,87 @@ def main():
|
|
| 332 |
print(f' ! {err}')
|
| 333 |
return
|
| 334 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
excel_path = wsl_path(args.excel_path)
|
| 336 |
|
| 337 |
# ββ Step 1: Parse Excel βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -350,242 +707,21 @@ def main():
|
|
| 350 |
print('Nothing to process.')
|
| 351 |
return
|
| 352 |
|
| 353 |
-
#
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
for uid, _ in cr_list:
|
| 358 |
-
docx_path, note = download_cr(uid, cr_dir, eol_user, eol_password)
|
| 359 |
-
if docx_path:
|
| 360 |
-
cr_paths[uid] = docx_path
|
| 361 |
-
print(f' [{uid}] OK ({note}) β {docx_path.name}')
|
| 362 |
-
|
| 363 |
-
n_cr_failed = len(cr_list) - len(cr_paths)
|
| 364 |
-
if n_cr_failed:
|
| 365 |
-
print(f' {len(cr_paths)}/{len(cr_list)} downloaded ({n_cr_failed} failed β details in warnings)')
|
| 366 |
-
else:
|
| 367 |
-
print(f' All {len(cr_list)} CR(s) downloaded successfully')
|
| 368 |
-
|
| 369 |
-
# ββ Step 3: Parse cover pages β group by target TS βββββββββββββββββββββββ
|
| 370 |
-
_section('Step 3 β Parsing CR cover pages')
|
| 371 |
-
ts_groups = {} # (spec_number, version) -> [uid, ...]
|
| 372 |
-
uid_cover_failed = []
|
| 373 |
-
|
| 374 |
-
for uid in cr_paths:
|
| 375 |
-
spec_number, version = parse_cr_cover(cr_paths[uid])
|
| 376 |
-
if spec_number and version:
|
| 377 |
-
key = (spec_number, version)
|
| 378 |
-
ts_groups.setdefault(key, []).append(uid)
|
| 379 |
-
print(f' [{uid}] -> TS {spec_number} v{version}')
|
| 380 |
-
else:
|
| 381 |
-
uid_cover_failed.append(uid)
|
| 382 |
-
print(f' [{uid}] WARNING: could not parse cover page β skipping')
|
| 383 |
-
|
| 384 |
-
if not ts_groups:
|
| 385 |
-
print('\nNo TSs identified. Nothing to apply.')
|
| 386 |
-
return
|
| 387 |
-
|
| 388 |
-
# ββ Step 4: Download TSs ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 389 |
-
_section('Step 4 β Downloading TSs')
|
| 390 |
-
ts_paths = {} # (spec_number, version) -> Path
|
| 391 |
-
spec_dirs = {} # (spec_number, version) -> Path (per-spec subfolder)
|
| 392 |
-
|
| 393 |
-
for (spec_number, version) in ts_groups:
|
| 394 |
-
spec_compact = spec_number.replace(' ', '')
|
| 395 |
-
spec_dir = ts_dir / spec_compact
|
| 396 |
-
spec_dir.mkdir(parents=True, exist_ok=True)
|
| 397 |
-
spec_dirs[(spec_number, version)] = spec_dir
|
| 398 |
-
|
| 399 |
-
print(f' [TS {spec_number} v{version}] ', end='', flush=True)
|
| 400 |
-
filename, note = None, "not attempted"
|
| 401 |
-
for attempt in range(1, 4):
|
| 402 |
-
filename, note = download_ts(spec_number, version, spec_dir, eol_user, eol_password)
|
| 403 |
-
if filename:
|
| 404 |
-
break
|
| 405 |
-
if attempt < 3:
|
| 406 |
-
print(f'\n [attempt {attempt}/3 failed β retrying in 5s: {note}]', flush=True)
|
| 407 |
-
print(f' [TS {spec_number} v{version}] ', end='', flush=True)
|
| 408 |
-
time.sleep(5)
|
| 409 |
-
else:
|
| 410 |
-
print(f'\n [all 3 attempts failed]', flush=True)
|
| 411 |
-
if filename:
|
| 412 |
-
ts_paths[(spec_number, version)] = spec_dir / filename
|
| 413 |
-
print(f'OK ({note}) β {spec_compact}/{filename}')
|
| 414 |
-
else:
|
| 415 |
-
print(f'FAILED β {note}')
|
| 416 |
-
|
| 417 |
-
# Write failed_ts.json (even when empty so app.py can detect "no failures")
|
| 418 |
-
failed_ts_entries = [
|
| 419 |
-
{
|
| 420 |
-
"spec_number": spec_number,
|
| 421 |
-
"version": version,
|
| 422 |
-
"spec_compact": spec_number.replace(' ', ''),
|
| 423 |
-
"spec_dir": str(spec_dirs[(spec_number, version)]),
|
| 424 |
-
"expected_filename": f"ts_{spec_number.replace(' ', '')}_v{version}.docx",
|
| 425 |
-
"cr_uids": ts_groups[(spec_number, version)],
|
| 426 |
-
"cr_dir": str(cr_dir),
|
| 427 |
-
}
|
| 428 |
-
for (spec_number, version) in ts_groups
|
| 429 |
-
if (spec_number, version) not in ts_paths
|
| 430 |
-
]
|
| 431 |
-
(output_dir / "failed_ts.json").write_text(
|
| 432 |
-
json.dumps(failed_ts_entries, indent=2)
|
| 433 |
)
|
| 434 |
|
| 435 |
-
#
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
# Derive new version early so filenames are known upfront
|
| 446 |
-
new_v = derive_new_version(version)
|
| 447 |
-
stem = f'ts_{spec_compact}_v{new_v}_was_v{version}'
|
| 448 |
-
ts_applied = spec_dir / f'ts_{spec_compact}_v{version}_applied.docx'
|
| 449 |
-
ts_final = spec_dir / f'{stem}.docx'
|
| 450 |
-
log_path = spec_dir / f'{stem}.log'
|
| 451 |
-
errors = []
|
| 452 |
-
|
| 453 |
-
print(f'\n-- {ts_key} ({len(uids)} CR(s): {", ".join(uids)}) --')
|
| 454 |
-
|
| 455 |
-
if (spec_number, version) not in ts_paths:
|
| 456 |
-
msg = 'TS download failed β skipping'
|
| 457 |
-
print(f' SKIP: {msg}')
|
| 458 |
-
report.append((ts_key, 0, 0, len(uids), None, log_path, [msg]))
|
| 459 |
-
continue
|
| 460 |
-
|
| 461 |
-
ts_in = ts_paths[(spec_number, version)]
|
| 462 |
-
|
| 463 |
-
# All per-TS output is captured to log_buf (tee: stdout + file)
|
| 464 |
-
log_buf = io.StringIO()
|
| 465 |
-
tee = _TeeWriter(sys.stdout, log_buf)
|
| 466 |
-
|
| 467 |
-
with contextlib.redirect_stdout(tee):
|
| 468 |
-
log_header = (
|
| 469 |
-
f'Pipeline Log\n'
|
| 470 |
-
f'TS: {spec_number} v{version} -> v{new_v}\n'
|
| 471 |
-
f'CRs: {", ".join(uids)}\n'
|
| 472 |
-
f'Date: {datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\n'
|
| 473 |
-
f'{"=" * 60}\n'
|
| 474 |
-
)
|
| 475 |
-
print(log_header, end='')
|
| 476 |
-
|
| 477 |
-
# 5a. Parse all CR manifests and combine
|
| 478 |
-
combined_manifest = []
|
| 479 |
-
participating_uids = []
|
| 480 |
-
|
| 481 |
-
for uid in uids:
|
| 482 |
-
if uid not in cr_paths:
|
| 483 |
-
errors.append(f'[{uid}] CR download had failed β skipped')
|
| 484 |
-
continue
|
| 485 |
-
print(f' Parsing {uid}... ', end='', flush=True)
|
| 486 |
-
try:
|
| 487 |
-
changes = parse_cr(cr_paths[uid])
|
| 488 |
-
combined_manifest.extend(changes)
|
| 489 |
-
participating_uids.append(uid)
|
| 490 |
-
print(f'{len(changes)} change(s)')
|
| 491 |
-
except Exception as e:
|
| 492 |
-
errors.append(f'[{uid}] parse ERROR: {e}')
|
| 493 |
-
print(f'ERROR: {e}')
|
| 494 |
-
|
| 495 |
-
if not combined_manifest:
|
| 496 |
-
print(' No changes parsed β skipping apply step.')
|
| 497 |
-
report.append((ts_key, 0, 0, len(uids), None, log_path,
|
| 498 |
-
errors + ['No changes parsed']))
|
| 499 |
-
log_path.write_text(log_buf.getvalue(), encoding='utf-8')
|
| 500 |
-
continue
|
| 501 |
-
|
| 502 |
-
# 5b. Apply manifest to TS
|
| 503 |
-
print(f' Applying {len(combined_manifest)} change(s) to {ts_in.name}...')
|
| 504 |
-
try:
|
| 505 |
-
n_ok, n_skip, log_lines = apply_manifest(
|
| 506 |
-
ts_in, combined_manifest, ts_applied, author=author, date=tc_date
|
| 507 |
-
)
|
| 508 |
-
except Exception as e:
|
| 509 |
-
errors.append(f'apply_manifest ERROR: {e}')
|
| 510 |
-
print(f' ERROR: {e}')
|
| 511 |
-
report.append((ts_key, 0, 0, len(uids), None, log_path, errors))
|
| 512 |
-
log_path.write_text(log_buf.getvalue(), encoding='utf-8')
|
| 513 |
-
continue
|
| 514 |
-
|
| 515 |
-
for line in log_lines:
|
| 516 |
-
print(f' {line}')
|
| 517 |
-
# Bubble every un-applied change into the warnings list
|
| 518 |
-
for line in log_lines:
|
| 519 |
-
if line.strip().startswith('ERROR'):
|
| 520 |
-
errors.append(line.strip())
|
| 521 |
-
print(f' -> Applied: {n_ok} Skipped: {n_skip}')
|
| 522 |
-
|
| 523 |
-
# 6. Finalise metadata (Change History, History, title paragraph)
|
| 524 |
-
print(' Finalising metadata...')
|
| 525 |
-
try:
|
| 526 |
-
ts_doc = docx_lib.Document(str(ts_applied))
|
| 527 |
-
rev = RevCounter(ts_doc)
|
| 528 |
-
|
| 529 |
-
pub_ym, pub_month_year = compute_pub_date()
|
| 530 |
-
old_v = version
|
| 531 |
-
|
| 532 |
-
# Extract old date string from first paragraph
|
| 533 |
-
title_text = ts_doc.paragraphs[0].text
|
| 534 |
-
date_match = re.search(r'\((\d{4}-\d{2})\)', title_text)
|
| 535 |
-
old_date_str = date_match.group(1) if date_match else ''
|
| 536 |
-
|
| 537 |
-
print(f' Version: {old_v} -> {new_v}')
|
| 538 |
-
print(f' Publication: {pub_month_year} ({pub_ym})')
|
| 539 |
-
|
| 540 |
-
# One Change History row per CR
|
| 541 |
-
for uid in participating_uids:
|
| 542 |
-
try:
|
| 543 |
-
meta = extract_cr_metadata(str(cr_paths[uid]))
|
| 544 |
-
ch_cells = update_change_history_table(
|
| 545 |
-
ts_doc, meta, pub_ym, old_v, new_v, rev, author, tc_date
|
| 546 |
-
)
|
| 547 |
-
print(f' [Change History] {uid}: {ch_cells}')
|
| 548 |
-
except NoChangeHistoryTable:
|
| 549 |
-
print(f' [Change History] {uid}: NOT PRESENT β this document has no Change History table (History table only)')
|
| 550 |
-
except Exception as e:
|
| 551 |
-
errors.append(f'[{uid}] Change History ERROR: {e}')
|
| 552 |
-
print(f' [Change History] {uid}: ERROR β {e}')
|
| 553 |
-
|
| 554 |
-
# One History row for the whole TS
|
| 555 |
-
try:
|
| 556 |
-
h_cells = update_history_table(
|
| 557 |
-
ts_doc, new_v, pub_month_year, rev, author, tc_date
|
| 558 |
-
)
|
| 559 |
-
print(f' [History] {h_cells}')
|
| 560 |
-
except Exception as e:
|
| 561 |
-
errors.append(f'History table ERROR: {e}')
|
| 562 |
-
print(f' [History] ERROR β {e}')
|
| 563 |
-
|
| 564 |
-
# Title paragraph version + date
|
| 565 |
-
if old_date_str:
|
| 566 |
-
try:
|
| 567 |
-
update_title_para(
|
| 568 |
-
ts_doc, old_v, new_v, old_date_str, pub_ym, rev, author, tc_date
|
| 569 |
-
)
|
| 570 |
-
print(f' [Title] V{old_v} -> V{new_v}, ({old_date_str}) -> ({pub_ym})')
|
| 571 |
-
except Exception as e:
|
| 572 |
-
errors.append(f'Title update ERROR: {e}')
|
| 573 |
-
print(f' [Title] ERROR β {e}')
|
| 574 |
-
else:
|
| 575 |
-
print(f' [Title] SKIP β no (YYYY-MM) pattern in: {title_text!r}')
|
| 576 |
-
|
| 577 |
-
ts_doc.save(str(ts_final))
|
| 578 |
-
print(f' Saved: {spec_compact}/{ts_final.name}')
|
| 579 |
-
print(f' Log: {spec_compact}/{log_path.name}')
|
| 580 |
-
report.append((ts_key, n_ok, n_skip, len(uids), ts_final, log_path, errors))
|
| 581 |
-
|
| 582 |
-
except Exception as e:
|
| 583 |
-
errors.append(f'Finalisation ERROR: {e}')
|
| 584 |
-
print(f' Finalisation ERROR: {e}')
|
| 585 |
-
report.append((ts_key, n_ok, n_skip, len(uids), ts_applied, log_path, errors))
|
| 586 |
-
|
| 587 |
-
# Write log file after the tee context exits
|
| 588 |
-
log_path.write_text(log_buf.getvalue(), encoding='utf-8')
|
| 589 |
|
| 590 |
# ββ Final Report ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 591 |
_section('Final Report')
|
|
|
|
| 16 |
Options:
|
| 17 |
--output-dir Base output folder (default: ~/CR_Processing)
|
| 18 |
--author Tracked-change author name (default: "CR Application")
|
| 19 |
+
--retry-mode Skip steps 1-4; apply CRs listed in failed_ts.json
|
| 20 |
+
--ts-mode Apply all CRs for a given spec number across all versions
|
| 21 |
+
--ts-id Spec number to process in ts-mode (e.g. "102 267")
|
| 22 |
+
--excel-hash Excel hash used to filter the HF index in ts-mode
|
| 23 |
+
--hf-repo HuggingFace dataset repo containing the CR index
|
| 24 |
"""
|
| 25 |
|
| 26 |
import argparse
|
|
|
|
| 30 |
import json
|
| 31 |
import os
|
| 32 |
import re
|
| 33 |
+
import shutil
|
| 34 |
import sys
|
| 35 |
import time
|
| 36 |
from pathlib import Path
|
|
|
|
| 81 |
self._real.flush()
|
| 82 |
|
| 83 |
|
| 84 |
+
# ββ Shared Steps 2, 4, 5, 6 ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
+
|
| 86 |
+
def _run_steps_2_to_6(cr_list, ts_groups, output_dir, cr_dir, ts_dir,
|
| 87 |
+
eol_user, eol_password, author, tc_date):
|
| 88 |
+
"""
|
| 89 |
+
Execute steps 2 (download CRs), 4 (download TSs), 5 & 6 (apply + finalise).
|
| 90 |
+
|
| 91 |
+
cr_list : list of (uid, title)
|
| 92 |
+
ts_groups : dict {(spec_number, version): [uid, ...]} β may be pre-built
|
| 93 |
+
(ts-mode) or None to trigger Step 3 (cover page parse).
|
| 94 |
+
"""
|
| 95 |
+
# ββ Step 2: Download CR DOCXs βββββββββββββββββββββββββββββββββββββββββββββ
|
| 96 |
+
_section('Step 2 β Downloading CR DOCXs')
|
| 97 |
+
cr_paths = {} # uid -> Path
|
| 98 |
+
|
| 99 |
+
for uid, _ in cr_list:
|
| 100 |
+
docx_path, note = download_cr(uid, cr_dir, eol_user, eol_password)
|
| 101 |
+
if docx_path:
|
| 102 |
+
cr_paths[uid] = docx_path
|
| 103 |
+
print(f' [{uid}] OK ({note}) β {docx_path.name}')
|
| 104 |
+
else:
|
| 105 |
+
print(f' [{uid}] FAILED β {note}')
|
| 106 |
+
|
| 107 |
+
n_cr_failed = len(cr_list) - len(cr_paths)
|
| 108 |
+
if n_cr_failed:
|
| 109 |
+
print(f' {len(cr_paths)}/{len(cr_list)} downloaded ({n_cr_failed} failed)')
|
| 110 |
+
else:
|
| 111 |
+
print(f' All {len(cr_list)} CR(s) downloaded successfully')
|
| 112 |
+
|
| 113 |
+
# ββ Step 3: Parse cover pages (only when ts_groups not pre-built) βββββββββ
|
| 114 |
+
if ts_groups is None:
|
| 115 |
+
_section('Step 3 β Parsing CR cover pages')
|
| 116 |
+
ts_groups = {}
|
| 117 |
+
uid_cover_failed = []
|
| 118 |
+
|
| 119 |
+
for uid in cr_paths:
|
| 120 |
+
spec_number, version = parse_cr_cover(cr_paths[uid])
|
| 121 |
+
if spec_number and version:
|
| 122 |
+
key = (spec_number, version)
|
| 123 |
+
ts_groups.setdefault(key, []).append(uid)
|
| 124 |
+
print(f' [{uid}] -> TS {spec_number} v{version}')
|
| 125 |
+
else:
|
| 126 |
+
uid_cover_failed.append(uid)
|
| 127 |
+
print(f' [{uid}] WARNING: could not parse cover page β skipping')
|
| 128 |
+
|
| 129 |
+
if not ts_groups:
|
| 130 |
+
print('\nNo TSs identified. Nothing to apply.')
|
| 131 |
+
return [], {}, {}, {}
|
| 132 |
+
|
| 133 |
+
# ββ Step 4: Download TSs ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 134 |
+
_section('Step 4 β Downloading TSs')
|
| 135 |
+
ts_paths = {} # (spec_number, version) -> Path
|
| 136 |
+
spec_dirs = {} # (spec_number, version) -> Path (per-spec subfolder)
|
| 137 |
+
|
| 138 |
+
for (spec_number, version) in ts_groups:
|
| 139 |
+
spec_compact = spec_number.replace(' ', '')
|
| 140 |
+
spec_dir = ts_dir / spec_compact
|
| 141 |
+
spec_dir.mkdir(parents=True, exist_ok=True)
|
| 142 |
+
spec_dirs[(spec_number, version)] = spec_dir
|
| 143 |
+
|
| 144 |
+
print(f' [TS {spec_number} v{version}] ', end='', flush=True)
|
| 145 |
+
filename, note = None, "not attempted"
|
| 146 |
+
for attempt in range(1, 4):
|
| 147 |
+
filename, note = download_ts(spec_number, version, spec_dir, eol_user, eol_password)
|
| 148 |
+
if filename:
|
| 149 |
+
break
|
| 150 |
+
if attempt < 3:
|
| 151 |
+
print(f'\n [attempt {attempt}/3 failed β retrying in 5s: {note}]', flush=True)
|
| 152 |
+
print(f' [TS {spec_number} v{version}] ', end='', flush=True)
|
| 153 |
+
time.sleep(5)
|
| 154 |
+
else:
|
| 155 |
+
print(f'\n [all 3 attempts failed]', flush=True)
|
| 156 |
+
if filename:
|
| 157 |
+
ts_paths[(spec_number, version)] = spec_dir / filename
|
| 158 |
+
print(f'OK ({note}) β {spec_compact}/{filename}')
|
| 159 |
+
else:
|
| 160 |
+
print(f'FAILED β {note}')
|
| 161 |
+
|
| 162 |
+
# Write failed_ts.json
|
| 163 |
+
failed_ts_entries = [
|
| 164 |
+
{
|
| 165 |
+
"spec_number": spec_number,
|
| 166 |
+
"version": version,
|
| 167 |
+
"spec_compact": spec_number.replace(' ', ''),
|
| 168 |
+
"spec_dir": str(spec_dirs[(spec_number, version)]),
|
| 169 |
+
"expected_filename": f"ts_{spec_number.replace(' ', '')}_v{version}.docx",
|
| 170 |
+
"cr_uids": ts_groups[(spec_number, version)],
|
| 171 |
+
"cr_dir": str(cr_dir),
|
| 172 |
+
}
|
| 173 |
+
for (spec_number, version) in ts_groups
|
| 174 |
+
if (spec_number, version) not in ts_paths
|
| 175 |
+
]
|
| 176 |
+
(output_dir / "failed_ts.json").write_text(
|
| 177 |
+
json.dumps(failed_ts_entries, indent=2)
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
# ββ Steps 5 & 6: Apply CRs + Finalise each TS ββββββββββββββββββββββββββββ
|
| 181 |
+
_section('Steps 5 & 6 β Applying CRs and Finalising Metadata')
|
| 182 |
+
report = [] # (ts_key, n_ok, n_skip, n_crs, out_path, log_path, errors)
|
| 183 |
+
|
| 184 |
+
for (spec_number, version), uids in ts_groups.items():
|
| 185 |
+
ts_key = f'TS {spec_number} v{version}'
|
| 186 |
+
spec_compact = spec_number.replace(' ', '')
|
| 187 |
+
spec_dir = spec_dirs.get((spec_number, version), ts_dir / spec_compact)
|
| 188 |
+
spec_dir.mkdir(parents=True, exist_ok=True)
|
| 189 |
+
|
| 190 |
+
new_v = derive_new_version(version)
|
| 191 |
+
stem = f'ts_{spec_compact}_v{new_v}_was_v{version}'
|
| 192 |
+
ts_applied = spec_dir / f'ts_{spec_compact}_v{version}_applied.docx'
|
| 193 |
+
ts_final = spec_dir / f'{stem}.docx'
|
| 194 |
+
log_path = spec_dir / f'{stem}.log'
|
| 195 |
+
errors = []
|
| 196 |
+
|
| 197 |
+
print(f'\n-- {ts_key} ({len(uids)} CR(s): {", ".join(uids)}) --')
|
| 198 |
+
|
| 199 |
+
if (spec_number, version) not in ts_paths:
|
| 200 |
+
msg = 'TS download failed β skipping'
|
| 201 |
+
print(f' SKIP: {msg}')
|
| 202 |
+
report.append((ts_key, 0, 0, len(uids), None, log_path, [msg]))
|
| 203 |
+
continue
|
| 204 |
+
|
| 205 |
+
ts_in = ts_paths[(spec_number, version)]
|
| 206 |
+
|
| 207 |
+
log_buf = io.StringIO()
|
| 208 |
+
tee = _TeeWriter(sys.stdout, log_buf)
|
| 209 |
+
|
| 210 |
+
with contextlib.redirect_stdout(tee):
|
| 211 |
+
log_header = (
|
| 212 |
+
f'Pipeline Log\n'
|
| 213 |
+
f'TS: {spec_number} v{version} -> v{new_v}\n'
|
| 214 |
+
f'CRs: {", ".join(uids)}\n'
|
| 215 |
+
f'Date: {datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}\n'
|
| 216 |
+
f'{"=" * 60}\n'
|
| 217 |
+
)
|
| 218 |
+
print(log_header, end='')
|
| 219 |
+
|
| 220 |
+
combined_manifest = []
|
| 221 |
+
participating_uids = []
|
| 222 |
+
|
| 223 |
+
for uid in uids:
|
| 224 |
+
if uid not in cr_paths:
|
| 225 |
+
errors.append(f'[{uid}] CR download had failed β skipped')
|
| 226 |
+
continue
|
| 227 |
+
print(f' Parsing {uid}... ', end='', flush=True)
|
| 228 |
+
try:
|
| 229 |
+
changes = parse_cr(cr_paths[uid])
|
| 230 |
+
combined_manifest.extend(changes)
|
| 231 |
+
participating_uids.append(uid)
|
| 232 |
+
print(f'{len(changes)} change(s)')
|
| 233 |
+
except Exception as e:
|
| 234 |
+
errors.append(f'[{uid}] parse ERROR: {e}')
|
| 235 |
+
print(f'ERROR: {e}')
|
| 236 |
+
|
| 237 |
+
if not combined_manifest:
|
| 238 |
+
print(' No changes parsed β skipping apply step.')
|
| 239 |
+
report.append((ts_key, 0, 0, len(uids), None, log_path,
|
| 240 |
+
errors + ['No changes parsed']))
|
| 241 |
+
log_path.write_text(log_buf.getvalue(), encoding='utf-8')
|
| 242 |
+
continue
|
| 243 |
+
|
| 244 |
+
print(f' Applying {len(combined_manifest)} change(s) to {ts_in.name}...')
|
| 245 |
+
try:
|
| 246 |
+
n_ok, n_skip, log_lines = apply_manifest(
|
| 247 |
+
ts_in, combined_manifest, ts_applied, author=author, date=tc_date
|
| 248 |
+
)
|
| 249 |
+
except Exception as e:
|
| 250 |
+
errors.append(f'apply_manifest ERROR: {e}')
|
| 251 |
+
print(f' ERROR: {e}')
|
| 252 |
+
report.append((ts_key, 0, 0, len(uids), None, log_path, errors))
|
| 253 |
+
log_path.write_text(log_buf.getvalue(), encoding='utf-8')
|
| 254 |
+
continue
|
| 255 |
+
|
| 256 |
+
for line in log_lines:
|
| 257 |
+
print(f' {line}')
|
| 258 |
+
for line in log_lines:
|
| 259 |
+
if line.strip().startswith('ERROR'):
|
| 260 |
+
errors.append(line.strip())
|
| 261 |
+
print(f' -> Applied: {n_ok} Skipped: {n_skip}')
|
| 262 |
+
|
| 263 |
+
print(' Finalising metadata...')
|
| 264 |
+
try:
|
| 265 |
+
ts_doc = docx_lib.Document(str(ts_applied))
|
| 266 |
+
rev = RevCounter(ts_doc)
|
| 267 |
+
|
| 268 |
+
pub_ym, pub_month_year = compute_pub_date()
|
| 269 |
+
old_v = version
|
| 270 |
+
|
| 271 |
+
title_text = ts_doc.paragraphs[0].text
|
| 272 |
+
date_match = re.search(r'\((\d{4}-\d{2})\)', title_text)
|
| 273 |
+
old_date_str = date_match.group(1) if date_match else ''
|
| 274 |
+
|
| 275 |
+
print(f' Version: {old_v} -> {new_v}')
|
| 276 |
+
print(f' Publication: {pub_month_year} ({pub_ym})')
|
| 277 |
+
|
| 278 |
+
for uid in participating_uids:
|
| 279 |
+
try:
|
| 280 |
+
meta = extract_cr_metadata(str(cr_paths[uid]))
|
| 281 |
+
ch_cells = update_change_history_table(
|
| 282 |
+
ts_doc, meta, pub_ym, old_v, new_v, rev, author, tc_date
|
| 283 |
+
)
|
| 284 |
+
print(f' [Change History] {uid}: {ch_cells}')
|
| 285 |
+
except NoChangeHistoryTable:
|
| 286 |
+
print(f' [Change History] {uid}: NOT PRESENT β this document has no Change History table (History table only)')
|
| 287 |
+
except Exception as e:
|
| 288 |
+
errors.append(f'[{uid}] Change History ERROR: {e}')
|
| 289 |
+
print(f' [Change History] {uid}: ERROR β {e}')
|
| 290 |
+
|
| 291 |
+
try:
|
| 292 |
+
h_cells = update_history_table(
|
| 293 |
+
ts_doc, new_v, pub_month_year, rev, author, tc_date
|
| 294 |
+
)
|
| 295 |
+
print(f' [History] {h_cells}')
|
| 296 |
+
except Exception as e:
|
| 297 |
+
errors.append(f'History table ERROR: {e}')
|
| 298 |
+
print(f' [History] ERROR β {e}')
|
| 299 |
+
|
| 300 |
+
if old_date_str:
|
| 301 |
+
try:
|
| 302 |
+
update_title_para(
|
| 303 |
+
ts_doc, old_v, new_v, old_date_str, pub_ym, rev, author, tc_date
|
| 304 |
+
)
|
| 305 |
+
print(f' [Title] V{old_v} -> V{new_v}, ({old_date_str}) -> ({pub_ym})')
|
| 306 |
+
except Exception as e:
|
| 307 |
+
errors.append(f'Title update ERROR: {e}')
|
| 308 |
+
print(f' [Title] ERROR β {e}')
|
| 309 |
+
else:
|
| 310 |
+
print(f' [Title] SKIP β no (YYYY-MM) pattern in: {title_text!r}')
|
| 311 |
+
|
| 312 |
+
ts_doc.save(str(ts_final))
|
| 313 |
+
print(f' Saved: {spec_compact}/{ts_final.name}')
|
| 314 |
+
print(f' Log: {spec_compact}/{log_path.name}')
|
| 315 |
+
report.append((ts_key, n_ok, n_skip, len(uids), ts_final, log_path, errors))
|
| 316 |
+
|
| 317 |
+
except Exception as e:
|
| 318 |
+
errors.append(f'Finalisation ERROR: {e}')
|
| 319 |
+
print(f' Finalisation ERROR: {e}')
|
| 320 |
+
report.append((ts_key, n_ok, n_skip, len(uids), ts_applied, log_path, errors))
|
| 321 |
+
|
| 322 |
+
log_path.write_text(log_buf.getvalue(), encoding='utf-8')
|
| 323 |
+
|
| 324 |
+
return report, cr_paths, ts_paths, spec_dirs
|
| 325 |
+
|
| 326 |
+
|
| 327 |
# ββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 328 |
|
| 329 |
def main():
|
|
|
|
| 335 |
'excel_path',
|
| 336 |
nargs='?',
|
| 337 |
default=None,
|
| 338 |
+
help='Path to .xls or .xlsx contribution list (not required in --retry-mode or --ts-mode)',
|
| 339 |
)
|
| 340 |
ap.add_argument(
|
| 341 |
'person_name',
|
|
|
|
| 358 |
action='store_true',
|
| 359 |
help='Skip steps 1-4; apply CRs to TSs listed in failed_ts.json that now have their DOCX on disk',
|
| 360 |
)
|
| 361 |
+
ap.add_argument(
|
| 362 |
+
'--ts-mode',
|
| 363 |
+
action='store_true',
|
| 364 |
+
help='Apply all CRs for a given spec number across all versions (uses HF index)',
|
| 365 |
+
)
|
| 366 |
+
ap.add_argument(
|
| 367 |
+
'--ts-id',
|
| 368 |
+
default='',
|
| 369 |
+
help='Spec number to process in ts-mode, e.g. "102 267"',
|
| 370 |
+
)
|
| 371 |
+
ap.add_argument(
|
| 372 |
+
'--excel-hash',
|
| 373 |
+
default='',
|
| 374 |
+
help='Excel hash used to filter the HF index in ts-mode',
|
| 375 |
+
)
|
| 376 |
+
ap.add_argument(
|
| 377 |
+
'--hf-repo',
|
| 378 |
+
default='OrganizedProgrammers/CR_Index',
|
| 379 |
+
help='HuggingFace dataset repo containing the CR index',
|
| 380 |
+
)
|
| 381 |
+
ap.add_argument(
|
| 382 |
+
'--cr-cache-dir',
|
| 383 |
+
default='',
|
| 384 |
+
help='Shared directory for caching downloaded CR DOCXs across runs '
|
| 385 |
+
'(default: <output-dir>/CRs)',
|
| 386 |
+
)
|
| 387 |
args = ap.parse_args()
|
| 388 |
|
| 389 |
+
if args.ts_mode and not args.ts_id:
|
| 390 |
+
ap.error('--ts-id is required when using --ts-mode')
|
| 391 |
+
if not args.ts_mode and not args.retry_mode and not args.excel_path:
|
| 392 |
+
ap.error('excel_path is required when not in --retry-mode or --ts-mode')
|
| 393 |
|
| 394 |
eol_user = os.environ.get("EOL_USER", "")
|
| 395 |
eol_password = os.environ.get("EOL_PASSWORD", "")
|
|
|
|
| 397 |
sys.exit("ERROR: EOL_USER and EOL_PASSWORD must be set")
|
| 398 |
|
| 399 |
output_dir = Path(wsl_path(args.output_dir)).expanduser()
|
| 400 |
+
cr_cache = args.cr_cache_dir.strip()
|
| 401 |
+
cr_dir = Path(wsl_path(cr_cache)).expanduser() if cr_cache else output_dir / 'CRs'
|
| 402 |
+
ts_dir = output_dir / 'TS'
|
| 403 |
cr_dir.mkdir(parents=True, exist_ok=True)
|
| 404 |
ts_dir.mkdir(parents=True, exist_ok=True)
|
| 405 |
|
|
|
|
| 437 |
print(f' [TS {spec_number} v{version}] DOCX found β will apply')
|
| 438 |
else:
|
| 439 |
print(f' [TS {spec_number} v{version}] DOCX missing β skipping')
|
|
|
|
| 440 |
cr_entry_dir = Path(entry['cr_dir'])
|
| 441 |
for uid in entry['cr_uids']:
|
| 442 |
extracted = cr_entry_dir / f'{uid}_extracted.docx'
|
|
|
|
| 446 |
elif plain.exists():
|
| 447 |
cr_paths[uid] = plain
|
| 448 |
|
| 449 |
+
# ββ Steps 5 & 6 (retry mode) βββββββββββββββββββββββββββββββββββββββββ
|
| 450 |
report = []
|
| 451 |
|
| 452 |
for (spec_number, version), uids in ts_groups.items():
|
|
|
|
| 523 |
|
| 524 |
for line in log_lines:
|
| 525 |
print(f' {line}')
|
|
|
|
| 526 |
for line in log_lines:
|
| 527 |
if line.strip().startswith('ERROR'):
|
| 528 |
errors.append(line.strip())
|
|
|
|
| 608 |
print(f' ! {err}')
|
| 609 |
return
|
| 610 |
|
| 611 |
+
# ββ TS mode β load HF index, skip Steps 1 & 3 ββββββββββββββββββββββββββββ
|
| 612 |
+
if args.ts_mode:
|
| 613 |
+
hf_token = os.environ.get("HF_TOKEN", "")
|
| 614 |
+
if not hf_token:
|
| 615 |
+
sys.exit("ERROR: HF_TOKEN must be set in ts-mode")
|
| 616 |
+
|
| 617 |
+
from hf_cr_index import load_hf_index
|
| 618 |
+
|
| 619 |
+
_section(f'TS mode β spec {args.ts_id!r}')
|
| 620 |
+
print(f'Loading HF index from {args.hf_repo}...')
|
| 621 |
+
try:
|
| 622 |
+
all_records = load_hf_index(hf_token, args.hf_repo)
|
| 623 |
+
except Exception as e:
|
| 624 |
+
sys.exit(f'ERROR loading HF index: {e}')
|
| 625 |
+
|
| 626 |
+
records = [
|
| 627 |
+
r for r in all_records
|
| 628 |
+
if r.get("excel_hash") == args.excel_hash
|
| 629 |
+
and r.get("spec_number") == args.ts_id
|
| 630 |
+
]
|
| 631 |
+
|
| 632 |
+
if not records:
|
| 633 |
+
sys.exit(
|
| 634 |
+
f'ERROR: no indexed CRs found for spec {args.ts_id!r} '
|
| 635 |
+
f'with excel_hash={args.excel_hash!r}'
|
| 636 |
+
)
|
| 637 |
+
|
| 638 |
+
# Build ts_groups from index (bypasses Step 3)
|
| 639 |
+
ts_groups = {}
|
| 640 |
+
for r in records:
|
| 641 |
+
key = (r["spec_number"], r["version"])
|
| 642 |
+
ts_groups.setdefault(key, []).append(r["uid"])
|
| 643 |
+
|
| 644 |
+
# Build cr_list for Step 2 download
|
| 645 |
+
cr_list = [(r["uid"], r["title"]) for r in records]
|
| 646 |
+
|
| 647 |
+
print(f'Found {len(records)} CR(s) across {len(ts_groups)} version(s):')
|
| 648 |
+
for (spec, ver), uids in ts_groups.items():
|
| 649 |
+
print(f' TS {spec} v{ver}: {", ".join(uids)}')
|
| 650 |
+
|
| 651 |
+
report, cr_paths, ts_paths, spec_dirs = _run_steps_2_to_6(
|
| 652 |
+
cr_list, ts_groups, output_dir, cr_dir, ts_dir,
|
| 653 |
+
eol_user, eol_password, author, tc_date,
|
| 654 |
+
)
|
| 655 |
+
|
| 656 |
+
# Copy the CRs actually applied into the run output dir so the ZIP
|
| 657 |
+
# contains exactly the CRs used for this TS (only needed when using
|
| 658 |
+
# a shared CR cache that lives outside output_dir).
|
| 659 |
+
_run_cr_dir = output_dir / 'CRs'
|
| 660 |
+
if cr_dir.resolve() != _run_cr_dir.resolve():
|
| 661 |
+
_run_cr_dir.mkdir(parents=True, exist_ok=True)
|
| 662 |
+
for _p in cr_paths.values():
|
| 663 |
+
if _p.exists():
|
| 664 |
+
shutil.copy2(_p, _run_cr_dir / _p.name)
|
| 665 |
+
|
| 666 |
+
_section('Final Report (TS mode)')
|
| 667 |
+
n_success = sum(1 for r in report if r[4] is not None and not r[6])
|
| 668 |
+
n_partial = sum(1 for r in report if r[4] is not None and r[6])
|
| 669 |
+
n_failed = sum(1 for r in report if r[4] is None)
|
| 670 |
+
|
| 671 |
+
print(f'Spec: {args.ts_id}')
|
| 672 |
+
print(f'CRs found: {len(cr_list)}')
|
| 673 |
+
print(f'TSs updated: {n_success} fully OK, {n_partial} with warnings, {n_failed} failed')
|
| 674 |
+
print()
|
| 675 |
+
|
| 676 |
+
for ts_key, n_ok, n_skip, n_crs, out_path, log_path, errors in report:
|
| 677 |
+
status = 'OK' if out_path and not errors else ('WARN' if out_path else 'FAIL')
|
| 678 |
+
print(f' [{status}] {ts_key}')
|
| 679 |
+
print(f' CRs: {n_crs} | Body changes applied: {n_ok} | Skipped: {n_skip}')
|
| 680 |
+
if out_path:
|
| 681 |
+
print(f' Output: {out_path.parent.name}/{out_path.name}')
|
| 682 |
+
if log_path and log_path.exists():
|
| 683 |
+
print(f' Log: {log_path.parent.name}/{log_path.name}')
|
| 684 |
+
for err in errors:
|
| 685 |
+
print(f' ! {err}')
|
| 686 |
+
|
| 687 |
+
print()
|
| 688 |
+
print(f'Output directory: {output_dir}/')
|
| 689 |
+
return
|
| 690 |
+
|
| 691 |
+
# ββ Normal mode βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 692 |
excel_path = wsl_path(args.excel_path)
|
| 693 |
|
| 694 |
# ββ Step 1: Parse Excel βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 707 |
print('Nothing to process.')
|
| 708 |
return
|
| 709 |
|
| 710 |
+
# Steps 2, 3 (cover page parse), 4, 5, 6
|
| 711 |
+
report, cr_paths, ts_paths, spec_dirs = _run_steps_2_to_6(
|
| 712 |
+
cr_list, None, output_dir, cr_dir, ts_dir,
|
| 713 |
+
eol_user, eol_password, author, tc_date,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
)
|
| 715 |
|
| 716 |
+
# Copy the CRs actually applied into the run output dir so the ZIP
|
| 717 |
+
# contains exactly the CRs used for this run (only needed when using
|
| 718 |
+
# a shared CR cache that lives outside output_dir).
|
| 719 |
+
_run_cr_dir = output_dir / 'CRs'
|
| 720 |
+
if cr_dir.resolve() != _run_cr_dir.resolve():
|
| 721 |
+
_run_cr_dir.mkdir(parents=True, exist_ok=True)
|
| 722 |
+
for _p in cr_paths.values():
|
| 723 |
+
if _p.exists():
|
| 724 |
+
shutil.copy2(_p, _run_cr_dir / _p.name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 725 |
|
| 726 |
# ββ Final Report ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 727 |
_section('Final Report')
|