e904b667c6
Build/Publish Develop Docs / deploy (push) Failing after 1s
PaddleOCR Code Style Check / check-code-style (push) Failing after 1s
PaddleOCR PR Tests GPU / detect-changes (push) Failing after 1s
PaddleOCR PR Tests / detect-changes (push) Failing after 1s
PaddleOCR PR Tests GPU / test-pr-gpu (push) Has been cancelled
PaddleOCR PR Tests / test-pr (push) Has been cancelled
PaddleOCR PR Tests GPU / test-pr-gpu-impl (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.13) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.8) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.9) (push) Has been cancelled
381 lines
13 KiB
Python
Executable File
381 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
# Copyright (c) 2026 PaddlePaddle Authors. All Rights Reserved.
|
||
#
|
||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||
# you may not use this file except in compliance with the License.
|
||
# You may obtain a copy of the License at
|
||
#
|
||
# http://www.apache.org/licenses/LICENSE-2.0
|
||
#
|
||
# Unless required by applicable law or agreed to in writing, software
|
||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
# See the License for the specific language governing permissions and
|
||
# limitations under the License.
|
||
|
||
"""Extract named XCTAttachments from an .xcresult bundle into files.
|
||
|
||
Uses `xcrun xcresulttool` only. Prerequisites are probed against the **currently
|
||
selected** Xcode (`xcode-select`): see `_check_xcresulttool_capability`.
|
||
|
||
**Discovery (documented CLI behavior)**
|
||
|
||
- ``xcresulttool get test-results tests`` — “Get all tests from test report.”
|
||
(``xcrun xcresulttool help get test-results tests``). We walk Test Case nodes
|
||
and read each ``nodeIdentifierURL`` as ``--test-id``.
|
||
|
||
- ``xcresulttool get test-results activities`` — “Get the activity trees for the
|
||
specified test.” (``help get test-results activities``). Attachment records
|
||
(``name``, ``payloadId``) are taken from activity trees under ``testRuns``.
|
||
|
||
- ``xcresulttool export attachments`` — “An additional manifest.json file will be
|
||
generated… contains a list of attachment details for each test.”
|
||
(``help export attachments``). We resolve on-disk filenames via
|
||
``exportedFileName`` / ``suggestedHumanReadableName`` in that manifest when
|
||
present.
|
||
|
||
**Logical filename matching (repo heuristic, not from Apple help)**
|
||
|
||
We match ``--name`` to each activities record’s ``name`` by **exact string**, or
|
||
by **stem + underscore-suffixed** basename before the extension. Adjust
|
||
``_matches_wanted_attachment`` if your tests use different attachment titles.
|
||
|
||
If ``activities`` yields no attachments, we fall back to walking the ``tests``
|
||
JSON for ``attachments`` on nodes (empty after the activities pass in some
|
||
bundles).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
from pathlib import Path
|
||
from typing import Any, List, Optional, Set, Tuple
|
||
|
||
AttachmentRow = Tuple[str, str, str] # stored_name, test_id_url, payload_id
|
||
|
||
|
||
def _die(what: str, where: str, next_step: str, code: int = 1) -> int:
|
||
sys.stderr.write(
|
||
f"[extract_xcresult_attachments] FAIL: {what}\n Where: {where}\n Next: {next_step}\n"
|
||
)
|
||
return code
|
||
|
||
|
||
def _run(cmd: List[str]) -> subprocess.CompletedProcess:
|
||
return subprocess.run(cmd, capture_output=True, check=False)
|
||
|
||
|
||
def _check_xcresulttool_capability() -> None:
|
||
"""Require `xcrun xcresulttool help get` to list `test-results` (command exists)."""
|
||
r = _run(["xcrun", "xcresulttool", "help", "get"])
|
||
text = (r.stdout + r.stderr).decode(errors="replace")
|
||
if "test-results" not in text:
|
||
raise SystemExit(
|
||
_die(
|
||
"`xcrun xcresulttool help get` has no `test-results` subcommand in this Xcode.",
|
||
"xcrun xcresulttool help get",
|
||
"Install a newer Xcode or `xcode-select` a toolchain that ships this subcommand.",
|
||
)
|
||
)
|
||
|
||
|
||
def _load_tests_json(result_path: Path) -> dict:
|
||
r = _run(
|
||
[
|
||
"xcrun",
|
||
"xcresulttool",
|
||
"get",
|
||
"test-results",
|
||
"tests",
|
||
"--path",
|
||
str(result_path),
|
||
"--format",
|
||
"json",
|
||
]
|
||
)
|
||
if r.returncode != 0:
|
||
raise SystemExit(
|
||
_die(
|
||
f"xcresulttool get test-results failed with exit {r.returncode}.",
|
||
str(result_path),
|
||
"Confirm the .xcresult was produced by a completed `xcodebuild test`.",
|
||
)
|
||
)
|
||
try:
|
||
return json.loads(r.stdout or b"{}")
|
||
except json.JSONDecodeError as e:
|
||
raise SystemExit(
|
||
_die(
|
||
f"Could not parse xcresulttool JSON: {e}.",
|
||
str(result_path),
|
||
"Re-run xcodebuild test to regenerate the .xcresult.",
|
||
)
|
||
)
|
||
|
||
|
||
def _collect_test_case_urls(node: dict) -> List[str]:
|
||
urls: List[str] = []
|
||
if node.get("nodeType") == "Test Case":
|
||
u = node.get("nodeIdentifierURL")
|
||
if isinstance(u, str) and u.startswith("test://"):
|
||
urls.append(u)
|
||
for ch in node.get("children") or []:
|
||
urls.extend(_collect_test_case_urls(ch))
|
||
return urls
|
||
|
||
|
||
def _walk_activity_tree(act: dict, acc: List[AttachmentRow], test_url: str) -> None:
|
||
for att in act.get("attachments") or []:
|
||
name = att.get("name")
|
||
pid = att.get("payloadId")
|
||
if isinstance(name, str) and isinstance(pid, str):
|
||
acc.append((name, test_url, pid))
|
||
for ch in act.get("childActivities") or []:
|
||
_walk_activity_tree(ch, acc, test_url)
|
||
|
||
|
||
def _attachments_from_activities(
|
||
result_path: Path, test_url: str
|
||
) -> List[AttachmentRow]:
|
||
r = _run(
|
||
[
|
||
"xcrun",
|
||
"xcresulttool",
|
||
"get",
|
||
"test-results",
|
||
"activities",
|
||
"--path",
|
||
str(result_path),
|
||
"--test-id",
|
||
test_url,
|
||
"--format",
|
||
"json",
|
||
]
|
||
)
|
||
if r.returncode != 0:
|
||
return []
|
||
try:
|
||
parsed = json.loads(r.stdout or b"{}")
|
||
except json.JSONDecodeError:
|
||
return []
|
||
acc: List[AttachmentRow] = []
|
||
for tr in parsed.get("testRuns") or []:
|
||
for act in tr.get("activities") or []:
|
||
_walk_activity_tree(act, acc, test_url)
|
||
return acc
|
||
|
||
|
||
def _walk_attachments_legacy(node: dict, acc: List[AttachmentRow]) -> None:
|
||
"""Fallback: walk ``tests`` JSON for ``attachments`` on nodes (used if activities path is empty)."""
|
||
identifier = node.get("identifier") or node.get("nodeIdentifierURL") or ""
|
||
for att in node.get("attachments") or []:
|
||
name = att.get("name")
|
||
pid = att.get("payloadId")
|
||
if isinstance(name, str) and isinstance(pid, str):
|
||
acc.append((name, str(identifier), pid))
|
||
for child in node.get("children") or []:
|
||
_walk_attachments_legacy(child, acc)
|
||
|
||
|
||
def _enumerate(result_path: Path) -> List[AttachmentRow]:
|
||
parsed = _load_tests_json(result_path)
|
||
acc: List[AttachmentRow] = []
|
||
urls: List[str] = []
|
||
for top in parsed.get("testNodes") or []:
|
||
urls.extend(_collect_test_case_urls(top))
|
||
for url in urls:
|
||
acc.extend(_attachments_from_activities(result_path, url))
|
||
if acc:
|
||
return acc
|
||
for top in parsed.get("testNodes") or []:
|
||
_walk_attachments_legacy(top, acc)
|
||
return acc
|
||
|
||
|
||
def _matches_wanted_attachment(stored: str, wanted: str) -> bool:
|
||
if stored == wanted:
|
||
return True
|
||
stem = Path(wanted).stem
|
||
suffix = Path(wanted).suffix if Path(wanted).suffix else ".json"
|
||
return stored.startswith(stem + "_") and stored.endswith(suffix)
|
||
|
||
|
||
def _pick_exported_file(
|
||
td_path: Path, wanted_out_name: str, stored_name: str
|
||
) -> Optional[Path]:
|
||
"""Pick the exported file using ``manifest.json`` when `xcresulttool export` writes it (see ``help export``)."""
|
||
manifest_path = td_path / "manifest.json"
|
||
if manifest_path.is_file():
|
||
try:
|
||
raw: Any = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||
except json.JSONDecodeError:
|
||
raw = []
|
||
blocks: List[dict] = raw if isinstance(raw, list) else []
|
||
for block in blocks:
|
||
for att in block.get("attachments") or []:
|
||
if not isinstance(att, dict):
|
||
continue
|
||
sug = att.get("suggestedHumanReadableName") or ""
|
||
fn = att.get("exportedFileName")
|
||
if not isinstance(fn, str):
|
||
continue
|
||
candidate = td_path / fn
|
||
if not candidate.is_file():
|
||
continue
|
||
if sug == stored_name or _matches_wanted_attachment(
|
||
sug, wanted_out_name
|
||
):
|
||
return candidate
|
||
|
||
direct = td_path / stored_name
|
||
if direct.is_file():
|
||
return direct
|
||
|
||
for p in sorted(td_path.iterdir()):
|
||
if not p.is_file() or p.name == "manifest.json":
|
||
continue
|
||
if _matches_wanted_attachment(p.name, wanted_out_name):
|
||
return p
|
||
|
||
orphans = [
|
||
p
|
||
for p in td_path.iterdir()
|
||
if p.is_file() and p.suffix == ".json" and p.name != "manifest.json"
|
||
]
|
||
if len(orphans) == 1:
|
||
return orphans[0]
|
||
|
||
return None
|
||
|
||
|
||
def _export_one(
|
||
result_path: Path,
|
||
test_identifier: str,
|
||
wanted_out_name: str,
|
||
stored_name: str,
|
||
out_path: Path,
|
||
) -> None:
|
||
with tempfile.TemporaryDirectory() as td:
|
||
r = _run(
|
||
[
|
||
"xcrun",
|
||
"xcresulttool",
|
||
"export",
|
||
"attachments",
|
||
"--path",
|
||
str(result_path),
|
||
"--test-id",
|
||
test_identifier,
|
||
"--output-path",
|
||
td,
|
||
]
|
||
)
|
||
if r.returncode != 0:
|
||
raise SystemExit(
|
||
_die(
|
||
f"xcresulttool export attachments failed with exit {r.returncode}.",
|
||
f"test-id={test_identifier}",
|
||
"Inspect stderr above.",
|
||
)
|
||
)
|
||
td_path = Path(td)
|
||
src = _pick_exported_file(td_path, wanted_out_name, stored_name)
|
||
if src is None or not src.is_file():
|
||
raise SystemExit(
|
||
_die(
|
||
f"Could not resolve exported file (wanted {wanted_out_name!r}, stored {stored_name!r}).",
|
||
f"export dir: {td}",
|
||
"Check `manifest.json` and `xcresulttool export attachments` stderr.",
|
||
)
|
||
)
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.move(str(src), str(out_path))
|
||
|
||
|
||
def main(argv: List[str] | None = None) -> int:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument(
|
||
"--result", required=True, type=Path, help="Path to .xcresult bundle."
|
||
)
|
||
parser.add_argument(
|
||
"--output-dir",
|
||
required=True,
|
||
type=Path,
|
||
help="Directory to write exported attachments into.",
|
||
)
|
||
parser.add_argument(
|
||
"--name",
|
||
action="append",
|
||
default=[],
|
||
help="Attachment name to extract (may be repeated). Fails the run if missing.",
|
||
)
|
||
parser.add_argument(
|
||
"--optional-name",
|
||
action="append",
|
||
default=[],
|
||
dest="optional_name",
|
||
help="Like --name, but a missing attachment is skipped with a message (exit still 0).",
|
||
)
|
||
args = parser.parse_args(argv)
|
||
if not args.name and not args.optional_name:
|
||
parser.error("at least one of --name or --optional-name is required")
|
||
|
||
_check_xcresulttool_capability()
|
||
|
||
if not args.result.exists():
|
||
return _die(
|
||
".xcresult bundle does not exist.",
|
||
str(args.result),
|
||
"Run `xcodebuild test -resultBundlePath <path>` first.",
|
||
)
|
||
|
||
attachments = _enumerate(args.result)
|
||
|
||
errors = 0
|
||
|
||
def _extract_wanted(wanted: str, optional: bool) -> None:
|
||
nonlocal errors
|
||
hits = [
|
||
(tid, sn)
|
||
for sn, tid, _pid in attachments
|
||
if _matches_wanted_attachment(sn, wanted)
|
||
]
|
||
uniq: Set[Tuple[str, str]] = set(hits)
|
||
if len(uniq) == 0:
|
||
if optional:
|
||
sys.stderr.write(
|
||
f"[extract_xcresult_attachments] optional attachment not found, skipping: {wanted!r}\n"
|
||
)
|
||
return
|
||
errors += _die(
|
||
f"Attachment not found: {wanted}.",
|
||
str(args.result),
|
||
"Confirm the test run attached JSON and inspect `get test-results activities` / `export attachments` for this bundle.",
|
||
)
|
||
return
|
||
if len(uniq) > 1:
|
||
errors += _die(
|
||
f"Attachment name collision: {wanted} matches {len(uniq)} exports.",
|
||
str(args.result),
|
||
"Ensure attachment base names are unique across tests.",
|
||
)
|
||
return
|
||
test_id, stored = next(iter(uniq))
|
||
_export_one(args.result, test_id, wanted, stored, args.out_dir / wanted)
|
||
|
||
for wanted in args.name:
|
||
_extract_wanted(wanted, optional=False)
|
||
for wanted in args.optional_name:
|
||
_extract_wanted(wanted, optional=True)
|
||
|
||
return 1 if errors else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|