|
41 | 41 | import enum |
42 | 42 | import fnmatch |
43 | 43 | import json |
44 | | -import math |
45 | 44 | import os |
46 | 45 | import pickle |
47 | 46 | import platform |
|
60 | 59 | import typing |
61 | 60 | import unittest |
62 | 61 | import unittest.loader |
| 62 | +import urllib.request |
63 | 63 | from abc import abstractmethod |
64 | 64 | from collections import defaultdict |
65 | 65 | from dataclasses import dataclass, field |
@@ -550,10 +550,36 @@ def partition_tests_into_processes(self, suites: list['TestSuite']) -> list[list |
550 | 550 | lambda suite: suite.test_file.config.new_worker_per_file, |
551 | 551 | ) |
552 | 552 | partitions = [suite.collected_tests for suite in per_file_suites] |
553 | | - per_partition = int(math.ceil(len(unpartitioned) / max(1, self.num_processes))) |
554 | | - while unpartitioned: |
555 | | - partitions.append([test for suite in unpartitioned[:per_partition] for test in suite.collected_tests]) |
556 | | - unpartitioned = unpartitioned[per_partition:] |
| 553 | + |
| 554 | + # Use timings if available to partition unpartitioned optimally |
| 555 | + timings = {} |
| 556 | + if unpartitioned and self.num_processes: |
| 557 | + configdir = unpartitioned[0].test_file.config.configdir if unpartitioned else None |
| 558 | + if configdir: |
| 559 | + timing_path = configdir / f"timings-{sys.platform.lower()}.json" |
| 560 | + if timing_path.exists(): |
| 561 | + with open(timing_path, "r", encoding="utf-8") as f: |
| 562 | + timings = json.load(f) |
| 563 | + |
| 564 | + timed_files = [] |
| 565 | + for suite in unpartitioned: |
| 566 | + file_path = str(suite.test_file.path).replace("\\", "/") |
| 567 | + total = timings.get(file_path, 20.0) |
| 568 | + timed_files.append((total, suite)) |
| 569 | + |
| 570 | + # Sort descending by expected time |
| 571 | + timed_files.sort(reverse=True, key=lambda x: x[0]) |
| 572 | + |
| 573 | + # Greedily assign to balance by timing sum |
| 574 | + process_loads = [[] for _ in range(self.num_processes)] |
| 575 | + process_times = [0.0] * self.num_processes |
| 576 | + for t, suite in timed_files: |
| 577 | + i = process_times.index(min(process_times)) |
| 578 | + process_loads[i].append(suite) |
| 579 | + process_times[i] += t |
| 580 | + for group in process_loads: |
| 581 | + partitions.append([test for suite in group for test in suite.collected_tests]) |
| 582 | + |
557 | 583 | return partitions |
558 | 584 |
|
559 | 585 | def run_tests(self, tests: list['TestSuite']): |
@@ -1299,6 +1325,31 @@ def get_bool_env(name: str): |
1299 | 1325 | return os.environ.get(name, '').lower() in ('true', '1') |
1300 | 1326 |
|
1301 | 1327 |
|
| 1328 | +def main_extract_test_timings(args): |
| 1329 | + """ |
| 1330 | + Fetches a test log from the given URL, extracts per-file test timings, and writes the output as JSON. |
| 1331 | + """ |
| 1332 | + |
| 1333 | + # Download the log file |
| 1334 | + with urllib.request.urlopen(args.url) as response: |
| 1335 | + log = response.read().decode("utf-8", errors="replace") |
| 1336 | + |
| 1337 | + pattern = re.compile( |
| 1338 | + r"^(?P<path>[^\s:]+)::\S+ +\.\.\. (?:ok|FAIL|ERROR|SKIPPED|expected failure|unexpected success|\S+) \((?P<time>[\d.]+)s\)", |
| 1339 | + re.MULTILINE, |
| 1340 | + ) |
| 1341 | + |
| 1342 | + timings = {} |
| 1343 | + for match in pattern.finditer(log): |
| 1344 | + raw_path = match.group("path").replace("\\", "/") |
| 1345 | + t = float(match.group("time")) |
| 1346 | + timings.setdefault(raw_path, 0.0) |
| 1347 | + timings[raw_path] += t |
| 1348 | + |
| 1349 | + with open(args.output, "w", encoding="utf-8") as f: |
| 1350 | + json.dump(timings, f, indent=2, sort_keys=True) |
| 1351 | + |
| 1352 | + |
1302 | 1353 | def main(): |
1303 | 1354 | is_mx_graalpytest = get_bool_env('MX_GRAALPYTEST') |
1304 | 1355 | parent_parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) |
@@ -1428,6 +1479,20 @@ def main(): |
1428 | 1479 | merge_tags_parser.add_argument('report_path') |
1429 | 1480 |
|
1430 | 1481 | # run the appropriate command |
| 1482 | + |
| 1483 | + # extract-test-timings command declaration |
| 1484 | + extract_parser = subparsers.add_parser( |
| 1485 | + "extract-test-timings", |
| 1486 | + help="Extract per-file test timings from a test log URL and write them as JSON" |
| 1487 | + ) |
| 1488 | + extract_parser.add_argument( |
| 1489 | + "url", help="URL of the test log file" |
| 1490 | + ) |
| 1491 | + extract_parser.add_argument( |
| 1492 | + "output", help="Output JSON file for per-file timings" |
| 1493 | + ) |
| 1494 | + extract_parser.set_defaults(main=main_extract_test_timings) |
| 1495 | + |
1431 | 1496 | args = parent_parser.parse_args() |
1432 | 1497 | args.main(args) |
1433 | 1498 |
|
|
0 commit comments