pl-e2e-test 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,675 @@
1
+ """Shard report recording, validation, merging, and JUnit output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import sys
10
+ import tempfile
11
+ import time
12
+ import xml.etree.ElementTree as ET
13
+ from collections import Counter, defaultdict
14
+ from collections.abc import Iterable, Sequence
15
+ from dataclasses import dataclass, field
16
+ from datetime import UTC, datetime
17
+ from importlib.metadata import version
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import pytest
22
+
23
+ from .sharding import Shard
24
+
25
+ REPORT_SCHEMA_VERSION = 1
26
+ REPORT_GLOB = "*.pl-e2e-report.json"
27
+
28
+
29
+ class ReportError(ValueError):
30
+ """A shard report cannot be safely merged."""
31
+
32
+
33
+ def _now() -> str:
34
+ return datetime.now(UTC).isoformat()
35
+
36
+
37
+ def collection_fingerprint(nodeids: Sequence[str]) -> str:
38
+ """Return a stable fingerprint for an ordered pytest collection."""
39
+
40
+ digest = hashlib.sha256()
41
+ for nodeid in nodeids:
42
+ digest.update(nodeid.encode("utf-8"))
43
+ digest.update(b"\0")
44
+ return digest.hexdigest()
45
+
46
+
47
+ def serialize_pytest_report(
48
+ report: pytest.TestReport | pytest.CollectReport,
49
+ ) -> dict[str, Any]:
50
+ longrepr = None if report.longrepr is None else str(report.longrepr)
51
+ serialized: dict[str, Any] = {
52
+ "nodeid": report.nodeid,
53
+ "outcome": report.outcome,
54
+ "longrepr": longrepr,
55
+ "sections": [list(section) for section in report.sections],
56
+ }
57
+ when = getattr(report, "when", None)
58
+ if when is not None:
59
+ serialized["when"] = when
60
+ duration = getattr(report, "duration", None)
61
+ if duration is not None:
62
+ serialized["duration"] = duration
63
+ wasxfail = getattr(report, "wasxfail", None)
64
+ if wasxfail is not None:
65
+ serialized["wasxfail"] = str(wasxfail)
66
+ return serialized
67
+
68
+
69
+ def _atomic_json(path: Path, value: dict[str, Any]) -> None:
70
+ path = path.resolve()
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ temporary_name: str | None = None
73
+ try:
74
+ with tempfile.NamedTemporaryFile(
75
+ mode="w",
76
+ encoding="utf-8",
77
+ dir=path.parent,
78
+ prefix=f".{path.name}.",
79
+ suffix=".tmp",
80
+ delete=False,
81
+ ) as temporary:
82
+ temporary_name = temporary.name
83
+ json.dump(value, temporary, indent=2, sort_keys=True)
84
+ temporary.write("\n")
85
+ os.replace(temporary_name, path)
86
+ finally:
87
+ if temporary_name is not None:
88
+ try:
89
+ Path(temporary_name).unlink()
90
+ except FileNotFoundError:
91
+ pass
92
+
93
+
94
+ @dataclass(slots=True)
95
+ class ShardReportRecorder:
96
+ """Mutable pytest-session state used to create one shard report."""
97
+
98
+ path: Path
99
+ shard: Shard
100
+ fuzz_seed: int | None
101
+ started_at: str = field(default_factory=_now)
102
+ started_monotonic: float = field(default_factory=time.monotonic)
103
+ full_nodeids: list[str] = field(default_factory=list)
104
+ selected_nodeids: list[str] = field(default_factory=list)
105
+ fingerprint: str = ""
106
+ reports: list[dict[str, Any]] = field(default_factory=list)
107
+ collection_errors: list[dict[str, Any]] = field(default_factory=list)
108
+ warnings: list[dict[str, Any]] = field(default_factory=list)
109
+ internal_errors: list[str] = field(default_factory=list)
110
+ interrupted: list[str] = field(default_factory=list)
111
+ intentionally_empty: bool = False
112
+
113
+ def set_collection(
114
+ self, full_nodeids: Sequence[str], selected_nodeids: Sequence[str]
115
+ ) -> None:
116
+ self.full_nodeids = list(full_nodeids)
117
+ self.selected_nodeids = list(selected_nodeids)
118
+ self.fingerprint = collection_fingerprint(self.full_nodeids)
119
+ self.intentionally_empty = bool(self.full_nodeids) and not self.selected_nodeids
120
+
121
+ def payload(self, exit_status: int) -> dict[str, Any]:
122
+ return {
123
+ "schema_version": REPORT_SCHEMA_VERSION,
124
+ "producer": {
125
+ "name": "pl-e2e-test",
126
+ "version": version("pl-e2e-test"),
127
+ "pytest_version": pytest.__version__,
128
+ },
129
+ "shard": {"index": self.shard.index, "count": self.shard.count},
130
+ "suite": {
131
+ "fingerprint": self.fingerprint,
132
+ "full_nodeids": self.full_nodeids,
133
+ "selected_nodeids": self.selected_nodeids,
134
+ },
135
+ "fuzz_seed": self.fuzz_seed,
136
+ "started_at": self.started_at,
137
+ "finished_at": _now(),
138
+ "duration": time.monotonic() - self.started_monotonic,
139
+ "exit_status": exit_status,
140
+ "reports": self.reports,
141
+ "collection_errors": self.collection_errors,
142
+ "warnings": self.warnings,
143
+ "internal_errors": self.internal_errors,
144
+ "interrupted": self.interrupted,
145
+ }
146
+
147
+ def write(self, exit_status: int) -> None:
148
+ _atomic_json(self.path, self.payload(exit_status))
149
+
150
+
151
+ def write_empty_shard_report(
152
+ path: Path, shard: Shard, *, fuzz_seed: int | None = None
153
+ ) -> None:
154
+ """Write a successful report for a diff run that selected no questions."""
155
+
156
+ recorder = ShardReportRecorder(path=path, shard=shard, fuzz_seed=fuzz_seed)
157
+ recorder.set_collection((), ())
158
+ recorder.write(0)
159
+
160
+
161
+ @dataclass(frozen=True, slots=True)
162
+ class ItemResult:
163
+ nodeid: str
164
+ outcome: str
165
+ duration: float
166
+ detail: str
167
+ sections: tuple[tuple[str, str], ...]
168
+ reason: str | None = None
169
+
170
+
171
+ def _expect_dict(value: Any, context: str) -> dict[str, Any]:
172
+ if not isinstance(value, dict):
173
+ raise ReportError(f"{context} must be a JSON object")
174
+ return value
175
+
176
+
177
+ def _expect_list(value: Any, context: str) -> list[Any]:
178
+ if not isinstance(value, list):
179
+ raise ReportError(f"{context} must be a JSON array")
180
+ return value
181
+
182
+
183
+ def _expect_int(value: Any, context: str) -> int:
184
+ if isinstance(value, bool) or not isinstance(value, int):
185
+ raise ReportError(f"{context} must be an integer")
186
+ return value
187
+
188
+
189
+ def _load_report(path: Path) -> dict[str, Any]:
190
+ try:
191
+ raw = json.loads(path.read_text(encoding="utf-8"))
192
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
193
+ raise ReportError(f"unable to read {path}: {exc}") from exc
194
+ report = _expect_dict(raw, os.fspath(path))
195
+ schema_version = report.get("schema_version")
196
+ if (
197
+ isinstance(schema_version, bool)
198
+ or not isinstance(schema_version, int)
199
+ or schema_version != REPORT_SCHEMA_VERSION
200
+ ):
201
+ raise ReportError(f"{path}: unsupported report schema {schema_version!r}")
202
+ report["_path"] = os.fspath(path)
203
+ return report
204
+
205
+
206
+ def _report_paths(inputs: Sequence[Path]) -> list[Path]:
207
+ paths: set[Path] = set()
208
+ for input_path in inputs:
209
+ if input_path.is_dir():
210
+ paths.update(path.resolve() for path in input_path.glob(REPORT_GLOB))
211
+ elif input_path.is_file():
212
+ paths.add(input_path.resolve())
213
+ else:
214
+ raise ReportError(f"report input does not exist: {input_path}")
215
+ if not paths:
216
+ raise ReportError("no shard reports found")
217
+ return sorted(paths, key=lambda path: path.as_posix())
218
+
219
+
220
+ def _string_list(value: Any, context: str) -> list[str]:
221
+ values = _expect_list(value, context)
222
+ if not all(isinstance(item, str) for item in values):
223
+ raise ReportError(f"{context} must contain only strings")
224
+ return values
225
+
226
+
227
+ def _validate_reports(reports: Sequence[dict[str, Any]]) -> list[str]:
228
+ first = reports[0]
229
+ first_shard = _expect_dict(first.get("shard"), "shard")
230
+ count = _expect_int(first_shard.get("count"), "shard.count")
231
+ if count < 1:
232
+ raise ReportError("shard.count must be at least 1")
233
+ first_suite = _expect_dict(first.get("suite"), "suite")
234
+ full_nodeids = _string_list(first_suite.get("full_nodeids"), "suite.full_nodeids")
235
+ if len(full_nodeids) != len(set(full_nodeids)):
236
+ raise ReportError("suite.full_nodeids contains duplicates")
237
+ fingerprint = first_suite.get("fingerprint")
238
+ if not isinstance(fingerprint, str) or fingerprint != collection_fingerprint(
239
+ full_nodeids
240
+ ):
241
+ raise ReportError("suite fingerprint does not match full_nodeids")
242
+
243
+ producer = _expect_dict(first.get("producer"), "producer")
244
+ if producer.get("name") != "pl-e2e-test" or not all(
245
+ isinstance(producer.get(key), str) for key in ("version", "pytest_version")
246
+ ):
247
+ raise ReportError("producer metadata is invalid")
248
+ producer_identity = (producer.get("version"), producer.get("pytest_version"))
249
+ fuzz_seed = first.get("fuzz_seed")
250
+ if fuzz_seed is not None and (
251
+ isinstance(fuzz_seed, bool) or not isinstance(fuzz_seed, int)
252
+ ):
253
+ raise ReportError("fuzz_seed must be an integer or null")
254
+ by_index: dict[int, dict[str, Any]] = {}
255
+ selected: set[str] = set()
256
+ for report in reports:
257
+ path = report.get("_path", "report")
258
+ shard = _expect_dict(report.get("shard"), f"{path}: shard")
259
+ index = _expect_int(shard.get("index"), f"{path}: shard.index")
260
+ report_count = _expect_int(shard.get("count"), f"{path}: shard.count")
261
+ if report_count != count:
262
+ raise ReportError(f"{path}: shard count does not match other reports")
263
+ if not 1 <= index <= count:
264
+ raise ReportError(f"{path}: shard index {index} is out of range")
265
+ if index in by_index:
266
+ raise ReportError(f"duplicate report for shard {index}/{count}")
267
+ by_index[index] = report
268
+
269
+ suite = _expect_dict(report.get("suite"), f"{path}: suite")
270
+ if suite.get("fingerprint") != fingerprint:
271
+ raise ReportError(f"{path}: collection fingerprint does not match")
272
+ if (
273
+ _string_list(suite.get("full_nodeids"), f"{path}: suite.full_nodeids")
274
+ != full_nodeids
275
+ ):
276
+ raise ReportError(f"{path}: full collection does not match")
277
+ report_producer = _expect_dict(report.get("producer"), f"{path}: producer")
278
+ if (
279
+ report_producer.get("version"),
280
+ report_producer.get("pytest_version"),
281
+ ) != producer_identity:
282
+ raise ReportError(f"{path}: producer versions do not match")
283
+ if report.get("fuzz_seed") != fuzz_seed:
284
+ raise ReportError(f"{path}: fuzz seed does not match")
285
+
286
+ selected_nodeids = _string_list(
287
+ suite.get("selected_nodeids"), f"{path}: suite.selected_nodeids"
288
+ )
289
+ if len(selected_nodeids) != len(set(selected_nodeids)):
290
+ raise ReportError(f"{path}: selected_nodeids contains duplicates")
291
+ duplicates = selected.intersection(selected_nodeids)
292
+ if duplicates:
293
+ raise ReportError(f"{path}: duplicate selected test {min(duplicates)}")
294
+ unknown = set(selected_nodeids).difference(full_nodeids)
295
+ if unknown:
296
+ raise ReportError(f"{path}: selected test is absent from full collection")
297
+ selected.update(selected_nodeids)
298
+
299
+ missing_indices = sorted(set(range(1, count + 1)).difference(by_index))
300
+ if missing_indices:
301
+ raise ReportError(
302
+ "missing shard reports: "
303
+ + ", ".join(f"{index}/{count}" for index in missing_indices)
304
+ )
305
+ if len(reports) != count:
306
+ raise ReportError(f"expected {count} shard reports, found {len(reports)}")
307
+ if selected != set(full_nodeids):
308
+ missing = set(full_nodeids).difference(selected)
309
+ raise ReportError(
310
+ f"reports do not select the full suite; missing {len(missing)} tests"
311
+ )
312
+ return full_nodeids
313
+
314
+
315
+ def _phases_by_nodeid(
316
+ reports: Sequence[dict[str, Any]],
317
+ ) -> dict[str, list[dict[str, Any]]]:
318
+ phases: dict[str, list[dict[str, Any]]] = defaultdict(list)
319
+ for report in reports:
320
+ path = report.get("_path", "report")
321
+ suite = _expect_dict(report.get("suite"), f"{path}: suite")
322
+ selected = set(
323
+ _string_list(
324
+ suite.get("selected_nodeids"), f"{path}: suite.selected_nodeids"
325
+ )
326
+ )
327
+ for phase in _expect_list(report.get("reports"), f"{path}: reports"):
328
+ phase_dict = _expect_dict(phase, f"{path}: report entry")
329
+ nodeid = phase_dict.get("nodeid")
330
+ if not isinstance(nodeid, str):
331
+ raise ReportError(f"{path}: report nodeid must be a string")
332
+ if nodeid not in selected:
333
+ raise ReportError(f"{path}: result for unselected test {nodeid}")
334
+ phases[nodeid].append(phase_dict)
335
+ for nodeid, node_phases in phases.items():
336
+ whens = [phase.get("when") for phase in node_phases]
337
+ if len(whens) != len(set(whens)):
338
+ raise ReportError(f"duplicate test phase for {nodeid}")
339
+ return phases
340
+
341
+
342
+ def _sections(phases: Iterable[dict[str, Any]]) -> tuple[tuple[str, str], ...]:
343
+ result: list[tuple[str, str]] = []
344
+ for phase in phases:
345
+ for section in _expect_list(phase.get("sections", []), "report sections"):
346
+ if (
347
+ isinstance(section, list)
348
+ and len(section) == 2
349
+ and all(isinstance(value, str) for value in section)
350
+ ):
351
+ result.append((section[0], section[1]))
352
+ return tuple(result)
353
+
354
+
355
+ def _item_result(nodeid: str, phases: Sequence[dict[str, Any]]) -> ItemResult:
356
+ by_when = {
357
+ phase.get("when"): phase
358
+ for phase in phases
359
+ if phase.get("when") in {"setup", "call", "teardown"}
360
+ }
361
+ duration = sum(
362
+ float(phase.get("duration", 0.0))
363
+ for phase in phases
364
+ if isinstance(phase.get("duration", 0.0), int | float)
365
+ )
366
+ sections = _sections(phases)
367
+ setup = by_when.get("setup")
368
+ call = by_when.get("call")
369
+ teardown = by_when.get("teardown")
370
+ failed_phases = [
371
+ phase
372
+ for phase in (setup, call, teardown)
373
+ if phase is not None and phase.get("outcome") == "failed"
374
+ ]
375
+ if failed_phases:
376
+ outcome = (
377
+ "error"
378
+ if any(phase.get("when") != "call" for phase in failed_phases)
379
+ else "failed"
380
+ )
381
+ return ItemResult(
382
+ nodeid,
383
+ outcome,
384
+ duration,
385
+ "\n\n".join(
386
+ str(phase.get("longrepr") or "test failed") for phase in failed_phases
387
+ ),
388
+ sections,
389
+ )
390
+ skipped_phase = next(
391
+ (
392
+ phase
393
+ for phase in (setup, call)
394
+ if phase is not None and phase.get("outcome") == "skipped"
395
+ ),
396
+ None,
397
+ )
398
+ if skipped_phase is not None:
399
+ wasxfail = skipped_phase.get("wasxfail")
400
+ outcome = "xfailed" if wasxfail is not None else "skipped"
401
+ reason = str(wasxfail or skipped_phase.get("longrepr") or "skipped")
402
+ return ItemResult(nodeid, outcome, duration, "", sections, reason)
403
+ if (
404
+ setup is None
405
+ or setup.get("outcome") != "passed"
406
+ or call is None
407
+ or teardown is None
408
+ ):
409
+ return ItemResult(
410
+ nodeid,
411
+ "error",
412
+ duration,
413
+ "test did not complete all setup, call, and teardown phases",
414
+ sections,
415
+ )
416
+ if call.get("wasxfail") is not None:
417
+ return ItemResult(
418
+ nodeid,
419
+ "xpassed",
420
+ duration,
421
+ "",
422
+ sections,
423
+ str(call["wasxfail"]),
424
+ )
425
+ return ItemResult(nodeid, "passed", duration, "", sections)
426
+
427
+
428
+ def _session_errors(
429
+ reports: Sequence[dict[str, Any]], item_results: dict[str, ItemResult]
430
+ ) -> list[ItemResult]:
431
+ errors: list[ItemResult] = []
432
+ for report in reports:
433
+ shard = _expect_dict(report.get("shard"), "shard")
434
+ label = f"shard-{shard.get('index')}-of-{shard.get('count')}"
435
+ details: list[str] = []
436
+ for collection_error in _expect_list(
437
+ report.get("collection_errors", []), "collection_errors"
438
+ ):
439
+ item = _expect_dict(collection_error, "collection error")
440
+ details.append(str(item.get("longrepr") or "collection failed"))
441
+ details.extend(
442
+ str(value)
443
+ for value in _expect_list(
444
+ report.get("internal_errors", []), "internal_errors"
445
+ )
446
+ )
447
+ details.extend(
448
+ str(value)
449
+ for value in _expect_list(report.get("interrupted", []), "interrupted")
450
+ )
451
+ exit_status = _expect_int(report.get("exit_status"), "exit_status")
452
+ suite = _expect_dict(report.get("suite"), "suite")
453
+ selected_nodeids = _string_list(
454
+ suite.get("selected_nodeids"), "suite.selected_nodeids"
455
+ )
456
+ test_failure_explains_exit = any(
457
+ item_results[nodeid].outcome in {"failed", "error"}
458
+ for nodeid in selected_nodeids
459
+ )
460
+ if exit_status != 0 and not details and not test_failure_explains_exit:
461
+ details.append(f"pytest exited with status {exit_status}")
462
+ if details:
463
+ errors.append(
464
+ ItemResult(
465
+ nodeid=f"prairielearn_e2e::{label}",
466
+ outcome="error",
467
+ duration=0.0,
468
+ detail="\n\n".join(details),
469
+ sections=(),
470
+ )
471
+ )
472
+ return errors
473
+
474
+
475
+ def _summary(results: Sequence[ItemResult]) -> str:
476
+ counts = Counter(result.outcome for result in results)
477
+ order = ("failed", "error", "passed", "skipped", "xfailed", "xpassed")
478
+ parts = [f"{counts[name]} {name}" for name in order if counts[name]]
479
+ return ", ".join(parts) if parts else "no tests ran"
480
+
481
+
482
+ def _print_terminal(results: Sequence[ItemResult], warnings: Sequence[str]) -> None:
483
+ for result in results:
484
+ if result.outcome not in {"failed", "error"}:
485
+ continue
486
+ print(f"{'=' * 20} {result.outcome.upper()} {result.nodeid} {'=' * 20}")
487
+ print(result.detail)
488
+ for heading, content in result.sections:
489
+ print(f"-------------------- {heading} --------------------")
490
+ print(content)
491
+ if warnings:
492
+ print("==================== warnings summary ====================")
493
+ for warning in warnings:
494
+ print(warning)
495
+ noteworthy = [
496
+ result
497
+ for result in results
498
+ if result.outcome in {"failed", "error", "skipped", "xfailed", "xpassed"}
499
+ ]
500
+ if noteworthy:
501
+ print("==================== short test summary info ====================")
502
+ for result in noteworthy:
503
+ print(f"{result.outcome.upper():8} {result.nodeid}")
504
+ duration = sum(result.duration for result in results)
505
+ print(
506
+ f"==================== {_summary(results)} in {duration:.2f}s "
507
+ "===================="
508
+ )
509
+
510
+
511
+ def _failure_message(detail: str, fallback: str) -> str:
512
+ lines = [line.strip() for line in detail.splitlines() if line.strip()]
513
+ return lines[-1] if lines else fallback
514
+
515
+
516
+ def _xml_safe(value: str) -> str:
517
+ return "".join(
518
+ character
519
+ if (
520
+ character in "\t\n\r"
521
+ or 0x20 <= ord(character) <= 0xD7FF
522
+ or 0xE000 <= ord(character) <= 0xFFFD
523
+ or 0x10000 <= ord(character) <= 0x10FFFF
524
+ )
525
+ else f"#x{ord(character):02X}"
526
+ for character in value
527
+ )
528
+
529
+
530
+ def _write_junit(path: Path, results: Sequence[ItemResult]) -> None:
531
+ counts = Counter(result.outcome for result in results)
532
+ failures = counts["failed"]
533
+ errors = counts["error"]
534
+ skipped = counts["skipped"] + counts["xfailed"]
535
+ duration = sum(result.duration for result in results)
536
+ testsuites = ET.Element(
537
+ "testsuites",
538
+ {
539
+ "name": "pl-e2e-test",
540
+ "tests": str(len(results)),
541
+ "failures": str(failures),
542
+ "errors": str(errors),
543
+ "skipped": str(skipped),
544
+ "time": f"{duration:.6f}",
545
+ },
546
+ )
547
+ testsuite = ET.SubElement(
548
+ testsuites,
549
+ "testsuite",
550
+ {
551
+ "name": "pl-e2e-test",
552
+ "tests": str(len(results)),
553
+ "failures": str(failures),
554
+ "errors": str(errors),
555
+ "skipped": str(skipped),
556
+ "time": f"{duration:.6f}",
557
+ },
558
+ )
559
+ for result in results:
560
+ testcase = ET.SubElement(
561
+ testsuite,
562
+ "testcase",
563
+ {
564
+ "classname": "prairielearn_e2e",
565
+ "name": _xml_safe(result.nodeid),
566
+ "time": f"{result.duration:.6f}",
567
+ },
568
+ )
569
+ if result.outcome == "failed":
570
+ failure = ET.SubElement(
571
+ testcase,
572
+ "failure",
573
+ {"message": _xml_safe(_failure_message(result.detail, "test failed"))},
574
+ )
575
+ failure.text = _xml_safe(result.detail)
576
+ elif result.outcome == "error":
577
+ error = ET.SubElement(
578
+ testcase,
579
+ "error",
580
+ {"message": _xml_safe(_failure_message(result.detail, "test error"))},
581
+ )
582
+ error.text = _xml_safe(result.detail)
583
+ elif result.outcome in {"skipped", "xfailed"}:
584
+ ET.SubElement(
585
+ testcase,
586
+ "skipped",
587
+ {"message": _xml_safe(result.reason or "skipped")},
588
+ )
589
+
590
+ stdout = "\n".join(
591
+ content
592
+ for heading, content in result.sections
593
+ if "stdout" in heading.lower()
594
+ )
595
+ stderr = "\n".join(
596
+ content
597
+ for heading, content in result.sections
598
+ if "stderr" in heading.lower() or "log" in heading.lower()
599
+ )
600
+ if stdout:
601
+ ET.SubElement(testcase, "system-out").text = _xml_safe(stdout)
602
+ if stderr:
603
+ ET.SubElement(testcase, "system-err").text = _xml_safe(stderr)
604
+
605
+ ET.indent(testsuites)
606
+ path = path.resolve()
607
+ path.parent.mkdir(parents=True, exist_ok=True)
608
+ temporary_name: str | None = None
609
+ try:
610
+ with tempfile.NamedTemporaryFile(
611
+ mode="wb",
612
+ dir=path.parent,
613
+ prefix=f".{path.name}.",
614
+ suffix=".tmp",
615
+ delete=False,
616
+ ) as temporary:
617
+ temporary_name = temporary.name
618
+ ET.ElementTree(testsuites).write(
619
+ temporary, encoding="utf-8", xml_declaration=True
620
+ )
621
+ os.replace(temporary_name, path)
622
+ finally:
623
+ if temporary_name is not None:
624
+ try:
625
+ Path(temporary_name).unlink()
626
+ except FileNotFoundError:
627
+ pass
628
+
629
+
630
+ def merge_reports(inputs: Sequence[Path], junitxml: Path) -> int:
631
+ """Validate and merge shard reports, returning a process exit status."""
632
+
633
+ report_paths = _report_paths(inputs)
634
+ if junitxml.resolve() in report_paths:
635
+ raise ReportError("JUnit output path must not overwrite an input report")
636
+ try:
637
+ junitxml.unlink(missing_ok=True)
638
+ except OSError as exc:
639
+ raise ReportError(f"unable to replace {junitxml}: {exc}") from exc
640
+ reports = [_load_report(path) for path in report_paths]
641
+ full_nodeids = _validate_reports(reports)
642
+ phases = _phases_by_nodeid(reports)
643
+ results = [_item_result(nodeid, phases.get(nodeid, ())) for nodeid in full_nodeids]
644
+ results.extend(
645
+ _session_errors(reports, {result.nodeid: result for result in results})
646
+ )
647
+ warnings = sorted(
648
+ {
649
+ str(warning.get("message", ""))
650
+ for report in reports
651
+ for warning in _expect_list(report.get("warnings", []), "warnings")
652
+ if isinstance(warning, dict) and warning.get("message")
653
+ }
654
+ )
655
+ _print_terminal(results, warnings)
656
+ _write_junit(junitxml, results)
657
+ return int(any(result.outcome in {"failed", "error"} for result in results))
658
+
659
+
660
+ def main(argv: Sequence[str] | None = None) -> int:
661
+ parser = argparse.ArgumentParser(
662
+ description="Merge pl-e2e-test shard reports into one terminal and JUnit report."
663
+ )
664
+ parser.add_argument("reports", nargs="+", type=Path)
665
+ parser.add_argument("--junitxml", type=Path, default=Path("pl-e2e-results.xml"))
666
+ args = parser.parse_args(argv)
667
+ try:
668
+ return merge_reports(args.reports, args.junitxml)
669
+ except (OSError, ReportError) as exc:
670
+ print(f"pl-e2e-merge-reports: error: {exc}", file=sys.stderr)
671
+ return 2
672
+
673
+
674
+ if __name__ == "__main__":
675
+ raise SystemExit(main())