|
| 1 | +import hashlib |
| 2 | +import json |
1 | 3 | import subprocess |
2 | | -from typing import List, Dict, Optional |
| 4 | +from typing import List, Dict, Optional, Tuple |
3 | 5 | from jinja2 import Environment, FileSystemLoader |
4 | 6 | from dataclasses import dataclass, field |
5 | 7 | import shutil |
6 | 8 | from pathlib import Path |
7 | 9 | from paths import PRECICE_REL_OUTPUT_DIR, PRECICE_TOOLS_DIR, PRECICE_REL_REFERENCE_DIR, PRECICE_TESTS_DIR, PRECICE_TUTORIAL_DIR |
8 | 10 |
|
| 11 | +ITERATIONS_LOGS_DIR = "iterations-logs" |
| 12 | + |
9 | 13 | from metadata_parser.metdata import Tutorial, CaseCombination, Case, ReferenceResult |
10 | 14 | from .SystemtestArguments import SystemtestArguments |
11 | 15 |
|
|
19 | 23 | import os |
20 | 24 |
|
21 | 25 |
|
22 | | -GLOBAL_TIMEOUT = 600 |
| 26 | +GLOBAL_TIMEOUT = 900 |
23 | 27 | SHORT_TIMEOUT = 10 |
24 | 28 |
|
25 | 29 |
|
@@ -134,6 +138,7 @@ class Systemtest: |
134 | 138 | arguments: SystemtestArguments |
135 | 139 | case_combination: CaseCombination |
136 | 140 | reference_result: ReferenceResult |
| 141 | + max_time: Optional[float] = None |
137 | 142 | params_to_use: Dict[str, str] = field(init=False) |
138 | 143 | env: Dict[str, str] = field(init=False) |
139 | 144 |
|
@@ -413,6 +418,88 @@ def _run_field_compare(self): |
413 | 418 | elapsed_time = time.perf_counter() - time_start |
414 | 419 | return FieldCompareResult(1, stdout_data, stderr_data, self, elapsed_time) |
415 | 420 |
|
| 421 | + @staticmethod |
| 422 | + def _sha256_file(path: Path) -> str: |
| 423 | + """Compute SHA-256 hex digest of a file.""" |
| 424 | + h = hashlib.sha256() |
| 425 | + mv = memoryview(bytearray(128 * 1024)) |
| 426 | + with open(path, 'rb', buffering=0) as f: |
| 427 | + while n := f.readinto(mv): |
| 428 | + h.update(mv[:n]) |
| 429 | + return h.hexdigest() |
| 430 | + |
| 431 | + def _collect_iterations_logs( |
| 432 | + self, system_test_dir: Path |
| 433 | + ) -> List[Tuple[str, Path]]: |
| 434 | + """ |
| 435 | + Collect precice-*-iterations.log files from case dirs. |
| 436 | + Returns list of (relative_path, absolute_path) e.g. ("solid-fenics/precice-Solid-iterations.log", path). |
| 437 | + """ |
| 438 | + collected = [] |
| 439 | + for case in self.case_combination.cases: |
| 440 | + case_dir = system_test_dir / Path(case.path).name |
| 441 | + if not case_dir.exists(): |
| 442 | + continue |
| 443 | + for log_file in case_dir.glob("precice-*-iterations.log"): |
| 444 | + if log_file.is_file(): |
| 445 | + rel = f"{Path(case.path).name}/{log_file.name}" |
| 446 | + collected.append((rel, log_file)) |
| 447 | + return collected |
| 448 | + |
| 449 | + def __archive_iterations_logs(self): |
| 450 | + """ |
| 451 | + Copy precice-*-iterations.log from case dirs into iterations-logs/ |
| 452 | + so they are available in CI artifacts (issue #440). |
| 453 | + """ |
| 454 | + collected = self._collect_iterations_logs(self.system_test_dir) |
| 455 | + if not collected: |
| 456 | + return |
| 457 | + dest_dir = self.system_test_dir / ITERATIONS_LOGS_DIR |
| 458 | + dest_dir.mkdir(exist_ok=True) |
| 459 | + for rel, src in collected: |
| 460 | + dest_name = Path(rel).name |
| 461 | + if len(collected) > 1: |
| 462 | + prefix = Path(rel).parent.name + "_" |
| 463 | + dest_name = prefix + dest_name |
| 464 | + shutil.copy2(src, dest_dir / dest_name) |
| 465 | + logging.debug(f"Archived {len(collected)} iterations log(s) to {dest_dir} for {self}") |
| 466 | + |
| 467 | + def __compare_iterations_hashes(self) -> bool: |
| 468 | + """ |
| 469 | + Compare current iterations.log hashes against reference sidecar. |
| 470 | + Returns True if comparison passes (or is skipped). Returns False if hashes differ. |
| 471 | + """ |
| 472 | + sidecar = self.reference_result.path.with_suffix(".iterations-hashes.json") |
| 473 | + if not sidecar.exists(): |
| 474 | + return True |
| 475 | + try: |
| 476 | + ref_hashes = json.loads(sidecar.read_text()) |
| 477 | + except (json.JSONDecodeError, OSError) as e: |
| 478 | + logging.warning(f"Could not read iterations hashes from {sidecar}: {e}") |
| 479 | + return True |
| 480 | + if not ref_hashes: |
| 481 | + return True |
| 482 | + collected = self._collect_iterations_logs(self.system_test_dir) |
| 483 | + current = {rel: self._sha256_file(p) for rel, p in collected} |
| 484 | + for rel, expected in ref_hashes.items(): |
| 485 | + if rel not in current: |
| 486 | + logging.critical( |
| 487 | + f"Missing iterations log {rel} (expected from reference); {self} fails" |
| 488 | + ) |
| 489 | + return False |
| 490 | + if current[rel] != expected: |
| 491 | + logging.critical( |
| 492 | + f"Hash mismatch for {rel} (iterations.log regression); {self} fails" |
| 493 | + ) |
| 494 | + return False |
| 495 | + if len(current) != len(ref_hashes): |
| 496 | + extra = set(current) - set(ref_hashes) |
| 497 | + logging.critical( |
| 498 | + f"Unexpected iterations log(s) {extra}; {self} fails" |
| 499 | + ) |
| 500 | + return False |
| 501 | + return True |
| 502 | + |
416 | 503 | def _build_docker(self): |
417 | 504 | """ |
418 | 505 | Builds the docker image |
@@ -513,11 +600,56 @@ def __write_logs(self, stdout_data: List[str], stderr_data: List[str]): |
513 | 600 | with open(self.system_test_dir / "stderr.log", 'w') as stderr_file: |
514 | 601 | stderr_file.write("\n".join(stderr_data)) |
515 | 602 |
|
| 603 | + def __apply_precice_max_time_override(self): |
| 604 | + """ |
| 605 | + If max_time is set, override <max-time value="..."> in precice-config.xml |
| 606 | + of the copied tutorial directory. Applies to both test runs and reference generation. |
| 607 | + Uses a precise pattern to target only <max-time> tags. |
| 608 | + """ |
| 609 | + if self.max_time is None: |
| 610 | + return |
| 611 | + if not (isinstance(self.max_time, (int, float)) and self.max_time > 0): |
| 612 | + logging.warning( |
| 613 | + f"Invalid max_time {self.max_time} for {self}; must be a positive number. Skipping override.") |
| 614 | + return |
| 615 | + config_path = self.system_test_dir / "precice-config.xml" |
| 616 | + if not config_path.exists(): |
| 617 | + logging.warning( |
| 618 | + f"Requested max_time override for {self}, but no precice-config.xml " |
| 619 | + f"found in {self.system_test_dir}" |
| 620 | + ) |
| 621 | + return |
| 622 | + try: |
| 623 | + text = config_path.read_text() |
| 624 | + except Exception as e: |
| 625 | + logging.warning(f"Could not read {config_path} to apply max_time override: {e}") |
| 626 | + return |
| 627 | + # Target only <max-time value="..."> to avoid modifying time-window-size etc. |
| 628 | + pattern = r'(<max-time\s+value=")([^"]*)(")' |
| 629 | + matches = re.findall(pattern, text) |
| 630 | + if not matches: |
| 631 | + logging.warning( |
| 632 | + f"Requested max_time override for {self}, but no <max-time> tag " |
| 633 | + f"found in {config_path}" |
| 634 | + ) |
| 635 | + return |
| 636 | + if len(matches) > 1: |
| 637 | + logging.warning( |
| 638 | + f"Multiple <max-time> tags found in {config_path}; overriding all to {self.max_time}" |
| 639 | + ) |
| 640 | + new_text = re.sub(pattern, rf"\g<1>{self.max_time}\g<3>", text) |
| 641 | + try: |
| 642 | + config_path.write_text(new_text) |
| 643 | + logging.info(f"Overwrote max-time in {config_path} to {self.max_time} for {self}") |
| 644 | + except Exception as e: |
| 645 | + logging.warning(f"Failed to write updated {config_path}: {e}") |
| 646 | + |
516 | 647 | def __prepare_for_run(self, run_directory: Path): |
517 | 648 | """ |
518 | 649 | Prepares the run_directory with folders and datastructures needed for every systemtest execution |
519 | 650 | """ |
520 | 651 | self.__copy_tutorial_into_directory(run_directory) |
| 652 | + self.__apply_precice_max_time_override() |
521 | 653 | self.__copy_tools(run_directory) |
522 | 654 | self.__put_gitignore(run_directory) |
523 | 655 | host_uid, host_gid = self.__get_uid_gid() |
@@ -562,6 +694,21 @@ def run(self, run_directory: Path): |
562 | 694 | solver_time=docker_run_result.runtime, |
563 | 695 | fieldcompare_time=0) |
564 | 696 |
|
| 697 | + self.__archive_iterations_logs() |
| 698 | + if not self.__compare_iterations_hashes(): |
| 699 | + self.__write_logs(std_out, std_err) |
| 700 | + logging.critical( |
| 701 | + f"Iterations.log hash comparison failed (regression), {self} failed" |
| 702 | + ) |
| 703 | + return SystemtestResult( |
| 704 | + False, |
| 705 | + std_out, |
| 706 | + std_err, |
| 707 | + self, |
| 708 | + build_time=docker_build_result.runtime, |
| 709 | + solver_time=docker_run_result.runtime, |
| 710 | + fieldcompare_time=0) |
| 711 | + |
565 | 712 | fieldcompare_result = self._run_field_compare() |
566 | 713 | std_out.extend(fieldcompare_result.stdout_data) |
567 | 714 | std_err.extend(fieldcompare_result.stderr_data) |
|
0 commit comments