blob: eaf1f8cf175d307abcd1cbc2077de9a0b7e2d61e [file]
#!/usr/bin/env python3
"""Reference-vs-ours parity gate for the official Spark Connect test suite.
This encodes the correct definition of "done": our Rust-backed client must match
the *reference* pyspark client's pass/skip/fail profile against the SAME Spark
Connect server. It runs each official connect test file twice - once with the
reference client, once with ours - parses pytest's summary, and reports per-file
diffs. A file is a REGRESSION only when a test the reference client passes fails
(or errors) with our client. Tests that the reference client also skips/fails are
environmental (no local JVM, single-node server, unsupported server type, etc.)
and are NOT counted against us.
Discovery: all `test_*.py` under <spark>/python/pyspark/**/tests/connect/**.
Exit code: 0 if there are no regressions, 1 otherwise. `--baseline-only` and
`--ours-only` record one side to JSON without judging.
Usage:
python3 scripts/parity_baseline_diff.py \
--spark ~/workspace/origin/spark \
--target-pyspark ./python \
--remote sc://localhost:15002 \
[--filter test_parity_] [--jobs 1] [--out /tmp/parity_diff.json]
"""
from __future__ import annotations
import argparse
import concurrent.futures
import json
import os
import re
import subprocess
import sys
from pathlib import Path
# pytest summary line, e.g. "5 passed, 2 skipped, 1 failed in 3.2s"
_SUMMARY_RE = re.compile(
r"(?:(\d+) failed)?.*?(?:(\d+) passed)?.*?(?:(\d+) skipped)?.*?(?:(\d+) error)?",
)
def discover(spark_py: Path, name_filter: str | None, path_contains: str | None) -> list[Path]:
files = []
for p in sorted(spark_py.rglob("test_*.py")):
s = str(p).replace(os.sep, "/")
if "/tests/connect/" not in s:
continue
if name_filter and name_filter not in p.name:
continue
if path_contains and path_contains not in s:
continue
files.append(p)
return files
def parse_summary(text: str) -> dict[str, int]:
"""Extract counts from the last pytest summary line."""
counts = {"passed": 0, "failed": 0, "skipped": 0, "error": 0}
# Look at the tail; pytest prints the summary on the final non-empty lines.
for line in reversed(text.strip().splitlines()):
line = line.strip().strip("=").strip()
if not line:
continue
found = False
for key in ("passed", "failed", "skipped", "error", "errors"):
m = re.search(rf"(\d+) {key}\b", line)
if m:
counts["error" if key == "errors" else key] = int(m.group(1))
found = True
if found and ("passed" in line or "failed" in line or "error" in line or "skipped" in line):
break
return counts
def run_one(test_file: Path, spark_py: Path, remote: str, target_pyspark: str | None) -> dict:
env = dict(os.environ)
env["SPARK_CONNECT_TESTING_REMOTE"] = remote
env["SPARK_TESTING"] = "1"
env["SPARK_SKIP_CONNECT_COMPAT_TESTS"] = "1" # force remote-only (no local JVM)
pp = []
if target_pyspark:
pp.append(os.path.abspath(os.path.expanduser(target_pyspark)))
pp.append(str(spark_py))
if env.get("PYTHONPATH"):
pp.append(env["PYTHONPATH"])
env["PYTHONPATH"] = os.pathsep.join(pp)
r = subprocess.run(
[sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", str(test_file)],
env=env,
capture_output=True,
text=True,
# A single connect test file should finish in well under this. If it does not,
# treat it as a hang (recorded as a timeout) rather than blocking the whole sweep
# for 20 minutes on one file.
timeout=int(os.environ.get("PARITY_FILE_TIMEOUT", "300")),
)
out = r.stdout + "\n" + r.stderr
counts = parse_summary(out)
counts["returncode"] = r.returncode
return counts
def collect_side(files, spark_py, remote, target, jobs) -> dict[str, dict]:
results: dict[str, dict] = {}
def work(tf: Path):
try:
return tf.name, run_one(tf, spark_py, remote, target)
except subprocess.TimeoutExpired:
return tf.name, {
"passed": 0,
"failed": 0,
"skipped": 0,
"error": 0,
"returncode": -1,
"timeout": True,
}
if jobs <= 1:
for tf in files:
name, res = work(tf)
results[name] = res
print(f" {name:<55} {_short(res)}")
else:
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as ex:
for name, res in ex.map(work, files):
results[name] = res
print(f" {name:<55} {_short(res)}")
return results
def _short(res: dict) -> str:
if res.get("timeout"):
return "TIMEOUT"
return f"pass={res['passed']} fail={res['failed']} skip={res['skipped']} err={res['error']}"
def _is_regression(r: dict, o: dict) -> bool:
"""A file regresses when ours fails/errors more than reference, passes fewer, or times out."""
if o.get("timeout"):
return True
return (o["failed"] + o["error"]) > (r["failed"] + r["error"]) or o["passed"] < r["passed"]
def run_interleaved(files, spark_py, remote, target, jobs) -> dict:
"""Run reference AND ours per file, diffing immediately so regressions stream out.
This gives incremental, actionable results (fix the first regression without waiting
for the whole suite), unlike running one full side and then the other.
"""
def work(tf: Path):
def one(t):
try:
return run_one(tf, spark_py, remote, t)
except subprocess.TimeoutExpired:
return {
"passed": 0,
"failed": 0,
"skipped": 0,
"error": 0,
"returncode": -1,
"timeout": True,
}
return tf.name, one(None), one(target)
payload = {"reference": {}, "ours": {}, "regressions": []}
n_reg = 0
executor = concurrent.futures.ThreadPoolExecutor(max_workers=max(1, jobs))
futures = [executor.submit(work, tf) for tf in files]
done = 0
for fut in concurrent.futures.as_completed(futures):
name, r, o = fut.result()
payload["reference"][name] = r
payload["ours"][name] = o
done += 1
if _is_regression(r, o):
n_reg += 1
payload["regressions"].append(name)
print(f"[{done}/{len(files)}] REGRESSION {name}")
print(f" reference: {_short(r)}")
print(f" ours : {_short(o)}")
else:
print(f"[{done}/{len(files)}] ok {name:<45} ours={_short(o)}", flush=True)
executor.shutdown()
print(f"\n{n_reg} regression file(s) out of {len(files)}.")
if payload["regressions"]:
print("Regressions:", ", ".join(sorted(payload["regressions"])))
return payload
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--spark", required=True)
ap.add_argument("--target-pyspark", default=None)
ap.add_argument("--remote", default="sc://localhost:15002")
ap.add_argument(
"--filter", default=None, help="only run test files whose name contains this substring"
)
ap.add_argument(
"--path-contains",
default=None,
help="only run test files whose full path contains this substring "
"(e.g. 'sql/tests/connect' for just the SQL connect tests)",
)
ap.add_argument("--jobs", type=int, default=1)
ap.add_argument("--out", default=None)
ap.add_argument(
"--baseline-only", action="store_true", help="run only the reference client and record it"
)
ap.add_argument("--ours-only", action="store_true", help="run only our client and record it")
args = ap.parse_args()
spark_py = Path(os.path.expanduser(args.spark)) / "python"
files = discover(spark_py, args.filter, args.path_contains)
if not files:
print(f"!! no connect test files found under {spark_py} (filter={args.filter})")
return 2
print(f"Discovered {len(files)} connect test files (filter={args.filter or 'none'}).\n")
payload: dict = {"remote": args.remote, "files": len(files)}
rc = 0
if args.baseline_only or args.ours_only:
# Single-side recording (no diff).
target = args.target_pyspark if args.ours_only else None
side = "ours" if args.ours_only else "reference"
print(f"== {side} ==")
payload[side] = collect_side(files, spark_py, args.remote, target, args.jobs)
else:
print(
f"{'=' * 82}\nPARITY DIFF (streaming; regression = reference passes but ours does not)\n{'=' * 82}"
)
result = run_interleaved(files, spark_py, args.remote, args.target_pyspark, args.jobs)
payload.update(result)
rc = 1 if result["regressions"] else 0
if args.out:
with open(args.out, "w") as f:
json.dump(payload, f, indent=2)
print(f"wrote {args.out}")
return rc
if __name__ == "__main__":
raise SystemExit(main())