Created
December 27, 2025 01:28
-
-
Save yosshi4486/49ababce3bf99cf77ae03e5d9d55aaf3 to your computer and use it in GitHub Desktop.
Python script for extracting images from a xcresult. (written by codex)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import re | |
| import subprocess | |
| import sys | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Any | |
| @dataclass(frozen=True) | |
| class ExportedAttachment: | |
| test_identifier: str | None | |
| exported_file_name: str | |
| suggested_human_readable_name: str | None | |
| is_associated_with_failure: bool | None | |
| _INVALID_FILENAME_CHARS = re.compile(r"[^A-Za-z0-9._-]+") | |
| _IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".heic"} | |
| MANIFEST_FILE = "manifest.json" | |
| def _run_xcresult_export(xcresult: Path, out_dir: Path, only_failures: bool) -> None: | |
| cmd = [ | |
| "xcrun", | |
| "xcresulttool", | |
| "export", | |
| "attachments", | |
| "--path", | |
| str(xcresult), | |
| "--output-path", | |
| str(out_dir), | |
| ] | |
| if only_failures: | |
| cmd.append("--only-failures") | |
| subprocess.run(cmd, check=True) | |
| def _load_manifest(path: Path) -> Any: | |
| if not path.exists(): | |
| raise RuntimeError(f"manifest.json が生成されませんでした: {path}") | |
| manifest = json.loads(path.read_text(encoding="utf-8")) | |
| if not isinstance(manifest, list): | |
| raise RuntimeError("manifest.json の形式が想定外です(list ではありません)") | |
| return manifest | |
| def _build_attachments(manifest: Any) -> list[ExportedAttachment]: | |
| attachments: list[ExportedAttachment] = [] | |
| for entry in manifest: | |
| if not isinstance(entry, dict): | |
| continue | |
| test_identifier = entry.get("testIdentifier") | |
| if not isinstance(test_identifier, str) or not test_identifier: | |
| test_identifier = None | |
| for att in entry.get("attachments", []): | |
| parsed = _parse_attachment(test_identifier, att) | |
| if parsed: | |
| attachments.append(parsed) | |
| return attachments | |
| def _parse_attachment(test_identifier: str | None, att: Any) -> ExportedAttachment | None: | |
| if not isinstance(att, dict): | |
| return None | |
| exported_file_name = att.get("exportedFileName") | |
| if not isinstance(exported_file_name, str) or not exported_file_name: | |
| return None | |
| suggested = att.get("suggestedHumanReadableName") | |
| is_failure = att.get("isAssociatedWithFailure") | |
| return ExportedAttachment( | |
| test_identifier=test_identifier, | |
| exported_file_name=exported_file_name, | |
| suggested_human_readable_name=suggested if isinstance(suggested, str) else None, | |
| is_associated_with_failure=is_failure if isinstance(is_failure, bool) else None, | |
| ) | |
| def _cleanup_manifest(path: Path) -> None: | |
| try: | |
| path.unlink() | |
| except OSError: | |
| pass | |
| # Basically, ID-based image names are hard to read, so this method renames files based on the test name. | |
| def rename_with_testcase_names(out_dir: Path, attachments: list[ExportedAttachment]) -> None: | |
| used_names: set[str] = set() | |
| for att in attachments: | |
| target = _candidate_testcase_target(out_dir, att, used_names) | |
| if not target: | |
| continue | |
| src = out_dir / att.exported_file_name | |
| if target == src: | |
| used_names.add(target.name) | |
| continue | |
| try: | |
| src.rename(target) | |
| used_names.add(target.name) | |
| except OSError: | |
| continue | |
| return | |
| def _candidate_testcase_target(out_dir: Path, att: ExportedAttachment, used_names: set[str]) -> Path | None: | |
| if not att.test_identifier: | |
| return None | |
| src = out_dir / att.exported_file_name | |
| if not src.exists(): | |
| return None | |
| # まずは「テストケース名(末尾)」を優先してファイル名にする | |
| # 例: DemoAppPreviewTest/portrait-Content View-0-0() -> portrait-Content View-0-0() | |
| leaf = att.test_identifier.split("/")[-1] | |
| leaf_sanitized = _sanitize_filename_component(leaf) | |
| full_sanitized = _sanitize_filename_component(att.test_identifier.replace("/", "__")) | |
| ext = _resolve_extension(src.suffix, att.suggested_human_readable_name or "") | |
| for base in (leaf_sanitized, full_sanitized): | |
| if not base: | |
| continue | |
| target = _unique_target(out_dir, base, ext, src, used_names) | |
| if target: | |
| return target | |
| return None | |
| def _unique_target( | |
| out_dir: Path, | |
| base: str, | |
| ext: str, | |
| src: Path, | |
| used_names: set[str], | |
| ) -> Path | None: | |
| candidate = f"{base}{ext}" | |
| target = out_dir / candidate | |
| counter = 1 | |
| while candidate in used_names or (target.exists() and target != src): | |
| candidate = f"{base}_{counter}{ext}" | |
| counter += 1 | |
| target = out_dir / candidate | |
| return target | |
| # 拡張子を解決する | |
| def _resolve_extension(current_suffix: str, suggested: str) -> str: | |
| ext = (current_suffix or "").lower() | |
| if not ext: | |
| ext = ".png" | |
| suggested_suffix = Path(suggested).suffix.lower() | |
| if suggested_suffix in _IMAGE_SUFFIXES: | |
| ext = suggested_suffix | |
| if not ext: | |
| ext = ".png" | |
| return ext | |
| def _sanitize_filename_component(name: str) -> str: | |
| sanitized = _INVALID_FILENAME_CHARS.sub("_", name.strip()) | |
| return sanitized.strip("._") | |
| # 画像の抽出とリネームを行う | |
| def extract_and_rename( | |
| xcresult: Path, | |
| out_dir: Path, | |
| *, | |
| only_failures: bool, | |
| keep_manifest: bool, | |
| ) -> list[ExportedAttachment]: | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| _run_xcresult_export(xcresult, out_dir, only_failures) | |
| manifest_path = out_dir / MANIFEST_FILE | |
| manifest = _load_manifest(manifest_path) | |
| attachments = _build_attachments(manifest) | |
| rename_with_testcase_names(out_dir, attachments) | |
| if not keep_manifest: | |
| _cleanup_manifest(manifest_path) | |
| return attachments | |
| def main() -> int: | |
| parser = argparse.ArgumentParser( | |
| description="xcresult から attachments を抽出します(xcresulttool export attachments の薄いラッパー)" | |
| ) | |
| parser.add_argument("xcresult", type=Path) | |
| parser.add_argument("--out", type=Path, default=Path(".")) | |
| parser.add_argument("--only-failures", action="store_true") | |
| parser.add_argument( | |
| "--keep-manifest", | |
| action="store_true", | |
| help="export attachments が生成した manifest.json を残します(デフォルトは削除)", | |
| ) | |
| args = parser.parse_args() | |
| if not args.xcresult.exists(): | |
| print(f"xcresult が見つかりません: {args.xcresult}", file=sys.stderr) | |
| return 2 | |
| attachments = extract_and_rename( | |
| args.xcresult, | |
| args.out, | |
| only_failures=args.only_failures, | |
| keep_manifest=args.keep_manifest, | |
| ) | |
| print(f"抽出したattachmentsの画像: {len(attachments)} 件(出力先: {args.out})") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment