diff --git a/changelog-entries/732.md b/changelog-entries/732.md new file mode 100644 index 000000000..a85a5a4b6 --- /dev/null +++ b/changelog-entries/732.md @@ -0,0 +1 @@ +- Added optional `source` blocks in `tests.yaml` so system tests can load tutorials from external git repositories or archives ([#732](https://github.com/precice/tutorials/pull/732)). diff --git a/tools/tests/README.md b/tools/tests/README.md index 0b4b17764..67d48cacb 100644 --- a/tools/tests/README.md +++ b/tools/tests/README.md @@ -170,6 +170,40 @@ Note that you will need to define the `TUTORIALS_REF` in the file [`reference_ve The results will be added to a Git LFS, but you will need special push access: just use the aforementioned GitHub Actions workflow, instead. +#### External test sources + +Local test cases are defined in the `tutorials:` list. For test cases maintained in other git repositories or available as archives, use the `external:` list. A test suite may define both lists so that external cases can be referenced via YAML anchors alongside local tutorials (for example in the `release` suite). + +```yaml +test_suites: + some-external-test-case: + external: + - &some-external-test-case + source: + type: git + url: https://github.com/some-user/tutorials.git + ref: some-branch + subdir: . + path: path-to-test-case + case_combination: + ... + + release: + tutorials: + - *quickstart_openfoam_cpp + external: + - *some-external-test-case +``` + +Supported `source.type` values: + +- `git`: shallow-clone `url` at `ref`. The `ref` must exist on the remote; clone failures are reported as errors. +- `archive`: download and extract a `.tar.gz` / `.zip` from `url`. + +Fetched external sources are cached under `~/.cache/precice-tutorials` (or `PRECICE_EXTERNAL_CACHE_DIR`) so that repeated test runs and CI matrix jobs do not re-download the same repository or archive every time. The cache key is derived from the source URL, ref, and optional subdir. + +The runner fetches the tutorial, copies it into the run directory, and then continues with the usual Docker build/run and fieldcompare steps. `TUTORIALS_REF` / `TUTORIALS_PR` build arguments apply only to test cases sourced from the tutorials repository; external test cases are pinned by `source.ref`. Reference result paths are resolved relative to the root directory of the fetched test case. + ### Adding new components To add a new component, a few changes are needed: @@ -299,7 +333,7 @@ cases: Description: - `name`: A human-readable, descriptive name -- `path`: Where the tutorial is located, relative to the tutorials repository +- `path`: Where the tutorial is located, relative to the tutorials repository (or the tutorial folder name for external sources) - `url`: A web page with more information on the tutorial - `participants`: A list of preCICE participants, typically corresponding to different domains of the simulation - `cases`: A list of solver configuration directories. Each element of the list includes: diff --git a/tools/tests/metadata_parser/metdata.py b/tools/tests/metadata_parser/metdata.py index 882fcca2a..0375bf987 100644 --- a/tools/tests/metadata_parser/metdata.py +++ b/tools/tests/metadata_parser/metdata.py @@ -6,6 +6,8 @@ import itertools from paths import PRECICE_TESTS_DIR, PRECICE_TUTORIAL_DIR +from systemtests.sources import TutorialSource + @dataclass class BuildArgument: @@ -283,13 +285,15 @@ def from_cases_tuple(cls, cases: Tuple[Case], tutorial: Tutorial): class ReferenceResult: path: Path case_combination: CaseCombination + base_dir: Path | None = None def __repr__(self) -> str: return f"{self.path.as_posix()}" def __post_init__(self): # built full path - self.path = PRECICE_TUTORIAL_DIR / self.path + base = self.base_dir if self.base_dir is not None else PRECICE_TUTORIAL_DIR + self.path = Path(base) / self.path @dataclass @@ -303,6 +307,10 @@ class Tutorial: url: str participants: List[str] cases: List[Case] + source: "TutorialSource" = field(default_factory=TutorialSource.local) + # Filesystem path to the fetched external tutorial, resolved once at parse + # time and reused when copying into the run directory (None for local). + resolved_root: "Path | None" = None case_combinations: List[CaseCombination] = field(init=False) def __post_init__(self): @@ -359,13 +367,16 @@ def get_case_by_string(self, case_name: str) -> Optional[Case]: return None @classmethod - def from_yaml(cls, path, available_components): + def from_yaml(cls, path, available_components, base_dir=None, source=None): """ Creates a Tutorial instance from a YAML file. Args: - path: The path to the YAML file. + path: The path to the metadata.yaml file. available_components: The Components instance containing available components. + base_dir: Optional base directory for resolving tutorial path (for external sources). + Defaults to PRECICE_TUTORIAL_DIR. + source: Optional TutorialSource (for external tutorials). Returns: An instance of Tutorial. @@ -373,7 +384,8 @@ def from_yaml(cls, path, available_components): with open(path, 'r') as f: data = yaml.safe_load(f) name = data['name'] - path = PRECICE_TUTORIAL_DIR / data['path'] + base = base_dir if base_dir is not None else PRECICE_TUTORIAL_DIR + tutorial_path = Path(base) / data['path'] url = data['url'] participants = data.get('participants', []) cases_raw = data.get('cases', {}) @@ -381,7 +393,10 @@ def from_yaml(cls, path, available_components): for case_name in cases_raw.keys(): cases.append(Case.from_dict( case_name, cases_raw[case_name], available_components)) - return cls(name, path, url, participants, cases) + tut = cls(name, tutorial_path, url, participants, cases) + if source is not None: + tut.source = source + return tut class Tutorials(list): diff --git a/tools/tests/systemtests/Systemtest.py b/tools/tests/systemtests/Systemtest.py index 7df76460c..021fe7255 100644 --- a/tools/tests/systemtests/Systemtest.py +++ b/tools/tests/systemtests/Systemtest.py @@ -1,6 +1,7 @@ import hashlib import subprocess import threading +from .sources import resolve_tutorial_root, PRECICE_EXTERNAL_CACHE_DIR from typing import List, Dict, Optional, Tuple from jinja2 import Environment, FileSystemLoader from dataclasses import dataclass, field @@ -480,24 +481,36 @@ def __copy_tutorial_into_directory(self, run_directory: Path): """ current_time_string = datetime.now().strftime('%Y-%m-%d %H:%M:%S') self.run_directory = run_directory - pr_requested = self.params_to_use.get("TUTORIALS_PR") - if pr_requested: - logging.debug(f"Fetching the PR {pr_requested} HEAD reference") - self._fetch_pr(PRECICE_TUTORIAL_DIR, pr_requested) - current_ref = self._get_git_ref(PRECICE_TUTORIAL_DIR) - ref_requested = self.params_to_use.get("TUTORIALS_REF") - if ref_requested: - logging.debug(f"Checking out tutorials {ref_requested} before copying") - self._fetch_ref(PRECICE_TUTORIAL_DIR, ref_requested) - self._checkout_ref_in_subfolder(PRECICE_TUTORIAL_DIR, self.tutorial.path, ref_requested) - - self.tutorial_folder = slugify(f'{self.tutorial.path.name}_{self.case_combination.cases}_{current_time_string}') + current_ref = None + ref_requested = None + + if self.tutorial.source.type == "local": + pr_requested = self.params_to_use.get("TUTORIALS_PR") + if pr_requested: + logging.debug(f"Fetching the PR {pr_requested} HEAD reference") + self._fetch_pr(PRECICE_TUTORIAL_DIR, pr_requested) + current_ref = self._get_git_ref(PRECICE_TUTORIAL_DIR) + ref_requested = self.params_to_use.get("TUTORIALS_REF") + if ref_requested: + logging.debug(f"Checking out tutorials {ref_requested} before copying") + self._fetch_ref(PRECICE_TUTORIAL_DIR, ref_requested) + self._checkout_ref_in_subfolder( + PRECICE_TUTORIAL_DIR, self.tutorial.path, ref_requested) + + self.tutorial_folder = slugify( + f'{self.tutorial.path.name}_{self.case_combination.cases}_{current_time_string}') destination = run_directory / self.tutorial_folder - src = self.tutorial.path + # External sources are fetched and resolved once at parse time; reuse + # that path here to avoid a redundant fetch (and duplicate log line). + src = self.tutorial.resolved_root or resolve_tutorial_root( + self.tutorial.path, + self.tutorial.source, + PRECICE_EXTERNAL_CACHE_DIR, + ) self.system_test_dir = destination shutil.copytree(src, destination) - if ref_requested: + if self.tutorial.source.type == "local" and ref_requested: with open(destination / "tutorials_ref", 'w') as file: file.write(ref_requested) self._checkout_ref_in_subfolder(PRECICE_TUTORIAL_DIR, self.tutorial.path, current_ref) @@ -971,7 +984,8 @@ def _run_tutorial(self): return DockerComposeResult(exit_code, stdout_data, stderr_data, self, elapsed_time) def __repr__(self): - return f"{self.tutorial.name} {self.case_combination}" + prefix = "External: " if getattr(self.tutorial.source, "type", "local") != "local" else "" + return f"{prefix}{self.tutorial.name} {self.case_combination}" def __apply_max_time_override(self): """Overwrite or value in precice-config.xml.""" diff --git a/tools/tests/systemtests/TestSuite.py b/tools/tests/systemtests/TestSuite.py index e963a2f52..adf063988 100644 --- a/tools/tests/systemtests/TestSuite.py +++ b/tools/tests/systemtests/TestSuite.py @@ -1,6 +1,20 @@ from dataclasses import dataclass, field +from pathlib import Path from typing import Optional, List, Dict -from metadata_parser.metdata import Tutorials, Tutorial, Case, CaseCombination, ReferenceResult +from metadata_parser.metdata import ( + Tutorials, + Tutorial, + Case, + CaseCombination, + ReferenceResult, + Components, +) +from paths import PRECICE_TESTS_DIR +from systemtests.sources import ( + TutorialSource, + resolve_tutorial_root, + PRECICE_EXTERNAL_CACHE_DIR, +) import yaml @@ -49,6 +63,7 @@ def from_yaml(cls, path, parsed_tutorials: Tutorials): An instance of TestSuites. """ testsuites = [] + available_components = Components.from_yaml(PRECICE_TESTS_DIR / "components.yaml") with open(path, 'r') as f: data = yaml.safe_load(f) test_suites_raw = data['test_suites'] @@ -62,81 +77,59 @@ def from_yaml(cls, path, parsed_tutorials: Tutorials): skip_compares_of_tutorial = {} run_befores_of_tutorial = {} run_afters_of_tutorial = {} - # iterate over tutorials: - for tutorial_case in test_suites_raw[test_suite_name]['tutorials']: - tutorial = parsed_tutorials.get_by_path(tutorial_case['path']) - if not tutorial: - raise Exception(f"No tutorial with path {tutorial_case['path']} found.") - # initialize the datastructure for the new Testsuite - if tutorial not in case_combinations_of_tutorial: - case_combinations_of_tutorial[tutorial] = [] - reference_results_of_tutorial[tutorial] = [] - max_times_of_tutorial[tutorial] = [] - max_time_windows_of_tutorial[tutorial] = [] - timeouts_of_tutorial[tutorial] = [] - tolerances_of_tutorial[tutorial] = [] - skip_compares_of_tutorial[tutorial] = [] - run_befores_of_tutorial[tutorial] = [] - run_afters_of_tutorial[tutorial] = [] - - all_case_combinations = tutorial.case_combinations - case_combination_requested = CaseCombination.from_string_list( - tutorial_case['case_combination'], tutorial) - if case_combination_requested in all_case_combinations: - case_combinations_of_tutorial[tutorial].append(case_combination_requested) - reference_results_of_tutorial[tutorial].append(ReferenceResult( - tutorial_case['reference_result'], case_combination_requested)) - max_time_raw = tutorial_case.get('max_time', None) - if max_time_raw is not None and (not isinstance( - max_time_raw, (int, float)) or max_time_raw <= 0): - raise ValueError(f"max_time must be a positive number, got {max_time_raw!r}") - max_times_of_tutorial[tutorial].append(max_time_raw) - mtw_raw = tutorial_case.get('max_time_windows', None) - if mtw_raw is not None and (not isinstance(mtw_raw, int) or mtw_raw <= 0): - raise ValueError(f"max_time_windows must be a positive integer, got {mtw_raw!r}") - max_time_windows_of_tutorial[tutorial].append(mtw_raw) - - timeout_value = tutorial_case.get('timeout', None) - if timeout_value is not None and not isinstance(timeout_value, int): - raise TypeError( - f"Expected 'timeout' to be an integer or None, but got {type(timeout_value).__name__} " - f"(value: {timeout_value}) in tutorial '{tutorial}'." - ) - timeouts_of_tutorial[tutorial].append(timeout_value) - - tolerance_value = tutorial_case.get('tolerance', None) - if tolerance_value is not None: - if isinstance(tolerance_value, str): - try: - tolerance_value = float(tolerance_value) - except ValueError as exc: - raise ValueError( - f"tolerance must be a positive number, got {tolerance_value!r}") from exc - if not isinstance(tolerance_value, (int, float)) or tolerance_value <= 0: - raise ValueError( - f"tolerance must be a positive number, got {tolerance_value!r}") - tolerances_of_tutorial[tutorial].append(tolerance_value) - - skip_compare_value = tutorial_case.get('skip_compare', None) - if skip_compare_value is not None and not isinstance(skip_compare_value, bool): - raise TypeError( - f"Expected 'skip_compare' to be a boolean or None, but got " - f"{type(skip_compare_value).__name__} (value: {skip_compare_value}) " - f"in tutorial '{tutorial}'." - ) - skip_compares_of_tutorial[tutorial].append(skip_compare_value) - - run_before_raw = tutorial_case.get('run-before', None) - run_after_raw = tutorial_case.get('run-after', None) - run_befores_of_tutorial[tutorial].append( - run_before_raw.strip() - if isinstance(run_before_raw, str) and run_before_raw.strip() else None) - run_afters_of_tutorial[tutorial].append( - run_after_raw.strip() - if isinstance(run_after_raw, str) and run_after_raw.strip() else None) - else: - raise Exception( - f"Could not find the case combination {tutorial_case['case_combination']} in the current metadata of tutorial {tutorial.name}, or it does not define all necessary participants.") + suite_def = test_suites_raw[test_suite_name] + local_cases = suite_def.get('tutorials', []) + external_cases = suite_def.get('external', []) + if not local_cases and not external_cases: + raise ValueError( + f"Test suite '{test_suite_name}' must define 'tutorials' or 'external'." + ) + + for tutorial_case in local_cases: + cls._add_tutorial_case( + tutorial_case, + TutorialSource.local(), + parsed_tutorials, + available_components, + case_combinations_of_tutorial, + reference_results_of_tutorial, + max_times_of_tutorial, + max_time_windows_of_tutorial, + timeouts_of_tutorial, + tolerances_of_tutorial, + skip_compares_of_tutorial, + run_befores_of_tutorial, + run_afters_of_tutorial, + ) + + for tutorial_case in external_cases: + source_raw = tutorial_case.get('source') + if not source_raw: + raise ValueError( + f"External test entry in suite '{test_suite_name}' " + f"requires a 'source' block." + ) + source = TutorialSource.from_dict(source_raw) + if source.type == "local": + raise ValueError( + f"External test entry in suite '{test_suite_name}' " + f"must use a git or archive source." + ) + cls._add_tutorial_case( + tutorial_case, + source, + parsed_tutorials, + available_components, + case_combinations_of_tutorial, + reference_results_of_tutorial, + max_times_of_tutorial, + max_time_windows_of_tutorial, + timeouts_of_tutorial, + tolerances_of_tutorial, + skip_compares_of_tutorial, + run_befores_of_tutorial, + run_afters_of_tutorial, + ) testsuites.append(TestSuite( test_suite_name, @@ -153,6 +146,121 @@ def from_yaml(cls, path, parsed_tutorials: Tutorials): return cls(testsuites) + @staticmethod + def _add_tutorial_case( + tutorial_case: dict, + source: TutorialSource, + parsed_tutorials: Tutorials, + available_components: Components, + case_combinations_of_tutorial: Dict[Tutorial, List[CaseCombination]], + reference_results_of_tutorial: Dict[Tutorial, List[ReferenceResult]], + max_times_of_tutorial: Dict[Tutorial, list], + max_time_windows_of_tutorial: Dict[Tutorial, list], + timeouts_of_tutorial: Dict[Tutorial, List], + tolerances_of_tutorial: Dict[Tutorial, list], + skip_compares_of_tutorial: Dict[Tutorial, list], + run_befores_of_tutorial: Dict[Tutorial, list], + run_afters_of_tutorial: Dict[Tutorial, list], + ) -> None: + tutorial = parsed_tutorials.get_by_path(tutorial_case['path']) + if not tutorial and source.type != "local": + tutorial_root = resolve_tutorial_root( + Path(tutorial_case['path']), + source, + PRECICE_EXTERNAL_CACHE_DIR, + ) + metadata_path = tutorial_root / "metadata.yaml" + if not metadata_path.exists(): + raise FileNotFoundError( + f"No metadata.yaml found for external tutorial " + f"{tutorial_case['path']} at {tutorial_root}" + ) + tutorial = Tutorial.from_yaml( + metadata_path, + available_components, + base_dir=tutorial_root.parent, + source=source, + ) + tutorial.resolved_root = tutorial_root + parsed_tutorials.tutorials.append(tutorial) + if not tutorial: + raise Exception(f"No tutorial with path {tutorial_case['path']} found.") + if tutorial not in case_combinations_of_tutorial: + case_combinations_of_tutorial[tutorial] = [] + reference_results_of_tutorial[tutorial] = [] + max_times_of_tutorial[tutorial] = [] + max_time_windows_of_tutorial[tutorial] = [] + timeouts_of_tutorial[tutorial] = [] + tolerances_of_tutorial[tutorial] = [] + skip_compares_of_tutorial[tutorial] = [] + run_befores_of_tutorial[tutorial] = [] + run_afters_of_tutorial[tutorial] = [] + + all_case_combinations = tutorial.case_combinations + case_combination_requested = CaseCombination.from_string_list( + tutorial_case['case_combination'], tutorial) + if case_combination_requested in all_case_combinations: + case_combinations_of_tutorial[tutorial].append(case_combination_requested) + ref_base = tutorial.path.parent if source.type != "local" else None + reference_results_of_tutorial[tutorial].append(ReferenceResult( + tutorial_case['reference_result'], + case_combination_requested, + base_dir=ref_base, + )) + max_time_raw = tutorial_case.get('max_time', None) + if max_time_raw is not None and (not isinstance( + max_time_raw, (int, float)) or max_time_raw <= 0): + raise ValueError(f"max_time must be a positive number, got {max_time_raw!r}") + max_times_of_tutorial[tutorial].append(max_time_raw) + mtw_raw = tutorial_case.get('max_time_windows', None) + if mtw_raw is not None and (not isinstance(mtw_raw, int) or mtw_raw <= 0): + raise ValueError(f"max_time_windows must be a positive integer, got {mtw_raw!r}") + max_time_windows_of_tutorial[tutorial].append(mtw_raw) + + timeout_value = tutorial_case.get('timeout', None) + if timeout_value is not None and not isinstance(timeout_value, int): + raise TypeError( + f"Expected 'timeout' to be an integer or None, but got {type(timeout_value).__name__} " + f"(value: {timeout_value}) in tutorial '{tutorial}'." + ) + timeouts_of_tutorial[tutorial].append(timeout_value) + + tolerance_value = tutorial_case.get('tolerance', None) + if tolerance_value is not None: + if isinstance(tolerance_value, str): + try: + tolerance_value = float(tolerance_value) + except ValueError as exc: + raise ValueError( + f"tolerance must be a positive number, got {tolerance_value!r}") from exc + if not isinstance(tolerance_value, (int, float)) or tolerance_value <= 0: + raise ValueError( + f"tolerance must be a positive number, got {tolerance_value!r}") + tolerances_of_tutorial[tutorial].append(tolerance_value) + + skip_compare_value = tutorial_case.get('skip_compare', None) + if skip_compare_value is not None and not isinstance(skip_compare_value, bool): + raise TypeError( + f"Expected 'skip_compare' to be a boolean or None, but got " + f"{type(skip_compare_value).__name__} (value: {skip_compare_value}) " + f"in tutorial '{tutorial}'." + ) + skip_compares_of_tutorial[tutorial].append(skip_compare_value) + + run_before_raw = tutorial_case.get('run-before', None) + run_after_raw = tutorial_case.get('run-after', None) + run_befores_of_tutorial[tutorial].append( + run_before_raw.strip() + if isinstance(run_before_raw, str) and run_before_raw.strip() else None) + run_afters_of_tutorial[tutorial].append( + run_after_raw.strip() + if isinstance(run_after_raw, str) and run_after_raw.strip() else None) + else: + raise Exception( + f"Could not find the case combination {tutorial_case['case_combination']} " + f"in the current metadata of tutorial {tutorial.name}, or it does not " + f"define all necessary participants.") + def __iter__(self): return iter(self.testsuites) diff --git a/tools/tests/systemtests/sources.py b/tools/tests/systemtests/sources.py new file mode 100644 index 000000000..ee9fce94c --- /dev/null +++ b/tools/tests/systemtests/sources.py @@ -0,0 +1,192 @@ +""" +Support for external tutorial sources (git, archive) in systemtests. +""" + +import hashlib +import logging +import os +import shutil +import stat +import subprocess +import tarfile +import tempfile +from dataclasses import dataclass +from pathlib import Path + +# Same env var as Systemtest.GLOBAL_TIMEOUT (not imported to avoid circular deps). +_FETCH_TIMEOUT = int(os.environ.get("PRECICE_SYSTEMTESTS_TIMEOUT", 180)) + +# Cache directory for fetched tutorials. Can be overridden via PRECICE_EXTERNAL_CACHE_DIR env. +_DEFAULT_CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "precice-tutorials" +PRECICE_EXTERNAL_CACHE_DIR = Path(os.environ.get("PRECICE_EXTERNAL_CACHE_DIR", _DEFAULT_CACHE)) + + +@dataclass +class TutorialSource: + """Describes where a test case (tutorial) is sourced from (tutorials repository or external source).""" + + type: str # "local" | "git" | "archive" + url: str | None = None + ref: str | None = None + subdir: str | None = None + + @classmethod + def local(cls) -> "TutorialSource": + return cls(type="local") + + @classmethod + def from_dict(cls, data: dict) -> "TutorialSource": + if data is None or data.get("type") == "local": + return cls.local() + return cls( + type=data["type"], + url=data.get("url"), + ref=data.get("ref"), + subdir=data.get("subdir"), + ) + + +def _cache_key(prefix: str, url: str, ref: str | None = None, subdir: str | None = None) -> str: + """Generate a short content-addressable cache key.""" + parts = [url] + if ref: + parts.append(ref) + if subdir: + parts.append(subdir) + raw = f"{prefix}:{':'.join(parts)}" + return hashlib.sha256(raw.encode()).hexdigest()[:16] + + +def _ensure_shell_scripts_executable(root: Path) -> None: + """Mark all .sh files under root as executable. + + ZIP extraction does not preserve Unix permission bits, so run.sh and other + tutorial scripts lose their execute bit. The original modes are not known, + so this unconditionally adds execute bits to every .sh file. Intended only + for extracted tutorial/test-case archives. + """ + for script in root.rglob("*.sh"): + script.chmod(script.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def fetch_git_repo(url: str, ref: str, cache_dir: Path, subdir: str | None = None) -> Path: + """ + Clone or update a git repository and return the path to the checkout. + If subdir is given, returns the path to that subdirectory within the repo. + """ + cache_dir.mkdir(parents=True, exist_ok=True) + key = _cache_key("git", url, ref, subdir) + checkout = cache_dir / key + + if checkout.exists(): + logging.info(f"Using cached external git source {url} (ref {ref}) from {checkout}") + try: + subprocess.run( + ["git", "-C", str(checkout), "fetch", "origin", ref, "--depth", "1"], + check=True, + capture_output=True, + timeout=_FETCH_TIMEOUT, + ) + subprocess.run( + ["git", "-C", str(checkout), "checkout", "FETCH_HEAD"], + check=True, + capture_output=True, + timeout=_FETCH_TIMEOUT, + ) + except subprocess.CalledProcessError as e: + logging.warning(f"Git fetch/checkout failed for {url}, recloning: {e}") + shutil.rmtree(checkout, ignore_errors=True) + + if not checkout.exists(): + logging.info(f"Fetching external git source {url} (ref {ref}) into {checkout}") + result = subprocess.run( + ["git", "clone", "--depth", "1", "--branch", ref, url, str(checkout)], + capture_output=True, + text=True, + timeout=_FETCH_TIMEOUT, + ) + if result.returncode != 0: + shutil.rmtree(checkout, ignore_errors=True) + raise RuntimeError( + f"git clone --branch {ref!r} failed for {url}: {result.stderr}" + ) + + if subdir: + subpath = checkout / subdir + if not subpath.is_dir(): + raise FileNotFoundError(f"Subdirectory {subdir} not found in {url} (ref {ref})") + return subpath + return checkout + + +def fetch_archive(url: str, cache_dir: Path, subdir: str | None = None) -> Path: + """ + Download and extract an archive (tar.gz, tar, zip) and return the path. + """ + import urllib.request + + cache_dir.mkdir(parents=True, exist_ok=True) + key = _cache_key("archive", url, subdir=subdir) + extract_dir = cache_dir / key + + if extract_dir.exists(): + logging.info(f"Using cached external archive {url} from {extract_dir}") + _ensure_shell_scripts_executable(extract_dir) + return extract_dir / subdir if subdir else extract_dir + + with tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz") as tmp: + tmp_path = Path(tmp.name) + try: + logging.info(f"Fetching external archive {url} into {extract_dir}") + urllib.request.urlretrieve(url, tmp_path) + + extract_dir.mkdir(parents=True, exist_ok=True) + if url.endswith(".tar.gz") or url.endswith(".tgz") or url.endswith(".tar"): + with tarfile.open(tmp_path, "r:*") as tf: + tf.extractall(extract_dir) + else: + import zipfile + + with zipfile.ZipFile(tmp_path, "r") as zf: + zf.extractall(extract_dir) + _ensure_shell_scripts_executable(extract_dir) + finally: + tmp_path.unlink(missing_ok=True) + + if subdir: + subpath = extract_dir / subdir + if not subpath.is_dir(): + raise FileNotFoundError(f"Subdirectory {subdir} not found in {url}") + return subpath + return extract_dir + + +def resolve_tutorial_root( + path: Path, + source: TutorialSource, + cache_dir: Path, +) -> Path: + """ + Resolve the filesystem path to the tutorial root. + + For local sources, returns path as-is (already under PRECICE_TUTORIAL_DIR). + For git/archive sources, fetches the repository/archive and returns the path + to the tutorial directory. The tutorial name (path.name) is used as the + subdirectory within the fetched content. + """ + if source.type == "local": + return path + + if source.type == "git": + if not source.url or not source.ref: + raise ValueError("git source requires 'url' and 'ref'") + root = fetch_git_repo(source.url, source.ref, cache_dir, source.subdir) + return root / path.name + + if source.type == "archive": + if not source.url: + raise ValueError("archive source requires 'url'") + root = fetch_archive(source.url, cache_dir, source.subdir) + return root / path.name + + raise ValueError(f"Unknown source type: {source.type}") diff --git a/tools/tests/tests.yaml b/tools/tests/tests.yaml index f5377778b..6bfd3bb54 100644 --- a/tools/tests/tests.yaml +++ b/tools/tests/tests.yaml @@ -568,6 +568,40 @@ test_suites: max_time: 20 reference_result: ./wolf-sheep-soil-creep/reference-results/soil-creep-landlab_wolf-sheep-grass-mesa.tar.gz +##################################################################### +## Externally-hosted test cases + partitioned-heat-conduction-robin: + external: + - &partitioned-heat-conduction-robin_left-openfoam_right-openfoam + source: + type: git + url: https://github.com/vidulejs/tutorials.git + ref: partitioned-heat-conduction-mixed-robin + subdir: . + path: partitioned-heat-conduction-robin + case_combination: + - left-openfoam + - right-openfoam + timeout: 480 + skip_compare: true + reference_result: ./partitioned-heat-conduction-robin/reference_results/left-openfoam_right-openfoam.tar.gz + + partitioned-heat-conduction-mixedbc: + external: + - &partitioned-heat-conduction-mixedbc_left-openfoam_right-openfoam + source: + type: archive + url: https://github.com/vidulejs/tutorials/archive/refs/heads/partitioned-heat-conduction-mixed-robin.zip + subdir: tutorials-partitioned-heat-conduction-mixed-robin + path: partitioned-heat-conduction-mixedbc + case_combination: + - left-openfoam + - right-openfoam + timeout: 480 + skip_compare: true + reference_result: ./partitioned-heat-conduction-mixedbc/reference_results/left-openfoam_right-openfoam.tar.gz + + ##################################################################### ## Test suites referring to the test suites defined above @@ -631,6 +665,9 @@ test_suites: - *volume-coupled-flow_fluid-openfoam_source-nutils - *water-hammer_fluid1d-left-nutils_fluid3d-right-openfoam - *wolf-sheep-soil-creep_soil-creep-landlab_wolf-sheep-grass-mesa + external: + - *partitioned-heat-conduction-mixedbc_left-openfoam_right-openfoam + - *partitioned-heat-conduction-robin_left-openfoam_right-openfoam # These tests take longer to run. They are available, but not regularly executed. extra: @@ -642,6 +679,11 @@ test_suites: - *turek-hron-fsi3_fluid-nutils_solid-nutils - *two-scale-heat-conduction_macro-nutils_micro-nutils + external: + external: + - *partitioned-heat-conduction-mixedbc_left-openfoam_right-openfoam + - *partitioned-heat-conduction-robin_left-openfoam_right-openfoam + # A selection of tests that cover a wide range of main features, meant for quicker CI executions precice: tutorials: @@ -759,6 +801,9 @@ test_suites: - *quickstart_openfoam_cpp - *volume-coupled-flow_fluid-openfoam_source-nutils - *water-hammer_fluid1d-left-nutils_fluid3d-right-openfoam + external: + - *partitioned-heat-conduction-mixedbc_left-openfoam_right-openfoam + - *partitioned-heat-conduction-robin_left-openfoam_right-openfoam su2-adapter: tutorials: