@matt82198/aesop 0.3.2 → 0.4.0

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.
@@ -18,7 +18,38 @@ from __future__ import annotations
18
18
  import time
19
19
 
20
20
 
21
- def fold_claims(events: list) -> dict[str, str]:
21
+ def _read_claim_events(store) -> list:
22
+ """Read the claims stream from a StateAPI (.get) OR an EventStore (.read).
23
+
24
+ RS3-W: wave_loop passes a raw EventStore, which exposes read() but not
25
+ get(). try_claim called store.get() unconditionally, so EVERY claim
26
+ attempt raised AttributeError and fail-closed to False -- the claim gate
27
+ was dead code and items fell through to claim-less dispatch paths.
28
+ """
29
+ getter = getattr(store, "get", None)
30
+ if callable(getter):
31
+ return getter("claims")
32
+ return store.read("claims")
33
+
34
+
35
+ def _claim_expired(ev: dict, payload: dict, now: float) -> bool:
36
+ """True when a claim_requested event is past its TTL (RS3-W N4).
37
+
38
+ A claim whose event timestamp plus payload ttl is in the past is treated
39
+ as released: a crashed holder's claim must not persist forever. Events
40
+ without a ttl or timestamp (legacy) never expire (backward compatible).
41
+ """
42
+ ttl = payload.get("ttl")
43
+ ts = ev.get("ts")
44
+ if ttl is None or ts is None:
45
+ return False
46
+ try:
47
+ return float(now) > float(ts) + float(ttl)
48
+ except (TypeError, ValueError):
49
+ return False
50
+
51
+
52
+ def fold_claims(events: list, now: float | None = None) -> dict[str, str]:
22
53
  """Fold a claims stream into the current state of who holds each resource.
23
54
 
24
55
  Processes claim_requested and claim_released events to determine the winner
@@ -28,14 +59,23 @@ def fold_claims(events: list) -> dict[str, str]:
28
59
  subsequent claim_released event. If an instance releases and then re-claims,
29
60
  the re-claim is the new active claim for that instance.
30
61
 
62
+ TTL enforcement (RS3-W N4): a claim_requested event whose ts + payload
63
+ ttl is earlier than ``now`` is EXPIRED and ignored -- a crashed instance's
64
+ claim becomes claimable again after its TTL instead of persisting forever.
65
+ Legacy events without ts/ttl never expire.
66
+
31
67
  Args:
32
68
  events: list of event dicts from the claims stream
33
69
  (as returned by EventStore.read('claims'))
70
+ now: reference time for TTL expiry (default: time.time())
34
71
 
35
72
  Returns:
36
73
  dict mapping resource_id -> holding_instance_id for all currently
37
74
  held resources. Empty dict if no claims exist or all have been released.
38
75
  """
76
+ if now is None:
77
+ now = time.time()
78
+
39
79
  # Track all claims by resource and instance (as list, to preserve order)
40
80
  claims_by_resource = {} # resource -> {instance_id: [versions...]}
41
81
  # Track all releases by resource and instance (as sorted list)
@@ -48,6 +88,9 @@ def fold_claims(events: list) -> dict[str, str]:
48
88
  version = ev.get("version", 0)
49
89
 
50
90
  if etype == "claim_requested":
91
+ if _claim_expired(ev, payload, now):
92
+ # Expired claim: releasable/ignored (never a live holder).
93
+ continue
51
94
  resource = payload.get("resource")
52
95
  instance_id = payload.get("instance_id")
53
96
  if resource is not None and instance_id is not None:
@@ -119,7 +162,8 @@ def try_claim(store, resource: str, instance_id: str, ttl: float = 300.0) -> boo
119
162
  in the stream could later become the winner if the true holder releases.
120
163
 
121
164
  Args:
122
- store: StateAPI or EventStore instance (must have append() and get() methods)
165
+ store: StateAPI or EventStore instance (must have append() plus
166
+ get() or read())
123
167
  resource: the resource identifier to claim (e.g., "wave_123", "lane_0", ...)
124
168
  instance_id: the instance identifier requesting the claim
125
169
  ttl: time-to-live in seconds (default 300s = 5min); embedded in the payload
@@ -140,7 +184,7 @@ def try_claim(store, resource: str, instance_id: str, ttl: float = 300.0) -> boo
140
184
  )
141
185
 
142
186
  # Re-read claims stream and fold to see current state
143
- events = store.get("claims")
187
+ events = _read_claim_events(store)
144
188
  claims = fold_claims(events)
145
189
 
146
190
  # Check if we won
@@ -162,7 +206,23 @@ def try_claim(store, resource: str, instance_id: str, ttl: float = 300.0) -> boo
162
206
 
163
207
  return False
164
208
  except Exception:
165
- # Fail-closed: any exception means we don't hold the claim
209
+ # Fail-closed: any exception means we don't hold the claim.
210
+ # RS5 F2: our claim_requested may already be in the stream (append
211
+ # succeeded, then the re-read/fold raised -- e.g. SQLite lock retries
212
+ # exhausted). Left un-retracted it becomes a PHANTOM holder: it wins
213
+ # as soon as the true holder releases, blocking the resource for a
214
+ # full TTL while we believe we failed. Best-effort retract our own
215
+ # request before returning False; if the retract itself fails there
216
+ # is nothing more we can do (TTL expiry remains the backstop).
217
+ try:
218
+ store.append(
219
+ "claims",
220
+ "claim_released",
221
+ {"resource": resource, "instance_id": instance_id},
222
+ actor=instance_id,
223
+ )
224
+ except Exception:
225
+ pass
166
226
  return False
167
227
 
168
228
 
@@ -201,7 +261,7 @@ def current_holder(store, resource: str) -> str | None:
201
261
  The instance_id of the current holder, or None if unclaimed.
202
262
  """
203
263
  try:
204
- events = store.get("claims")
264
+ events = _read_claim_events(store)
205
265
  claims = fold_claims(events)
206
266
  return claims.get(resource)
207
267
  except Exception:
@@ -44,6 +44,7 @@ def run_gh_command(args):
44
44
  """
45
45
  Run gh CLI command; return parsed JSON or None if gh missing/error.
46
46
  Raises subprocess.CalledProcessError on non-zero exit.
47
+ N9 FIX: Raises TimeoutExpired on timeout (caller handles transient errors).
47
48
  """
48
49
  try:
49
50
  result = subprocess.run(
@@ -59,8 +60,8 @@ def run_gh_command(args):
59
60
  result.check_returncode()
60
61
  return json.loads(result.stdout) if result.stdout.strip() else None
61
62
  except subprocess.TimeoutExpired:
62
- print("ERROR: gh command timed out")
63
- sys.exit(1)
63
+ # N9 FIX: Re-raise timeout so caller (poll loop) can retry
64
+ raise
64
65
  except FileNotFoundError:
65
66
  print("ERROR: gh CLI not found on PATH")
66
67
  sys.exit(1)
@@ -69,12 +70,13 @@ def run_gh_command(args):
69
70
  def get_pr_status(pr_number):
70
71
  """
71
72
  Fetch PR status via gh pr view.
72
- Returns dict with 'mergeable' and 'statusCheckRollup' keys.
73
+ Returns dict with 'mergeable', 'statusCheckRollup', and 'headRefOid' keys.
73
74
  Returns None if gh is missing.
75
+ F5 FIX: headRefOid is fetched to enable SHA-pinned merge via --match-head-commit.
74
76
  """
75
77
  data = run_gh_command([
76
78
  "gh", "pr", "view", str(pr_number),
77
- "--json", "mergeable,statusCheckRollup"
79
+ "--json", "mergeable,statusCheckRollup,headRefOid"
78
80
  ])
79
81
  return data
80
82
 
@@ -119,8 +121,13 @@ def check_ci_status(status_rollup, allow_no_checks=False, expected_checks=None):
119
121
  Returns: ("pending", None), ("success", None), or ("failure", check_name)
120
122
  """
121
123
  # Fail-closed: empty rollup defaults to PENDING unless explicitly allowed
124
+ # F2 FIX: expected_checks takes PRECEDENCE over allow_no_checks
125
+ # If expected_checks is non-empty and rollup is empty, return pending (missing required checks)
122
126
  if not status_rollup:
123
- if allow_no_checks:
127
+ if expected_checks:
128
+ # Expected checks are required but rollup is empty (they're missing)
129
+ return ("pending", None)
130
+ elif allow_no_checks:
124
131
  return ("success", None)
125
132
  else:
126
133
  # Empty rollup: fail-closed to PENDING (window where checks vanished/haven't registered yet)
@@ -148,12 +155,13 @@ def check_ci_status(status_rollup, allow_no_checks=False, expected_checks=None):
148
155
  elif not conclusion or conclusion == "":
149
156
  # COMPLETED with null/empty conclusion = fail-closed to PENDING (API anomaly)
150
157
  check_status = "pending"
151
- elif conclusion.upper() in ("NEUTRAL", "SKIPPED"):
152
- # Non-blocking advisory or skipped checks
158
+ elif conclusion.upper() in ("SUCCESS", "NEUTRAL", "SKIPPED"):
159
+ # SUCCESS, NEUTRAL, SKIPPED are all passing/non-blocking
153
160
  check_status = "success"
154
161
  else:
155
- # Other conclusion values (unknown) = success (GitHub default)
156
- check_status = "success"
162
+ # F6 FIX: Unknown conclusion values must fail-closed to PENDING (not success)
163
+ # Future GitHub API conclusion values should not auto-succeed
164
+ check_status = "pending"
157
165
  elif status in ("QUEUED", "IN_PROGRESS"):
158
166
  check_status = "pending"
159
167
  else:
@@ -202,20 +210,16 @@ def check_ci_status(status_rollup, allow_no_checks=False, expected_checks=None):
202
210
  # Expected check still pending
203
211
  return ("pending", None)
204
212
 
205
- # All expected checks passed - warn about any non-expected check failures but don't block
206
- # Non-expected checks are informational/flaky third-party checks; they should not gate merge
207
- non_expected_failures = []
208
- for check_name, check_status in found_checks.items():
209
- if check_name not in expected_checks and check_status == "failure":
210
- non_expected_failures.append(check_name)
211
-
212
- if non_expected_failures:
213
- # Log the failures loudly but don't block merge
214
- print(f"WARNING: Non-expected check(s) failed (informational, does not block merge): {', '.join(non_expected_failures)}")
215
-
216
- # All expected checks passed - merge is allowed even if non-expected checks are failing
217
- # (pending non-expected checks are also acceptable)
218
- return ("success", None)
213
+ # All expected checks passed. Now check if ANY check failed (expected or not).
214
+ # CRITICAL (BL4 P2 fix): Any check failure blocks merge, not just expected checks.
215
+ # The expected_checks parameter only REQUIRES certain checks to be present and pass,
216
+ # but doesn't exempt other checks from the failure rule (fail-closed semantics).
217
+ if failed_checks:
218
+ return ("failure", failed_checks[0])
219
+ elif pending_checks:
220
+ return ("pending", None)
221
+ else:
222
+ return ("success", None)
219
223
 
220
224
  # Determine overall status (when expected_checks is not specified)
221
225
  if failed_checks:
@@ -226,19 +230,32 @@ def check_ci_status(status_rollup, allow_no_checks=False, expected_checks=None):
226
230
  return ("success", None)
227
231
 
228
232
 
229
- def merge_pr(pr_number, merge_method, dry_run=False):
233
+ def merge_pr(pr_number, merge_method, dry_run=False, head_ref_oid=None):
230
234
  """
231
- Merge the PR using gh pr merge.
235
+ Merge the PR using gh pr merge with SHA-pinned commit.
232
236
  This call is STRUCTURALLY UNREACHABLE unless CI is SUCCESS.
237
+ F5 FIX: Uses --match-head-commit <sha> to ensure merge targets the exact commit
238
+ whose CI checks passed, preventing TOCTOU race where a push between CI check and
239
+ merge could sneak in an untested commit.
240
+ RS-B FIX: Requires headRefOid to be present (fail-closed). Does NOT merge unpinned.
233
241
  If dry_run is True, report what would be done without actually merging.
234
242
  Returns True on success, False on error.
235
243
  """
244
+ # RS-B FIX: Fail-closed if headRefOid is missing - require the SHA pin or abort
245
+ if not head_ref_oid:
246
+ print("ERROR: Cannot merge without SHA pin (headRefOid missing). Aborting merge to prevent TOCTOU race.")
247
+ return False
248
+
236
249
  if dry_run:
237
- print(f"[DRY-RUN] Would merge PR #{pr_number} with --{merge_method}")
250
+ print(f"[DRY-RUN] Would merge PR #{pr_number} with --{merge_method} --match-head-commit {head_ref_oid}")
238
251
  return True
239
252
 
253
+ merge_cmd = ["gh", "pr", "merge", str(pr_number), f"--{merge_method}"]
254
+ # F5 FIX: Add --match-head-commit to pin merge to the SHA that passed CI
255
+ merge_cmd.extend(["--match-head-commit", head_ref_oid])
256
+
240
257
  result = subprocess.run(
241
- ["gh", "pr", "merge", str(pr_number), f"--{merge_method}"],
258
+ merge_cmd,
242
259
  capture_output=True,
243
260
  text=True,
244
261
  timeout=30,
@@ -467,10 +484,10 @@ def run_self_test():
467
484
  return False
468
485
  print("[OK] Expected check failed: returns FAILURE")
469
486
 
470
- # Test 20b: Expected checks all pass, but non-expected check FAILED (P2 audit bug fix)
471
- # FIXED: When --expect-checks is given, non-expected failures should NOT block merge.
472
- # Non-expected checks are informational/flaky third-party checks; only expected checks gate merge.
473
- # The failure is logged loudly as a warning, but merge is allowed.
487
+ # Test 20b: Expected checks all pass, but non-expected check FAILED (P2 audit bug fix - BL4)
488
+ # FIXED: When --expect-checks is given, ANY check failure (expected or not) must block merge.
489
+ # The --expect-checks parameter only REQUIRES certain checks to be present and pass,
490
+ # but doesn't exempt other checks from the failure rule (fail-closed semantics).
474
491
  rollup_expected_pass_noncxpected_fail = [
475
492
  {"name": "unit-tests", "status": "COMPLETED", "conclusion": "SUCCESS"},
476
493
  {"name": "integration-tests", "status": "COMPLETED", "conclusion": "SUCCESS"},
@@ -480,12 +497,12 @@ def run_self_test():
480
497
  rollup_expected_pass_noncxpected_fail,
481
498
  expected_checks={"unit-tests", "integration-tests"}
482
499
  )
483
- if ci_status != "success":
484
- print(f"FAIL: Expected 'success' with non-expected check failed (audit P2 fix), got '{ci_status}'")
500
+ if ci_status != "failure" or failed_check != "lint":
501
+ print(f"FAIL: Expected 'failure' with non-expected check failed (BL4 P2 fix), got '{ci_status}' / '{failed_check}'")
485
502
  return False
486
- print("[OK] Non-expected check failed while expected pass: returns SUCCESS with warning (P2 audit fix)")
503
+ print("[OK] Non-expected check failed while expected pass: returns FAILURE (BL4 P2 audit fix)")
487
504
 
488
- # Test 20c: Expected checks all pass, non-expected pending is OK (does not block)
505
+ # Test 20c: Expected checks all pass, but non-expected pending (must wait for completion)
489
506
  rollup_expected_pass_noncexpected_pending = [
490
507
  {"name": "unit-tests", "status": "COMPLETED", "conclusion": "SUCCESS"},
491
508
  {"name": "integration-tests", "status": "COMPLETED", "conclusion": "SUCCESS"},
@@ -495,10 +512,10 @@ def run_self_test():
495
512
  rollup_expected_pass_noncexpected_pending,
496
513
  expected_checks={"unit-tests", "integration-tests"}
497
514
  )
498
- if ci_status != "success":
499
- print(f"FAIL: Expected 'success' when all expected pass + non-expected pending, got '{ci_status}'")
515
+ if ci_status != "pending":
516
+ print(f"FAIL: Expected 'pending' when all expected pass + non-expected pending, got '{ci_status}'")
500
517
  return False
501
- print("[OK] Expected all pass, non-expected pending: returns SUCCESS (pending non-expected is OK)")
518
+ print("[OK] Expected all pass, non-expected pending: returns PENDING (wait for all checks)")
502
519
 
503
520
  # Test 22: CheckRun STALE conclusion (invalidated check, counts as failure)
504
521
  stale_check = [
@@ -659,7 +676,16 @@ def main():
659
676
  print(f"TIMEOUT: CI did not conclude within {args.timeout}s")
660
677
  sys.exit(3)
661
678
 
662
- status = get_pr_status(args.pr_number)
679
+ # N9 FIX: Catch transient gh errors (CalledProcessError, TimeoutExpired) and retry
680
+ try:
681
+ status = get_pr_status(args.pr_number)
682
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
683
+ print(f"TRANSIENT ERROR: gh command failed ({type(e).__name__}), retrying...")
684
+ # Treat as "status unknown this tick", continue polling
685
+ print(f"CI PENDING ({elapsed:.0f}s elapsed)... waiting {args.poll}s")
686
+ time.sleep(args.poll)
687
+ continue
688
+
663
689
  if status is None:
664
690
  print("ERROR: gh CLI check failed")
665
691
  sys.exit(1)
@@ -677,7 +703,16 @@ def main():
677
703
  elif ci_status == "success":
678
704
  # SUCCESS: CI is green, re-check immediately before proceeding
679
705
  print(f"CI GREEN: All checks passed. Re-checking status before merge...")
680
- final_check = get_pr_status(args.pr_number)
706
+ # N9 FIX: Also catch transient errors on final check
707
+ try:
708
+ final_check = get_pr_status(args.pr_number)
709
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
710
+ print(f"TRANSIENT ERROR: final gh check failed ({type(e).__name__}), retrying...")
711
+ # Treat as status change, go back to polling
712
+ print(f"CI PENDING ({elapsed:.0f}s elapsed)... waiting {args.poll}s")
713
+ time.sleep(args.poll)
714
+ continue
715
+
681
716
  if final_check is None:
682
717
  print("ERROR: final status check failed")
683
718
  sys.exit(1)
@@ -695,19 +730,30 @@ def main():
695
730
 
696
731
  # SUCCESS: CI is still green, proceed to merge
697
732
  # This merge call is STRUCTURALLY UNREACHABLE unless ci_status == "success"
733
+ # F5 FIX: Capture headRefOid for SHA-pinned merge (prevents TOCTOU)
734
+ head_ref_oid = final_check.get("headRefOid")
698
735
  if args.dry_run:
699
736
  print(f"[DRY-RUN] PR #{args.pr_number} CI is green, would merge...")
700
737
  else:
701
738
  print(f"CI CONFIRMED GREEN. Merging PR #{args.pr_number}...")
702
739
 
703
- if merge_pr(args.pr_number, args.merge_method, dry_run=args.dry_run):
740
+ # N9 FIX: Catch TimeoutExpired from merge subprocess and treat as transient error
741
+ try:
742
+ merge_result = merge_pr(args.pr_number, args.merge_method, dry_run=args.dry_run, head_ref_oid=head_ref_oid)
743
+ except subprocess.TimeoutExpired:
744
+ print(f"TRANSIENT ERROR: merge command timed out, retrying...")
745
+ print(f"CI PENDING ({elapsed:.0f}s elapsed)... waiting {args.poll}s")
746
+ time.sleep(args.poll)
747
+ continue
748
+
749
+ if merge_result:
704
750
  if args.dry_run:
705
751
  print(f"[DRY-RUN] PR #{args.pr_number} merge command would succeed")
706
752
  else:
707
753
  print(f"MERGED: PR #{args.pr_number} merged successfully")
708
754
  sys.exit(0)
709
755
  else:
710
- print("ERROR: merge failed (PR state changed?)")
756
+ print("ERROR: merge failed (PR state changed? or missing SHA pin?)")
711
757
  sys.exit(1)
712
758
 
713
759
  # Still pending: wait and retry
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Shard-aware Python test runner for CI.
4
+
5
+ Distributes tracked test files across N shards using round-robin assignment.
6
+ Shard ID and total shard count come from environment variables or CLI args.
7
+
8
+ Solves the multiprocessing spawn-loop problem on Windows: inline heredocs set
9
+ __main__ = "<string>", and spawn-mode children recursively re-import and re-execute
10
+ the script. A guarded __main__ block prevents re-execution in child processes.
11
+
12
+ Usage:
13
+ python tools/ci_shard_runner.py # Uses SHARD_ID, TOTAL_SHARDS env vars
14
+ python tools/ci_shard_runner.py 0 4 # Or pass shard_id and total_shards as args
15
+
16
+ Exit codes:
17
+ 0: All tests passed
18
+ 1: Test failures, import failures, distribution errors, or no tests collected
19
+ """
20
+ import os
21
+ import subprocess
22
+ import sys
23
+ import unittest
24
+ from pathlib import Path
25
+
26
+
27
+ def distribute_shards(test_files, shard_id, total_shards):
28
+ """Distribute test files across shards using round-robin.
29
+
30
+ Args:
31
+ test_files: sorted list of test module names (stems, e.g., 'test_foo')
32
+ shard_id: integer 0..total_shards-1
33
+ total_shards: total number of shards
34
+
35
+ Returns:
36
+ list of test module names assigned to this shard
37
+ """
38
+ return [test_files[i] for i in range(len(test_files)) if i % total_shards == shard_id]
39
+
40
+
41
+ def main():
42
+ """Run Python tests for the assigned shard."""
43
+ # Ensure repo root is in sys.path for test imports
44
+ script_dir = Path(__file__).resolve().parent
45
+ root = script_dir.parent
46
+ if str(root) not in sys.path:
47
+ sys.path.insert(0, str(root))
48
+
49
+ # Parse shard ID and total shards from env or CLI args
50
+ if len(sys.argv) >= 3:
51
+ try:
52
+ shard_id = int(sys.argv[1])
53
+ total_shards = int(sys.argv[2])
54
+ except (ValueError, IndexError):
55
+ print("ERROR: Usage: python ci_shard_runner.py [shard_id total_shards]")
56
+ sys.exit(1)
57
+ else:
58
+ try:
59
+ shard_id = int(os.environ.get("SHARD_ID", os.environ.get("MATRIX_PYTHON_SHARD", "0")))
60
+ total_shards = int(os.environ.get("TOTAL_SHARDS", "4"))
61
+ except ValueError:
62
+ print("ERROR: SHARD_ID and TOTAL_SHARDS must be integers")
63
+ sys.exit(1)
64
+
65
+ # Get tracked test files only (exclude WIP untracked files)
66
+ try:
67
+ result = subprocess.run(
68
+ ["git", "ls-files", "tests/test_*.py"],
69
+ capture_output=True,
70
+ text=True,
71
+ check=True,
72
+ )
73
+ tracked_files = result.stdout.strip().split("\n") if result.stdout.strip() else []
74
+ # Extract just the stem for test discovery
75
+ test_files = sorted(set(Path(f).stem for f in tracked_files if f))
76
+ except Exception as e:
77
+ print(f"ERROR: Failed to get tracked test files: {e}", file=sys.stderr)
78
+ sys.exit(1)
79
+
80
+ # Distribute files across shards (round-robin for balance)
81
+ shard_files = distribute_shards(test_files, shard_id, total_shards)
82
+
83
+ if not shard_files:
84
+ print(
85
+ f"ERROR: No tests assigned to shard {shard_id} (total test files: {len(test_files)})",
86
+ file=sys.stderr,
87
+ )
88
+ print(f"This indicates a configuration error in the shard distribution.", file=sys.stderr)
89
+ sys.exit(1)
90
+
91
+ print(f"Shard {shard_id}: running {len(shard_files)} tests")
92
+ loader = unittest.TestLoader()
93
+ suite = unittest.TestSuite()
94
+ failed_imports = []
95
+
96
+ for test_name in shard_files:
97
+ try:
98
+ module = __import__(f"tests.{test_name}", fromlist=[test_name])
99
+ suite.addTests(loader.loadTestsFromModule(module))
100
+ except Exception as e:
101
+ failed_imports.append((test_name, str(e)))
102
+ print(f"ERROR: Failed to load {test_name}: {e}", file=sys.stderr)
103
+
104
+ # If there were import failures, exit immediately
105
+ if failed_imports:
106
+ print(
107
+ f"\n{len(failed_imports)} test module(s) failed to import:",
108
+ file=sys.stderr,
109
+ )
110
+ for name, error in failed_imports:
111
+ print(f" - {name}: {error}", file=sys.stderr)
112
+ sys.exit(1)
113
+
114
+ # If no tests were collected, this is also an error
115
+ if suite.countTestCases() == 0:
116
+ print(
117
+ f"ERROR: No tests were collected for shard {shard_id}",
118
+ file=sys.stderr,
119
+ )
120
+ sys.exit(1)
121
+
122
+ runner = unittest.TextTestRunner(verbosity=2)
123
+ result = runner.run(suite)
124
+ sys.exit(0 if result.wasSuccessful() else 1)
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()