Skip to content

Commit 377253c

Browse files
Source test cases from other repositories (#732)
1 parent d766d53 commit 377253c

7 files changed

Lines changed: 506 additions & 97 deletions

File tree

changelog-entries/732.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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)).

tools/tests/README.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,40 @@ Note that you will need to define the `TUTORIALS_REF` in the file [`reference_ve
170170

171171
The results will be added to a Git LFS, but you will need special push access: just use the aforementioned GitHub Actions workflow, instead.
172172

173+
#### External test sources
174+
175+
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).
176+
177+
```yaml
178+
test_suites:
179+
some-external-test-case:
180+
external:
181+
- &some-external-test-case
182+
source:
183+
type: git
184+
url: https://github.com/some-user/tutorials.git
185+
ref: some-branch
186+
subdir: .
187+
path: path-to-test-case
188+
case_combination:
189+
...
190+
191+
release:
192+
tutorials:
193+
- *quickstart_openfoam_cpp
194+
external:
195+
- *some-external-test-case
196+
```
197+
198+
Supported `source.type` values:
199+
200+
- `git`: shallow-clone `url` at `ref`. The `ref` must exist on the remote; clone failures are reported as errors.
201+
- `archive`: download and extract a `.tar.gz` / `.zip` from `url`.
202+
203+
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.
204+
205+
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.
206+
173207
### Adding new components
174208

175209
To add a new component, a few changes are needed:
@@ -299,7 +333,7 @@ cases:
299333
Description:
300334

301335
- `name`: A human-readable, descriptive name
302-
- `path`: Where the tutorial is located, relative to the tutorials repository
336+
- `path`: Where the tutorial is located, relative to the tutorials repository (or the tutorial folder name for external sources)
303337
- `url`: A web page with more information on the tutorial
304338
- `participants`: A list of preCICE participants, typically corresponding to different domains of the simulation
305339
- `cases`: A list of solver configuration directories. Each element of the list includes:

tools/tests/metadata_parser/metdata.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import itertools
77
from paths import PRECICE_TESTS_DIR, PRECICE_TUTORIAL_DIR
88

9+
from systemtests.sources import TutorialSource
10+
911

1012
@dataclass
1113
class BuildArgument:
@@ -283,13 +285,15 @@ def from_cases_tuple(cls, cases: Tuple[Case], tutorial: Tutorial):
283285
class ReferenceResult:
284286
path: Path
285287
case_combination: CaseCombination
288+
base_dir: Path | None = None
286289

287290
def __repr__(self) -> str:
288291
return f"{self.path.as_posix()}"
289292

290293
def __post_init__(self):
291294
# built full path
292-
self.path = PRECICE_TUTORIAL_DIR / self.path
295+
base = self.base_dir if self.base_dir is not None else PRECICE_TUTORIAL_DIR
296+
self.path = Path(base) / self.path
293297

294298

295299
@dataclass
@@ -303,6 +307,10 @@ class Tutorial:
303307
url: str
304308
participants: List[str]
305309
cases: List[Case]
310+
source: "TutorialSource" = field(default_factory=TutorialSource.local)
311+
# Filesystem path to the fetched external tutorial, resolved once at parse
312+
# time and reused when copying into the run directory (None for local).
313+
resolved_root: "Path | None" = None
306314
case_combinations: List[CaseCombination] = field(init=False)
307315

308316
def __post_init__(self):
@@ -359,29 +367,36 @@ def get_case_by_string(self, case_name: str) -> Optional[Case]:
359367
return None
360368

361369
@classmethod
362-
def from_yaml(cls, path, available_components):
370+
def from_yaml(cls, path, available_components, base_dir=None, source=None):
363371
"""
364372
Creates a Tutorial instance from a YAML file.
365373
366374
Args:
367-
path: The path to the YAML file.
375+
path: The path to the metadata.yaml file.
368376
available_components: The Components instance containing available components.
377+
base_dir: Optional base directory for resolving tutorial path (for external sources).
378+
Defaults to PRECICE_TUTORIAL_DIR.
379+
source: Optional TutorialSource (for external tutorials).
369380
370381
Returns:
371382
An instance of Tutorial.
372383
"""
373384
with open(path, 'r') as f:
374385
data = yaml.safe_load(f)
375386
name = data['name']
376-
path = PRECICE_TUTORIAL_DIR / data['path']
387+
base = base_dir if base_dir is not None else PRECICE_TUTORIAL_DIR
388+
tutorial_path = Path(base) / data['path']
377389
url = data['url']
378390
participants = data.get('participants', [])
379391
cases_raw = data.get('cases', {})
380392
cases = []
381393
for case_name in cases_raw.keys():
382394
cases.append(Case.from_dict(
383395
case_name, cases_raw[case_name], available_components))
384-
return cls(name, path, url, participants, cases)
396+
tut = cls(name, tutorial_path, url, participants, cases)
397+
if source is not None:
398+
tut.source = source
399+
return tut
385400

386401

387402
class Tutorials(list):

tools/tests/systemtests/Systemtest.py

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import hashlib
22
import subprocess
33
import threading
4+
from .sources import resolve_tutorial_root, PRECICE_EXTERNAL_CACHE_DIR
45
from typing import List, Dict, Optional, Tuple
56
from jinja2 import Environment, FileSystemLoader
67
from dataclasses import dataclass, field
@@ -480,24 +481,36 @@ def __copy_tutorial_into_directory(self, run_directory: Path):
480481
"""
481482
current_time_string = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
482483
self.run_directory = run_directory
483-
pr_requested = self.params_to_use.get("TUTORIALS_PR")
484-
if pr_requested:
485-
logging.debug(f"Fetching the PR {pr_requested} HEAD reference")
486-
self._fetch_pr(PRECICE_TUTORIAL_DIR, pr_requested)
487-
current_ref = self._get_git_ref(PRECICE_TUTORIAL_DIR)
488-
ref_requested = self.params_to_use.get("TUTORIALS_REF")
489-
if ref_requested:
490-
logging.debug(f"Checking out tutorials {ref_requested} before copying")
491-
self._fetch_ref(PRECICE_TUTORIAL_DIR, ref_requested)
492-
self._checkout_ref_in_subfolder(PRECICE_TUTORIAL_DIR, self.tutorial.path, ref_requested)
493-
494-
self.tutorial_folder = slugify(f'{self.tutorial.path.name}_{self.case_combination.cases}_{current_time_string}')
484+
current_ref = None
485+
ref_requested = None
486+
487+
if self.tutorial.source.type == "local":
488+
pr_requested = self.params_to_use.get("TUTORIALS_PR")
489+
if pr_requested:
490+
logging.debug(f"Fetching the PR {pr_requested} HEAD reference")
491+
self._fetch_pr(PRECICE_TUTORIAL_DIR, pr_requested)
492+
current_ref = self._get_git_ref(PRECICE_TUTORIAL_DIR)
493+
ref_requested = self.params_to_use.get("TUTORIALS_REF")
494+
if ref_requested:
495+
logging.debug(f"Checking out tutorials {ref_requested} before copying")
496+
self._fetch_ref(PRECICE_TUTORIAL_DIR, ref_requested)
497+
self._checkout_ref_in_subfolder(
498+
PRECICE_TUTORIAL_DIR, self.tutorial.path, ref_requested)
499+
500+
self.tutorial_folder = slugify(
501+
f'{self.tutorial.path.name}_{self.case_combination.cases}_{current_time_string}')
495502
destination = run_directory / self.tutorial_folder
496-
src = self.tutorial.path
503+
# External sources are fetched and resolved once at parse time; reuse
504+
# that path here to avoid a redundant fetch (and duplicate log line).
505+
src = self.tutorial.resolved_root or resolve_tutorial_root(
506+
self.tutorial.path,
507+
self.tutorial.source,
508+
PRECICE_EXTERNAL_CACHE_DIR,
509+
)
497510
self.system_test_dir = destination
498511
shutil.copytree(src, destination)
499512

500-
if ref_requested:
513+
if self.tutorial.source.type == "local" and ref_requested:
501514
with open(destination / "tutorials_ref", 'w') as file:
502515
file.write(ref_requested)
503516
self._checkout_ref_in_subfolder(PRECICE_TUTORIAL_DIR, self.tutorial.path, current_ref)
@@ -971,7 +984,8 @@ def _run_tutorial(self):
971984
return DockerComposeResult(exit_code, stdout_data, stderr_data, self, elapsed_time)
972985

973986
def __repr__(self):
974-
return f"{self.tutorial.name} {self.case_combination}"
987+
prefix = "External: " if getattr(self.tutorial.source, "type", "local") != "local" else ""
988+
return f"{prefix}{self.tutorial.name} {self.case_combination}"
975989

976990
def __apply_max_time_override(self):
977991
"""Overwrite <max-time> or <max-time-windows> value in precice-config.xml."""

0 commit comments

Comments
 (0)