#!/usr/bin/env python3 """Download issue data referenced by the Feishu issue export JSON.""" from __future__ import annotations import argparse import json import re import shutil import subprocess import sys from collections import OrderedDict from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Iterable FILE = Path(__file__).resolve() ROOT = FILE.parents[2] if str(ROOT) not in sys.path: sys.path.append(str(ROOT)) from tools.feishu_project.case_calib_recovery import recover_camera4_json PDCL_REF_RE = re.compile(r"ADAS_[^:/\\\s]+::[^/\\\s]*") MDI_RAW_REF_ARG_RE = re.compile(r"(?:^|\s)mdi\s+raw\b.*?(?:^|\s)-r\s+([^\s]+)") STANDARD_PATH_SPLIT_RE = re.compile(r"[,,\n;;]+") SHARED_CALIB_REL = Path("test_data") / "calibs" / "camera4.json" PLACEHOLDER_TEXTS = {"待填", "待补充", "none", "null", "待提供"} NETWORK_SHARE_PREFIX_MAPPINGS = ( ("//hfs.minieye.tech/project-D4Q2", "/mnt/D4Q2"), ("//192.168.2.122/project-D4Q2", "/mnt/D4Q2"), ("//hfs.minieye.tech/project-G1M3", "/mnt/G1M3"), ("//192.168.2.122/project-G1M3", "/mnt/G1M3"), ("//hfs.minieye.tech/G1M3", "/mnt/G1M3"), ("//192.168.2.122/G1M3", "/mnt/G1M3"), ) @dataclass class ActionResult: issue_id: int issue_name: str source_field: str source_kind: str raw_value: str | None normalized_ref: str | None output_dir: str status: str detail: str resolved_source_path: str | None = None command: list[str] | None = None candidate_paths: list[str] | None = None selected_subpath: str | None = None def to_dict(self) -> dict: return { "issue_id": self.issue_id, "issue_name": self.issue_name, "source_field": self.source_field, "source_kind": self.source_kind, "raw_value": self.raw_value, "normalized_ref": self.normalized_ref, "output_dir": self.output_dir, "status": self.status, "detail": self.detail, "resolved_source_path": self.resolved_source_path, "command": self.command, "candidate_paths": self.candidate_paths, "selected_subpath": self.selected_subpath, } @dataclass(frozen=True) class PDCLDownloadRequest: normalized_ref: str selected_subpath: str | None = None raw_token: str | None = None def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Download or stage issue data from a Feishu issue export JSON." ) parser.add_argument( "--input-json", default="tools/feishu_project/dongying_g1q3_issue_list.json", help="Path to the issue export JSON.", ) parser.add_argument( "--output-root", required=True, help="Directory where downloaded or copied data should be stored.", ) parser.add_argument( "--manifest-path", default=None, help="Optional explicit path for the execution manifest JSON.", ) parser.add_argument( "--issue-id", action="append", dest="issue_ids", type=int, help="Optional issue id filter. Can be repeated.", ) parser.add_argument( "--dry-run", action="store_true", help="Plan actions without running mdi or copying files.", ) parser.add_argument( "--skip-mdi", action="store_true", help="Skip PDCL/MDI downloads and only process standard paths.", ) parser.add_argument( "--skip-copy", action="store_true", help="Skip standard-path copies and only process PDCL/MDI downloads.", ) parser.add_argument( "--only-redownload-affected-cases", action="store_true", help=( "Only re-copy standard-path cases affected by the historical sigmastar.1/camera4.bin " "copy bug. This mode skips PDCL/MDI downloads and replaces stale copied targets." ), ) parser.add_argument( "--skip-calib-recovery", action="store_true", help=( "Skip recovering camera4.json from camera_config_folder.bin or mcap attachments " "after standard-path copies." ), ) return parser.parse_args() def load_issue_items(path: Path) -> list[dict]: payload = json.loads(path.read_text(encoding="utf-8")) return payload["items"] def ensure_dir(path: Path, dry_run: bool) -> None: if dry_run: return path.mkdir(parents=True, exist_ok=True) def log_progress(message: str) -> None: timestamp = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S") print(f"[download_issue_data {timestamp}] {message}", flush=True) def compact_text(value: object, max_len: int = 96) -> str: text = "" if value is None else str(value).strip() text = re.sub(r"\s+", " ", text) if len(text) <= max_len: return text return f"{text[: max_len - 3]}..." def summarize_issue_results(results: list[ActionResult]) -> str: if not results: return "no actions" summary: dict[str, int] = {} for result in results: summary[result.status] = summary.get(result.status, 0) + 1 return ", ".join(f"{status}={summary[status]}" for status in sorted(summary)) def normalize_issue_dirname(issue_id: int) -> str: return f"issue_{issue_id}" def iter_issue_fields(item: dict) -> Iterable[tuple[str, object]]: yield "问题数据地址", item.get("问题数据地址") yield "问题数据地址_PDCL", item.get("问题数据地址_PDCL") def _normalize_pdcl_selected_subpath(raw_subpath: str) -> str | None: cleaned = raw_subpath.strip().strip("/") if not cleaned: return None candidate = Path(cleaned) if candidate.is_absolute(): return None if any(part in {"", ".", ".."} for part in candidate.parts): return None return str(candidate) def _build_pdcl_request_from_token(token: str) -> PDCLDownloadRequest | None: stripped = token.strip().strip("\"'`") match = PDCL_REF_RE.match(stripped) if match is None: return None normalized_ref = match.group(0) suffix = stripped[match.end():] selected_subpath = None if suffix.startswith("/"): raw_subpath = suffix[1:] if raw_subpath and not raw_subpath.startswith("ADAS_"): selected_subpath = _normalize_pdcl_selected_subpath(raw_subpath) return PDCLDownloadRequest( normalized_ref=normalized_ref, selected_subpath=selected_subpath, raw_token=stripped, ) def extract_pdcl_requests(raw_value: object) -> list[PDCLDownloadRequest]: if raw_value is None: return [] text = str(raw_value).strip() if not text: return [] requests: list[PDCLDownloadRequest] = [] for segment in (part.strip() for part in STANDARD_PATH_SPLIT_RE.split(text)): if not segment: continue mdi_match = MDI_RAW_REF_ARG_RE.search(segment) if mdi_match is not None: request = _build_pdcl_request_from_token(mdi_match.group(1)) if request is not None: requests.append(request) continue search_pos = 0 while True: match = PDCL_REF_RE.search(segment, search_pos) if match is None: break normalized_ref = match.group(0) suffix = segment[match.end():] selected_subpath = None if suffix.startswith("/") and not suffix[1:].startswith("ADAS_"): selected_subpath = _normalize_pdcl_selected_subpath(suffix[1:]) requests.append( PDCLDownloadRequest( normalized_ref=normalized_ref, selected_subpath=selected_subpath, raw_token=segment, ) ) break requests.append( PDCLDownloadRequest( normalized_ref=normalized_ref, selected_subpath=None, raw_token=normalized_ref, ) ) search_pos = match.end() deduped: list[PDCLDownloadRequest] = [] seen = set() for request in requests: key = (request.normalized_ref, request.selected_subpath) if key in seen: continue seen.add(key) deduped.append(request) return deduped def extract_standard_paths(raw_value: object) -> list[str]: if raw_value is None: return [] text = str(raw_value).strip() if not text: return [] if text.lower() in PLACEHOLDER_TEXTS: return [] if extract_pdcl_requests(text): return [] if "/" not in text and "\\" not in text: return [] parts = [part.strip() for part in STANDARD_PATH_SPLIT_RE.split(text)] return [part for part in parts if part] def normalize_standard_source_path(path: Path) -> Path: normalized = path if normalized.name == "camera4.bin" and normalized.parent.name == "sigmastar.1": return normalized.parent.parent if normalized.name == "sigmastar.1": return normalized.parent return normalized def is_affected_standard_path(raw_path: str) -> bool: raw_path_obj = Path(raw_path.strip()) return normalize_standard_source_path(raw_path_obj) != raw_path_obj def normalize_share_path_separators(path_str: str) -> str: normalized = path_str.strip().replace("\\", "/") normalized = re.sub(r"/{3,}", "//", normalized) return normalized def rewrite_network_share_path(path_str: str) -> str | None: normalized = normalize_share_path_separators(path_str) for prefix_src, prefix_dst in NETWORK_SHARE_PREFIX_MAPPINGS: if normalized.startswith(prefix_src): return f"{prefix_dst}{normalized[len(prefix_src):]}" return None def build_path_candidates(raw_path: str) -> list[Path]: candidates: list[str] = [raw_path] normalized_share = normalize_share_path_separators(raw_path) if normalized_share != raw_path: candidates.append(normalized_share) network_share_rewritten = rewrite_network_share_path(raw_path) if network_share_rewritten: candidates.append(network_share_rewritten) for needle in ("hfs/project-G1M3", "project-G1M3"): for candidate in list(candidates): if needle in candidate: candidates.append(candidate.replace(needle, "G1M3")) normalized_candidates = [normalize_standard_source_path(Path(candidate)) for candidate in candidates] unique_candidates = list(OrderedDict.fromkeys(str(candidate) for candidate in normalized_candidates)) return [Path(candidate) for candidate in unique_candidates] def resolve_existing_path(raw_path: str) -> tuple[Path | None, list[Path]]: candidates = build_path_candidates(raw_path) for candidate in candidates: if candidate.exists(): return candidate, candidates return None, candidates def remove_existing_target(path: Path) -> None: if path.is_dir(): shutil.rmtree(path) else: path.unlink() def copy_source_path( source_path: Path, output_dir: Path, dry_run: bool, replace_existing: bool = False, legacy_target_names: Iterable[str] | None = None, ) -> tuple[str, str]: target = output_dir / source_path.name cleanup_targets: list[Path] = [target] if legacy_target_names: for target_name in legacy_target_names: cleanup_targets.append(output_dir / target_name) unique_cleanup_targets = list(OrderedDict.fromkeys(str(path) for path in cleanup_targets)) cleanup_paths = [Path(path) for path in unique_cleanup_targets] existing_targets = [path for path in cleanup_paths if path.exists()] if existing_targets and not replace_existing: return "exists", f"target already exists: {target}" if dry_run: if existing_targets and replace_existing: existing_str = ", ".join(str(path) for path in existing_targets) return "planned_redownload", f"would replace {existing_str} with {source_path} -> {target}" return "planned", f"would copy {source_path} -> {target}" ensure_dir(output_dir, dry_run=False) if replace_existing: for existing_target in existing_targets: remove_existing_target(existing_target) if source_path.is_dir(): shutil.copytree(source_path, target) else: shutil.copy2(source_path, target) if existing_targets and replace_existing: replaced = ", ".join(str(path) for path in existing_targets) return "redownloaded", f"replaced {replaced} with {target}" return "copied", f"copied to {target}" def build_copied_target_root(output_dir: Path, source_path: Path) -> Path: return output_dir / source_path.name def find_shared_test_data_dir(source_path: Path, max_parent_levels: int = 4) -> Path | None: if (source_path / SHARED_CALIB_REL).is_file(): return None current = source_path.parent for _ in range(max_parent_levels): candidate = current / "test_data" if (candidate / "calibs" / "camera4.json").is_file(): return candidate if current.parent == current: break current = current.parent return None def sync_shared_test_data( source_path: Path, target_root: Path, dry_run: bool, ) -> tuple[str | None, str | None, str | None]: shared_test_data_dir = find_shared_test_data_dir(source_path) if shared_test_data_dir is None: return None, None, None target_shared_test_data_dir = target_root / "test_data" target_shared_calib = target_shared_test_data_dir / "calibs" / "camera4.json" if target_shared_calib.is_file(): return None, None, None if dry_run: return ( "planned_shared_calib_sync", f"would copy shared test_data {shared_test_data_dir} -> {target_shared_test_data_dir}", str(shared_test_data_dir), ) shutil.copytree(shared_test_data_dir, target_shared_test_data_dir, dirs_exist_ok=True) return ( "synced_shared_calib", f"copied shared test_data {shared_test_data_dir} -> {target_shared_test_data_dir}", str(shared_test_data_dir), ) def recover_target_root_calib( source_root: Path, target_root: Path, dry_run: bool, ) -> tuple[str, str, str | None]: recovery = recover_camera4_json( source_root=source_root, target_root=target_root, dry_run=dry_run, ) return recovery.status, recovery.detail, None if recovery.source_path is None else str(recovery.source_path) def _expected_pdcl_root_dir(output_dir: Path, ref: str) -> Path: if "::" not in ref: raise ValueError(f"Unexpected PDCL ref without '::': {ref}") return output_dir / ref.split("::", 1)[1] def _prune_pdcl_download_to_selected_subpath( root_dir: Path, selected_subpath: str, dry_run: bool, ) -> tuple[str, str]: selected_rel = Path(selected_subpath) selected_path = root_dir / selected_rel if dry_run: return ( "planned_selected_subpath", f"would keep {selected_path} and shared test_data under {root_dir}", ) if not selected_path.exists(): return ( "failed_selected_subpath_missing", f"selected subpath not found after mdi download: {selected_path}", ) keep_names = {selected_rel.parts[0], "test_data"} removed_children: list[str] = [] for child in root_dir.iterdir(): if child.name in keep_names: continue removed_children.append(child.name) remove_existing_target(child) detail = f"kept selected subpath {selected_path}" if removed_children: detail += f"; removed siblings: {', '.join(sorted(removed_children))}" return "downloaded_selected_subpath", detail def run_mdi_download(request: PDCLDownloadRequest, output_dir: Path, dry_run: bool) -> tuple[str, str, list[str]]: command = ["mdi", "raw", "-r", request.normalized_ref, "-s", str(output_dir)] if dry_run: if request.selected_subpath: root_dir = _expected_pdcl_root_dir(output_dir, request.normalized_ref) status, detail = _prune_pdcl_download_to_selected_subpath(root_dir, request.selected_subpath, dry_run=True) return status, f"would run {' '.join(command)}; {detail}", command return "planned", f"would run {' '.join(command)}", command ensure_dir(output_dir, dry_run=False) completed = subprocess.run( command, check=False, capture_output=True, text=True, encoding="utf-8", ) if completed.returncode == 0: base_detail = completed.stdout.strip() or "mdi raw completed" if request.selected_subpath: root_dir = _expected_pdcl_root_dir(output_dir, request.normalized_ref) status, detail = _prune_pdcl_download_to_selected_subpath(root_dir, request.selected_subpath, dry_run=False) return status, f"{base_detail}\n{detail}", command return "downloaded", base_detail, command detail = completed.stderr.strip() or completed.stdout.strip() or "mdi raw failed" return "failed", detail, command def process_issue( item: dict, output_root: Path, dry_run: bool, skip_mdi: bool, skip_copy: bool, only_redownload_affected_cases: bool, skip_calib_recovery: bool, ) -> list[ActionResult]: issue_id = int(item["id"]) issue_name = str(item["name"]) issue_dir = output_root / normalize_issue_dirname(issue_id) results: list[ActionResult] = [] seen_pdcl_requests: set[tuple[str, str | None]] = set() seen_paths: set[str] = set() pdcl_index = 0 path_index = 0 for field_name, raw_value in iter_issue_fields(item): if not skip_mdi and not only_redownload_affected_cases: for request in extract_pdcl_requests(raw_value): request_key = (request.normalized_ref, request.selected_subpath) if request_key in seen_pdcl_requests: results.append( ActionResult( issue_id=issue_id, issue_name=issue_name, source_field=field_name, source_kind="pdcl_mdi_download", raw_value=None if raw_value is None else str(raw_value), normalized_ref=request.normalized_ref, output_dir=str(issue_dir), status="skipped_duplicate", detail=( f"duplicate PDCL ref: {request.normalized_ref}" if request.selected_subpath is None else f"duplicate PDCL ref+subpath: {request.normalized_ref} / {request.selected_subpath}" ), selected_subpath=request.selected_subpath, ) ) continue seen_pdcl_requests.add(request_key) pdcl_index += 1 download_dir = issue_dir / f"pdcl_{pdcl_index:02d}" request_desc = request.normalized_ref if request.selected_subpath: request_desc += f"/{request.selected_subpath}" log_progress( f"issue_{issue_id} [download] pdcl_{pdcl_index:02d}: {compact_text(request_desc)}" ) status, detail, command = run_mdi_download(request, download_dir, dry_run=dry_run) results.append( ActionResult( issue_id=issue_id, issue_name=issue_name, source_field=field_name, source_kind="pdcl_mdi_download", raw_value=None if raw_value is None else str(raw_value), normalized_ref=request.normalized_ref, output_dir=str(download_dir), status=status, detail=detail, command=command, selected_subpath=request.selected_subpath, ) ) if skip_copy: continue for raw_path in extract_standard_paths(raw_value): if raw_path in seen_paths: results.append( ActionResult( issue_id=issue_id, issue_name=issue_name, source_field=field_name, source_kind="standard_path", raw_value=raw_path, normalized_ref=None, output_dir=str(issue_dir), status="skipped_duplicate", detail=f"duplicate standard path: {raw_path}", ) ) continue seen_paths.add(raw_path) path_index += 1 copy_dir = issue_dir / f"path_{path_index:02d}" affected_standard_path = is_affected_standard_path(raw_path) if only_redownload_affected_cases and not affected_standard_path: continue log_progress( f"issue_{issue_id} [download] path_{path_index:02d}: {compact_text(raw_path)}" ) resolved_source_path, candidates = resolve_existing_path(raw_path) if resolved_source_path is None: results.append( ActionResult( issue_id=issue_id, issue_name=issue_name, source_field=field_name, source_kind="standard_path", raw_value=raw_path, normalized_ref=None, output_dir=str(copy_dir), status="skipped_missing", detail="source path not found after rewrite attempts", candidate_paths=[str(candidate) for candidate in candidates], ) ) continue legacy_target_names = [] if affected_standard_path: legacy_target_names.append(Path(raw_path.strip()).name) status, detail = copy_source_path( resolved_source_path, copy_dir, dry_run=dry_run, replace_existing=only_redownload_affected_cases and affected_standard_path, legacy_target_names=legacy_target_names, ) target_root = build_copied_target_root(copy_dir, resolved_source_path) results.append( ActionResult( issue_id=issue_id, issue_name=issue_name, source_field=field_name, source_kind="standard_path", raw_value=raw_path, normalized_ref=None, output_dir=str(copy_dir), status=status, detail=detail, resolved_source_path=str(resolved_source_path), candidate_paths=[str(candidate) for candidate in candidates], ) ) sync_status, sync_detail, shared_source_dir = sync_shared_test_data( resolved_source_path, target_root, dry_run=dry_run, ) if sync_status is not None: results.append( ActionResult( issue_id=issue_id, issue_name=issue_name, source_field=field_name, source_kind="shared_test_data", raw_value=raw_path, normalized_ref=None, output_dir=str(target_root / "test_data"), status=sync_status, detail=sync_detail or "", resolved_source_path=shared_source_dir, candidate_paths=[str(candidate) for candidate in candidates], ) ) if not skip_calib_recovery: calib_status, calib_detail, calib_source_path = recover_target_root_calib( source_root=resolved_source_path, target_root=target_root, dry_run=dry_run, ) results.append( ActionResult( issue_id=issue_id, issue_name=issue_name, source_field=field_name, source_kind="case_calib_recovery", raw_value=raw_path, normalized_ref=None, output_dir=str(target_root / "test_data" / "calibs"), status=calib_status, detail=calib_detail, resolved_source_path=calib_source_path, candidate_paths=[str(candidate) for candidate in candidates], ) ) return results def build_manifest( args: argparse.Namespace, input_json: Path, output_root: Path, action_results: list[ActionResult], ) -> dict: summary: dict[str, int] = {} for action in action_results: summary[action.status] = summary.get(action.status, 0) + 1 return { "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"), "input_json": str(input_json), "output_root": str(output_root), "dry_run": args.dry_run, "skip_mdi": args.skip_mdi, "skip_copy": args.skip_copy, "skip_calib_recovery": args.skip_calib_recovery, "only_redownload_affected_cases": args.only_redownload_affected_cases, "issue_filter": args.issue_ids or [], "summary": summary, "actions": [action.to_dict() for action in action_results], } def print_summary(manifest: dict) -> None: print(f"input_json: {manifest['input_json']}") print(f"output_root: {manifest['output_root']}") print(f"dry_run: {manifest['dry_run']}") for status, count in sorted(manifest["summary"].items()): print(f"{status}: {count}") def main() -> int: args = parse_args() input_json = Path(args.input_json).resolve() output_root = Path(args.output_root).resolve() manifest_path = ( Path(args.manifest_path).resolve() if args.manifest_path else output_root / "download_manifest.json" ) items = load_issue_items(input_json) if args.issue_ids: issue_filter = set(args.issue_ids) items = [item for item in items if int(item["id"]) in issue_filter] ensure_dir(output_root, dry_run=args.dry_run) action_results: list[ActionResult] = [] total_items = len(items) log_progress(f"issues_to_process: {total_items}") for index, item in enumerate(items, start=1): issue_id = int(item["id"]) issue_name = compact_text(item.get("name"), max_len=64) log_progress(f"[{index}/{total_items}] issue_{issue_id} start: {issue_name}") issue_results = process_issue( item=item, output_root=output_root, dry_run=args.dry_run, skip_mdi=args.skip_mdi, skip_copy=args.skip_copy, only_redownload_affected_cases=args.only_redownload_affected_cases, skip_calib_recovery=args.skip_calib_recovery, ) action_results.extend(issue_results) log_progress( f"[{index}/{total_items}] issue_{issue_id} done: {summarize_issue_results(issue_results)}" ) manifest = build_manifest(args, input_json, output_root, action_results) ensure_dir(manifest_path.parent, dry_run=args.dry_run) if not args.dry_run: manifest_path.write_text( json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) print_summary(manifest) if args.dry_run: print(f"manifest (not written in dry-run): {manifest_path}") else: print(f"manifest: {manifest_path}") return 0 if __name__ == "__main__": sys.exit(main())