char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Max Krasnyanskysnapdragon: ci updates to use new run script (#28293)d30500b83

char/aliceai-t5-moe
22.8 KiB663 linesraw
1"""Run llama.cpp Hexagon tests in a single QDC job.
2
3Bundles test scripts into one artifact and submits a single QDC job:
4
5  1. run_bench_tests_posix.py — llama-cli and llama-bench on CPU / GPU / NPU
6                                (from scripts/snapdragon/qdc/)
7
8Results are written to $GITHUB_STEP_SUMMARY when set (GitHub Actions).
9
10Prerequisites:
11  pip install /path/to/qualcomm_device_cloud_sdk*.whl
12
13Platform is inferred from --device:
14  android  Appium + pytest (Android phones: SM8750 / SM8650 / SM8850)
15  linux    BASH (Linux IoT: QCS9075M)
16
17Required environment variables:
18  QDC_API_KEY   API key from QDC UI -> Users -> Settings -> API Keys
19
20Usage:
21  python run_qdc_jobs.py \\
22      --pkg-dir    pkg-snapdragon/llama.cpp \\
23      --model-url  https://.../Llama-3.2-1B-Instruct-Q4_0.gguf \\
24      --device     SM8750
25"""
26
27from __future__ import annotations
28
29import argparse
30import enum
31import logging
32import os
33import re
34import shutil
35import sys
36import tempfile
37import time
38import xml.etree.ElementTree as ET
39from dataclasses import dataclass, field
40from pathlib import Path
41from typing import Callable
42
43from qualcomm_device_cloud_sdk.api import qdc_api
44from qualcomm_device_cloud_sdk.logging import configure_logging
45from qualcomm_device_cloud_sdk.models import (
46    ArtifactType,
47    JobMode,
48    JobState,
49    JobSubmissionParameter,
50    JobType,
51    TestFramework,
52)
53
54# configure_logging only sets up the SDK logger; basicConfig is needed for
55# our own log.info to reach stdout.
56logging.basicConfig(
57    level=logging.INFO,
58    format="%(asctime)s %(name)s %(levelname)s - %(message)s",
59    handlers=[logging.StreamHandler()],
60)
61configure_logging(level=logging.INFO, handlers=[logging.StreamHandler()])
62# Silence per-poll GET/status spam from the SDK and its HTTP client.
63logging.getLogger("qualcomm_device_cloud").setLevel(logging.WARNING)
64logging.getLogger("httpx").setLevel(logging.WARNING)
65logging.getLogger("httpcore").setLevel(logging.WARNING)
66log = logging.getLogger(__name__)
67
68POLL_INTERVAL        = 30
69JOB_TIMEOUT          = 3600
70LOG_UPLOAD_TIMEOUT   = 600
71CAPACITY_TIMEOUT     = 1800
72CAPACITY_POLL        = 60
73MAX_CONCURRENT_JOBS  = 5
74DEFAULT_RETRIES      = 0
75RETRY_DELAY          = 300
76TERMINAL_STATES     = {JobState.COMPLETED, JobState.CANCELED}
77NON_TERMINAL_STATES = {JobState.DISPATCHED, JobState.RUNNING, JobState.SETUP, JobState.SUBMITTED}
78
79
80class DeviceUnavailableError(Exception):
81    """Raised when the QDC device resource is not available (retryable)."""
82
83
84_SCRIPTS_DIR = Path(__file__).parent
85_TESTS_DIR = _SCRIPTS_DIR / "tests"
86
87# --- Shared test assets -------------------------------------------------------
88_UTILS = _TESTS_DIR / "utils.py"
89_CONFTEST = _TESTS_DIR / "conftest.py"
90_PYTEST_LINE_RE = re.compile(
91    r"(?:[\w/]+\.py::)?(?:\w+::)?([\w\[\].-]+)\s+(PASSED|FAILED|ERROR|SKIPPED)"
92)
93_EXCLUDED_LOGS = {
94    "qdc_android_whole_host-000.log",
95    "qdc_kernel_host-000.log",
96    "qdc_LE_whole_host-000.log",
97    "qdc_LE_kernel_host-000.log",
98    "script.log",
99}
100_NON_TERMINAL_STATE_VALUES = {s.value for s in NON_TERMINAL_STATES}
101
102# --- Android (Appium + pytest) assets ----------------------------------------
103_RUN_BENCH = _TESTS_DIR / "run_bench_tests_posix.py"
104_RUN_BACKEND_OPS = _TESTS_DIR / "run_backend_ops_posix.py"
105_REQUIREMENTS = _SCRIPTS_DIR / "requirements.txt"
106_RUN_PY = _SCRIPTS_DIR.parent / "run.py"
107
108# --- Linux (BASH) assets ------------------------------------------------------
109_RUN_LINUX_TEMPLATE = _TESTS_DIR / "linux" / "run_linux.sh"
110_LINUX_ENTRY_SCRIPT = "/bin/bash /data/local/tmp/TestContent/run_linux.sh"
111
112# =============================================================================
113# Artifact builders (per platform)
114# =============================================================================
115
116
117@dataclass
118class JobResult:
119    passed: bool
120    tests: dict[str, bool] = field(default_factory=dict)
121    raw_logs: dict[str, str] = field(default_factory=dict)
122    failure_details: dict[str, str] = field(default_factory=dict)
123
124
125def _write_lf(path: Path, content: str) -> None:
126    """Write text with LF line endings (required by /bin/bash on Linux)."""
127    with open(path, "w", encoding="utf-8", newline="\n") as f:
128        f.write(content)
129
130
131def _build_android_artifact(
132    pkg_dir: Path,
133    stage_dir: Path,
134    test_mode: str,
135    model_url: str | None,
136) -> Path:
137    """Android zip (Appium/pytest). Extracted by QDC under /qdc/appium/.
138
139    Zip structure:
140      llama_cpp_bundle/            installed package (adb pushed to /data/local/tmp/)
141      run.py                       Snapdragon runner
142      tests/
143        utils.py                   shared adb helpers
144        conftest.py                Appium pytest fixtures
145        test_bench_posix.py        bench + cli tests (for --test bench or all)
146        test_backend_ops_posix.py  test-backend-ops on HTP0
147      requirements.txt
148      pytest.ini                   addopts = --junitxml=results.xml
149    """
150    bundle_dir = stage_dir / "llama_cpp_bundle"
151    shutil.copytree(pkg_dir, bundle_dir)
152
153    dest = stage_dir / "run.py"
154    shutil.copy(_RUN_PY, dest)
155    dest.chmod(0o755)
156
157    tests_dir = stage_dir / "tests"
158    tests_dir.mkdir()
159
160    shutil.copy(_UTILS, tests_dir / "utils.py")
161    shutil.copy(_CONFTEST, tests_dir / "conftest.py")
162
163    if test_mode in ("bench", "all"):
164        assert model_url is not None
165        (tests_dir / "test_bench_posix.py").write_text(
166            _RUN_BENCH.read_text().replace("<<MODEL_URL>>", model_url)
167        )
168    if test_mode in ("backend-ops", "all"):
169        shutil.copy(_RUN_BACKEND_OPS, tests_dir / "test_backend_ops_posix.py")
170
171    shutil.copy(_REQUIREMENTS, stage_dir / "requirements.txt")
172    (stage_dir / "pytest.ini").write_text(
173        "[pytest]\naddopts = --junitxml=results.xml\n"
174    )
175
176    zip_base = str(stage_dir / "artifact")
177    shutil.make_archive(zip_base, "zip", stage_dir)
178    return Path(f"{zip_base}.zip")
179
180
181def _build_linux_artifact(
182    pkg_dir: Path,
183    stage_dir: Path,
184    test_mode: str,
185    model_url: str | None,
186) -> Path:
187    """Linux IoT zip (BASH framework). Extracted by QDC to /data/local/tmp/TestContent/.
188
189    Zip structure:
190      run_linux.sh               entry script (placeholder-substituted, LF line endings)
191      llama_cpp_bundle/          installed package
192    """
193    bundle_dir = stage_dir / "llama_cpp_bundle"
194    shutil.copytree(pkg_dir, bundle_dir)
195
196    template = _RUN_LINUX_TEMPLATE.read_text(encoding="utf-8")
197    rendered = template.replace("{MODEL_URL}", model_url or "").replace(
198        "{TEST_MODE}", test_mode
199    )
200    script_path = stage_dir / "run_linux.sh"
201    _write_lf(script_path, rendered)
202    script_path.chmod(0o755)
203
204    zip_base = str(stage_dir / "artifact")
205    shutil.make_archive(zip_base, "zip", stage_dir)
206    return Path(f"{zip_base}.zip")
207
208
209# =============================================================================
210# Platform enum + strategy table
211# =============================================================================
212
213
214class Platform(enum.Enum):
215    ANDROID = "android"
216    LINUX = "linux"
217
218
219@dataclass(frozen=True)
220class PlatformSpec:
221    test_framework: TestFramework
222    entry_script: str | None
223    build_artifact: Callable[[Path, Path, str, str | None], Path]
224    job_name_fmt: str
225
226
227PLATFORM_SPECS: dict[Platform, PlatformSpec] = {
228    Platform.ANDROID: PlatformSpec(
229        test_framework=TestFramework.APPIUM,
230        entry_script=None,
231        build_artifact=_build_android_artifact,
232        job_name_fmt="{base}",
233    ),
234    Platform.LINUX: PlatformSpec(
235        test_framework=TestFramework.BASH,
236        entry_script=_LINUX_ENTRY_SCRIPT,
237        build_artifact=_build_linux_artifact,
238        job_name_fmt="{base} (Linux)",
239    ),
240}
241
242DEVICE_PLATFORM: dict[str, Platform] = {
243    "SM8750": Platform.ANDROID,
244    "SM8650": Platform.ANDROID,
245    "SM8850": Platform.ANDROID,
246    "QCS9075M": Platform.LINUX,
247}
248
249
250# =============================================================================
251# Shared QDC job plumbing
252# =============================================================================
253
254
255def wait_for_job(client, job_id: str, timeout: int) -> str:
256    elapsed = 0
257    last_state = None
258    consecutive_errors = 0
259    max_consecutive_errors = 5
260    while elapsed < timeout:
261        try:
262            raw = qdc_api.get_job_status(client, job_id)
263            consecutive_errors = 0
264        except Exception as e:
265            consecutive_errors += 1
266            log.warning(
267                "Transient error polling job %s (%d/%d): %s",
268                job_id,
269                consecutive_errors,
270                max_consecutive_errors,
271                e,
272            )
273            if consecutive_errors >= max_consecutive_errors:
274                raise
275            time.sleep(POLL_INTERVAL)
276            elapsed += POLL_INTERVAL
277            continue
278        try:
279            status = JobState(raw)
280        except ValueError:
281            status = raw
282        if status in TERMINAL_STATES:
283            return raw.lower()
284        if raw != last_state:
285            log.info("Job %s: %s", job_id, raw)
286            last_state = raw
287        time.sleep(POLL_INTERVAL)
288        elapsed += POLL_INTERVAL
289    # Abort to free the QDC concurrency slot instead of leaking it.
290    try:
291        qdc_api.abort_job(client, job_id)
292        log.warning("Aborted job %s after timeout to free concurrency slot", job_id)
293    except Exception as e:
294        log.warning("Failed to abort job %s: %s", job_id, e)
295    raise TimeoutError(f"Job {job_id} did not finish within {timeout}s")
296
297
298def wait_for_log_upload(client, job_id: str) -> None:
299    elapsed = 0
300    while elapsed <= LOG_UPLOAD_TIMEOUT:
301        try:
302            status = (qdc_api.get_job_log_upload_status(client, job_id) or "").lower()
303        except Exception as e:
304            log.warning("get_job_log_upload_status failed: %s — will retry", e)
305            status = ""
306        if status in {"completed", "failed"}:
307            return
308        log.info("Waiting for log upload (status=%s) ...", status)
309        time.sleep(POLL_INTERVAL)
310        elapsed += POLL_INTERVAL
311    log.warning("Timed out waiting for log upload after %ds", LOG_UPLOAD_TIMEOUT)
312
313
314def wait_for_capacity(client, max_jobs: int = MAX_CONCURRENT_JOBS) -> None:
315    """Block until the user's active (non-terminal) QDC job count is below max_jobs."""
316    elapsed = 0
317    while elapsed < CAPACITY_TIMEOUT:
318        jobs_page = qdc_api.get_jobs_list(client, page_number=0, page_size=50)
319        if jobs_page is None:
320            log.warning(
321                "Could not retrieve job list; proceeding without capacity check"
322            )
323            return
324        items = getattr(jobs_page, "data", []) or []
325        active = sum(
326            1 for j in items if getattr(j, "state", None) in _NON_TERMINAL_STATE_VALUES
327        )
328        if active < max_jobs:
329            log.info("Active QDC jobs: %d / %d — proceeding", active, max_jobs)
330            return
331        log.info(
332            "Active QDC jobs: %d / %d — waiting %ds ...",
333            active,
334            max_jobs,
335            CAPACITY_POLL,
336        )
337        time.sleep(CAPACITY_POLL)
338        elapsed += CAPACITY_POLL
339    raise TimeoutError(
340        f"Capacity wait timed out after {CAPACITY_TIMEOUT}s"
341    )
342
343
344# ---------------------------------------------------------------------------
345# Log parsing helpers
346# ---------------------------------------------------------------------------
347
348
349def _parse_junit_xml(content: str) -> tuple[dict[str, bool], dict[str, str]]:
350    try:
351        root = ET.fromstring(content)
352    except ET.ParseError:
353        return {}, {}
354    results: dict[str, bool] = {}
355    failures: dict[str, str] = {}
356    for tc in root.iter("testcase"):
357        name = tc.get("name", "")
358        if classname := tc.get("classname", ""):
359            name = f"{classname}.{name}"
360        failure_el = tc.find("failure")
361        if failure_el is None:
362            failure_el = tc.find("error")
363        results[name] = failure_el is None
364        if failure_el is not None:
365            parts = [failure_el.get("message", ""), failure_el.text or ""]
366            failures[name] = "\n".join(p for p in parts if p).strip()
367    return results, failures
368
369
370def _parse_pytest_output(content: str) -> dict[str, bool]:
371    results: dict[str, bool] = {}
372    for m in _PYTEST_LINE_RE.finditer(content):
373        results[m.group(1)] = m.group(2) == "PASSED"
374    return results
375
376
377def fetch_logs_and_parse_tests(
378    client, job_id: str, max_retries: int = 5, retry_delay: int = 30
379) -> tuple[dict[str, bool], dict[str, str], dict[str, str]]:
380    """Returns (test_results, raw_logs, failure_details)."""
381    log_files = None
382    for attempt in range(1, max_retries + 1):
383        try:
384            log_files = qdc_api.get_job_log_files(client, job_id)
385            break
386        except Exception as e:
387            if attempt < max_retries:
388                log.warning(
389                    "get_job_log_files failed (attempt %d/%d): %s — retrying in %ds",
390                    attempt, max_retries, e, retry_delay,
391                )
392                time.sleep(retry_delay)
393            else:
394                log.error(
395                    "get_job_log_files failed after %d attempts: %s", max_retries, e
396                )
397                return {}, {}, {}
398    if not log_files:
399        log.warning("No log files returned for job %s", job_id)
400        return {}, {}, {}
401
402    test_results: dict[str, bool] = {}
403    pytest_fallback: dict[str, bool] = {}
404    raw_logs: dict[str, str] = {}
405    failure_details: dict[str, str] = {}
406
407    with tempfile.TemporaryDirectory() as tmpdir:
408        for lf in log_files:
409            zip_path = os.path.join(tmpdir, "log.zip")
410            log.info("Downloading log file: %s", lf.filename)
411            qdc_api.download_job_log_files(client, lf.filename, zip_path)
412            try:
413                shutil.unpack_archive(zip_path, tmpdir, "zip")
414            except Exception as e:
415                log.warning("Could not unpack %s as zip: %s", lf.filename, e)
416
417        for root_dir, _, files in os.walk(tmpdir):
418            for fname in sorted(files):
419                fpath = os.path.join(root_dir, fname)
420                content = Path(fpath).read_text(errors="replace")
421                if fname.endswith(".xml"):
422                    results, failures = _parse_junit_xml(content)
423                    test_results.update(results)
424                    failure_details.update(failures)
425                elif fname.endswith(".log"):
426                    if fname in _EXCLUDED_LOGS:
427                        continue
428                    log.info("--- %s ---\n%s", fname, content)
429                    raw_logs[fname] = content
430                    pytest_fallback.update(_parse_pytest_output(content))
431
432    return (
433        (test_results if test_results else pytest_fallback),
434        raw_logs,
435        failure_details,
436    )
437
438
439def write_summary(result: JobResult, title: str = "QDC Test Results") -> None:
440    summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
441    if not summary_path:
442        return
443
444    icon = "✅" if result.passed else "❌"
445
446    lines = [
447        f"## {title}\n",
448        f"Overall: {icon} {'PASSED' if result.passed else 'FAILED'}\n",
449    ]
450    reportable = {n: ok for n, ok in result.tests.items() if "test_install" not in n}
451    if reportable:
452        lines += ["| Test | Result |", "| ---- | ------ |"]
453        for name, ok in reportable.items():
454            lines.append(f"| `{name}` | {'✅' if ok else '❌'} |")
455        passed_n = sum(1 for v in reportable.values() if v)
456        failed_n = sum(1 for v in reportable.values() if not v)
457        lines += ["", f"**{passed_n} passed, {failed_n} failed**"]
458    else:
459        lines.append("_No per-test data available._")
460
461    failed_names = [n for n, ok in reportable.items() if not ok]
462    if failed_names:
463        lines += ["", "### Failures"]
464        for name in failed_names:
465            detail = result.failure_details.get(name)
466            if detail:
467                lines += [
468                    f"<details><summary><code>{name}</code></summary>",
469                    "",
470                    "```",
471                    detail,
472                    "```",
473                    "",
474                    "</details>",
475                ]
476
477    if result.raw_logs:
478        lines += ["", "### Raw Logs"]
479        for fname, content in sorted(result.raw_logs.items()):
480            lines += [
481                f"<details><summary>{fname}</summary>",
482                "",
483                "```",
484                content.rstrip(),
485                "```",
486                "",
487                "</details>",
488            ]
489
490    with open(summary_path, "a") as f:
491        f.write("\n".join(lines) + "\n")
492
493
494# =============================================================================
495# CLI + main
496# =============================================================================
497
498def parse_args() -> argparse.Namespace:
499    p = argparse.ArgumentParser(
500        description=__doc__,
501        formatter_class=argparse.RawDescriptionHelpFormatter,
502    )
503    p.add_argument("--pkg-dir", required=True, type=Path,
504                   help="Installed llama.cpp package directory (contains bin/ and lib/)")
505    p.add_argument("--model-url",
506                   help="Direct URL to the GGUF model file (required for --test bench)")
507    p.add_argument("--device", required=True,
508                   help="QDC chipset name, e.g. SM8750")
509    p.add_argument("--test", choices=["bench", "backend-ops", "all"], default="bench",
510                   help="Test suite to run (default: bench)")
511    p.add_argument("--job-timeout", type=int, default=JOB_TIMEOUT, metavar="SECONDS",
512                   help=f"Max seconds to wait for job completion (default: {JOB_TIMEOUT})")
513    p.add_argument("--retries", type=int, default=DEFAULT_RETRIES, metavar="N",
514                   help="Number of retries when device is unavailable (default: 0)")
515    p.add_argument("--retry-delay", type=int, default=RETRY_DELAY, metavar="SECONDS",
516                   help=f"Seconds to wait between retries (default: {RETRY_DELAY})")
517    args = p.parse_args()
518    if args.test in ("bench", "all") and not args.model_url:
519        p.error("--model-url is required when --test bench or --test all")
520    return args
521
522
523def _submit_and_run_job(client, args, spec, target_id, artifact_id) -> JobResult:
524    """Submit a QDC job and wait for results.
525
526    Raises DeviceUnavailableError for transient device/resource issues that
527    are worth retrying. Returns JobResult for definitive outcomes (pass or
528    test failure).
529    """
530    try:
531        wait_for_capacity(client)
532    except TimeoutError:
533        raise DeviceUnavailableError("Capacity wait timed out — device busy")
534
535    job_name = spec.job_name_fmt.format(base="llama.cpp Hexagon tests")
536
537    job_id = qdc_api.submit_job(
538        public_api_client=client,
539        target_id=target_id,
540        job_name=job_name,
541        external_job_id=None,
542        job_type=JobType.AUTOMATED,
543        job_mode=JobMode.APPLICATION,
544        timeout=max(1, args.job_timeout // 60),
545        test_framework=spec.test_framework,
546        entry_script=spec.entry_script,
547        job_artifacts=[artifact_id],
548        monkey_events=None,
549        monkey_session_timeout=None,
550        job_parameters=[JobSubmissionParameter.WIFIENABLED],
551    )
552    if job_id is None:
553        raise DeviceUnavailableError("Job submission failed — device may be unavailable")
554    log.info("Job submitted: %s  (device=%s)", job_id, args.device)
555
556    try:
557        job_status = wait_for_job(client, job_id, timeout=args.job_timeout)
558    except TimeoutError as e:
559        raise DeviceUnavailableError(str(e))
560    log.info("Job %s finished: %s", job_id, job_status)
561
562    wait_for_log_upload(client, job_id)
563    tests, raw_logs, failure_details = fetch_logs_and_parse_tests(client, job_id)
564
565    job_ok = job_status == JobState.COMPLETED.value.lower()
566
567    if not job_ok and not tests:
568        raise DeviceUnavailableError(
569            f"Job did not complete (status={job_status}) and produced no test results"
570        )
571
572    passed = job_ok and all(tests.values()) if tests else job_ok
573    if spec.test_framework == TestFramework.BASH and not tests:
574        log.error("No test results recovered (state=%s). Script likely never ran.", job_status)
575        passed = False
576    if not passed:
577        log.error("Job did not complete successfully or tests failed (status=%s)", job_status)
578
579    return JobResult(passed=passed, tests=tests, raw_logs=raw_logs, failure_details=failure_details)
580
581
582def main() -> int:
583    args = parse_args()
584
585    platform = DEVICE_PLATFORM.get(args.device)
586    if platform is None:
587        log.error(
588            "Unknown device %r. Known: %s",
589            args.device, ", ".join(sorted(DEVICE_PLATFORM.keys())),
590        )
591        return 1
592    spec = PLATFORM_SPECS[platform]
593
594    api_key = os.environ.get("QDC_API_KEY")
595    if not api_key:
596        log.error("QDC_API_KEY environment variable must be set")
597        return 1
598    if not args.pkg_dir.is_dir():
599        log.error("--pkg-dir %s does not exist", args.pkg_dir)
600        return 1
601
602    client = qdc_api.get_public_api_client_using_api_key(
603        api_key_header=api_key,
604        app_name_header="llama-cpp-ci",
605        on_behalf_of_header="llama-cpp-ci",
606        client_type_header="Python",
607    )
608
609    target_id = qdc_api.get_target_id(client, args.device)
610    if target_id is None:
611        log.error("Could not find QDC target for device %r", args.device)
612        return 1
613
614    with tempfile.TemporaryDirectory() as tmpdir:
615        log.info("Building %s artifact (test=%s) ...", platform.value, args.test)
616        zip_path = spec.build_artifact(
617            args.pkg_dir, Path(tmpdir), args.test, args.model_url
618        )
619        log.info("Uploading artifact (%d MB) ...", zip_path.stat().st_size // 1_000_000)
620        artifact_id = qdc_api.upload_file(client, str(zip_path), ArtifactType.TESTSCRIPT)
621
622    if artifact_id is None:
623        log.error("Artifact upload failed")
624        return 1
625
626    max_attempts = 1 + args.retries
627    for attempt in range(1, max_attempts + 1):
628        try:
629            result = _submit_and_run_job(client, args, spec, target_id, artifact_id)
630            break
631        except DeviceUnavailableError as e:
632            if attempt < max_attempts:
633                log.warning(
634                    "Attempt %d/%d failed (device unavailable): %s — retrying in %ds",
635                    attempt, max_attempts, e, args.retry_delay,
636                )
637                time.sleep(args.retry_delay)
638            else:
639                log.error(
640                    "Attempt %d/%d failed (device unavailable): %s — no retries left",
641                    attempt, max_attempts, e,
642                )
643                write_summary(
644                    JobResult(passed=False, tests={}),
645                    title=f"QDC Device Unavailable ({args.device})",
646                )
647                return 1
648    else:
649        return 1
650
651    if args.test == "backend-ops":
652        title = f"Backend Ops — HTP0 ({args.device})"
653    elif args.test == "all":
654        title = f"QDC Tests ({args.device})"
655    else:
656        title = f"QDC Test Results ({args.device})"
657    write_summary(result, title=title)
658
659    return 0 if result.passed else 1
660
661
662if __name__ == "__main__":
663    sys.exit(main())