Skip to content

Instantly share code, notes, and snippets.

@yosshi4486
Created December 27, 2025 01:28
Show Gist options
  • Select an option

  • Save yosshi4486/49ababce3bf99cf77ae03e5d9d55aaf3 to your computer and use it in GitHub Desktop.

Select an option

Save yosshi4486/49ababce3bf99cf77ae03e5d9d55aaf3 to your computer and use it in GitHub Desktop.
Python script for extracting images from a xcresult. (written by codex)
#!/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