Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add pytests and CI for simple configuration checks #32

Merged
merged 26 commits into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
24a8349
Add pytests for simple configuration checks
jo-basevi Mar 22, 2024
7bb5281
pr-1-ci.yml: Added job for simple ci checks run on the runner, and on…
CodeGat Mar 25, 2024
81b9368
conftest.py: Added in markers for config and highres
CodeGat Mar 25, 2024
44fa032
Add metadata checks to pytests
jo-basevi Mar 25, 2024
eef29bc
Update pytest marker descriptions in conftest.py
jo-basevi Mar 27, 2024
7119fa6
Add test to enforce manifest reproduce exe is on
jo-basevi Mar 27, 2024
061378f
pr-1-ci.yml: Made model-specific checks an optional input
CodeGat Mar 27, 2024
1bf520b
pr-1-ci.yml: Various fixes during review
CodeGat Mar 27, 2024
2da3572
test/conftest.py: Add pytest test marker back in
jo-basevi Mar 27, 2024
4c718c3
README-DEV.md: Added notes on the new jobs and workflows
CodeGat Mar 27, 2024
c429770
pr-1-ci.yml: Simplified branch-check job logic
CodeGat Mar 27, 2024
2ac3c8d
Updated commits done by github-actions[bot] to use the org var
CodeGat Mar 27, 2024
8121664
Update test/test_config.py with review suggestions
jo-basevi Mar 27, 2024
6c8b66e
Update .github/workflows/pr-1-ci.yml
CodeGat Mar 28, 2024
6f9a1c3
Refactor access-om2 specific pytests
jo-basevi Mar 28, 2024
784db81
pr-1-ci.yml: Update markers and directory where tests are run from
jo-basevi Mar 28, 2024
db96ead
Change test dependencies versions (to match payu's condaenv)
jo-basevi Mar 28, 2024
e644a29
Add target-branch option to pytests - so can infer info from target b…
jo-basevi Mar 28, 2024
3cc43af
pr-2-confirm.yml: Automatic bumping to 1.0 if there is no version in …
CodeGat Mar 28, 2024
c601d98
pr-1-ci.yml: Soft-fail job added that checks if the PR has modified t…
CodeGat Mar 28, 2024
024f6b7
Removed 'metadata.yml' from list of ignored paths in PR
CodeGat Mar 28, 2024
e7096ed
pr-1-ci.yml: Noted in the repro result comments that the version is m…
CodeGat Apr 1, 2024
7e2a764
pr-1-ci.yml: Removed dev-branch focussed repro comments because they …
CodeGat Apr 1, 2024
db7a43b
schedule-2-start.yml: Added correct link to tag
CodeGat Apr 1, 2024
31ed470
pr-1-ci.yml: Gave each Test Results check a distinct name
CodeGat Apr 2, 2024
f787291
Update pytests: Fail access-om2 tests for unknown resolutions and add…
jo-basevi Apr 2, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import pytest
from pathlib import Path

import yaml


@pytest.fixture(scope="session")
def output_path(request):
Expand Down Expand Up @@ -37,6 +39,22 @@ def checksum_path(request, control_path):
return Path(path)


@pytest.fixture(scope="session")
def metadata(control_path: Path):
metadata_path = control_path / 'metadata.yaml'
with open(metadata_path) as f:
content = yaml.safe_load(f)
return content


@pytest.fixture(scope="session")
def config(control_path: Path):
config_path = control_path / 'config.yaml'
with open(config_path) as f:
config_content = yaml.safe_load(f)
return config_content


# Set up command line options and default for directory paths
def pytest_addoption(parser):
"""Attaches optional command line arguments"""
Expand Down Expand Up @@ -66,15 +84,6 @@ def pytest_configure(config):
config.addinivalue_line(
"markers", "config: mark as configuration tests in quick CI checks"
)
config.addinivalue_line(
"markers", "metadata: mark as metadata tests in quick CI checks"
)
config.addinivalue_line(
"markers", "access_om2: mark as access-om2 specific tests"
)
config.addinivalue_line(
"markers", "access_om2_bgc: mark as access-om2-bgc specific tests"
)
config.addinivalue_line(
"markers", "highres: mark tests as high resolution model configuration tests"
)
1 change: 1 addition & 0 deletions test/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ pytest==8.0.1
jsonschema==4.21.1
requests==2.31.0
PyYAML==6.0.1
f90nml==1.4.4
116 changes: 116 additions & 0 deletions test/test_access_om2_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
from datetime import timedelta
import re

import pytest
import f90nml

from util import get_git_branch_name

# Mutually exclusive topic keywords
TOPIC_KEYWORDS = {
'spatial extent': {'global', 'regional'},
'forcing product': {'JRA55', 'ERA5'},
'forcing mode': {'repeat-year', 'ryf', 'repeat-decade', 'rdf',
'interannual', 'iaf'},
'model': {'access-om2', 'access-om2-025', 'access-om2-01'}
}


class AccessOM2Branch:
"""Use the naming patterns of the branch name to infer informatiom of
the ACCESS-OM2 config"""

def __init__(self, branch_name):
self.branch_name = branch_name
self.set_resolution()

self.is_high_resolution = self.resolution in ['025deg', '01deg']
self.is_bgc = 'bgc' in branch_name

def set_resolution(self):
# Resolutions are ordered, so the start of the list are matched first
resolutions = ['025deg', '01deg', '1deg']
self.resolution = None
for res in resolutions:
if res in self.branch_name:
self.resolution = res
return
# TODO Should unknown resolutions fail the pytests or be ignored?
jo-basevi marked this conversation as resolved.
Show resolved Hide resolved


@pytest.fixture(scope="class")
def branch(control_path):
branch_name = get_git_branch_name(control_path)
assert branch_name is not None, (
f"Failed getting git branch name of control path: {control_path}"
)
return AccessOM2Branch(branch_name)


@pytest.mark.access_om2
class TestAccessOM2:
"""ACCESS-OM2 Specific configuration and metadata tests"""

def test_mppncombine_fast_collate_exe(self, config, branch):
if branch.is_high_resolution:
pattern = r'/g/data/vk83/apps/mppnccombine-fast/.*/bin/mppnccombine-fast'
if 'collate' in config:
assert re.match(pattern, config['collate']['exe']), (
"Expect collate executable set to mppnccombine-fast"
)

def test_metadata_realm(self, metadata, branch):
expected_realms = ['ocean', 'seaIce']
expected_config = 'realm:\n - ocean\n - seaIce'
if branch.is_bgc:
expected_realms.append('ocnBgchem')
expected_config += '\n - ocnBgchem'

assert ('realm' in metadata
and metadata['realm'] == expected_realms), (
'Expected metadata realm set to:\n' + expected_config
)
jo-basevi marked this conversation as resolved.
Show resolved Hide resolved

def test_restart_period(self, branch, control_path):
accessom2_nml_path = control_path / 'accessom2.nml'
assert accessom2_nml_path.exists()

accessom2_nml = f90nml.read(accessom2_nml_path)
restart_period = accessom2_nml['date_manager_nml']['restart_period']

# TODO: Use set of expected periods?
if branch.resolution == '1deg':
expected_period = [5, 0, 0]
elif branch.resolution == '025deg':
if branch.is_bgc:
expected_period = [1, 0, 0]
else:
expected_period = [2, 0, 0]
elif branch.resolution == '01deg':
if branch.is_bgc:
expected_period = [0, 1, 0]
else:
expected_period = [0, 3, 0]
else:
return
assert restart_period == expected_period

def test_metadata_keywords(self, metadata):

assert 'keywords' in metadata
metadata_keywords = set(metadata['keywords'])

expected_keywords = set()
for topic, keywords in TOPIC_KEYWORDS.items():
mutually_exclusive = metadata_keywords.intersection(keywords)
assert len(mutually_exclusive) <= 1, (
f"Topic {topic} has multiple mutually exlusive keywords: " +
str(mutually_exclusive)
)

expected_keywords = expected_keywords.union(keywords)

unrecognised_keywords = metadata_keywords.difference(expected_keywords)
assert len(unrecognised_keywords) == 0, (
f"Metadata has unrecognised keywords: {unrecognised_keywords}"
)
aidanheerdegen marked this conversation as resolved.
Show resolved Hide resolved
91 changes: 22 additions & 69 deletions test/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,6 @@
DEFAULT_SCHEMA_VERSION = "1-0-0"


@pytest.fixture(scope="class")
def metadata(control_path: Path):
metadata_path = control_path / 'metadata.yaml'
with open(metadata_path) as f:
content = yaml.safe_load(f)
return content


@pytest.fixture(scope="class")
def config(control_path: Path):
config_path = control_path / 'config.yaml'
with open(config_path) as f:
config_content = yaml.safe_load(f)
return config_content


@pytest.fixture(scope="class")
def exe_manifest_fullpaths(control_path: Path):
manifest_path = control_path / 'manifests' / 'exe.yaml'
Expand Down Expand Up @@ -160,60 +144,29 @@ def test_no_scripts_in_top_level_directory(self, control_path):
"'tools' sub-directory"
)

def test_validate_metadata(self, metadata):
# Schema URL
schema_version = metadata.get("schema_version", DEFAULT_SCHEMA_VERSION)
url = f"{BASE_SCHEMA_URL}/{schema_version}.json"

@pytest.mark.highres
def test_mppncombine_fast_collate_exe(config):
pattern = r'/g/data/vk83/apps/mppnccombine-fast/.*/bin/mppnccombine-fast'
if 'collate' in config:
assert re.match(pattern, config['collate']['exe']), (
"Expect collate executable set to mppnccombine-fast"
)


@pytest.mark.metadata
def test_validate_metadata(metadata):
# Schema URL
schema_version = metadata.get("schema_version", DEFAULT_SCHEMA_VERSION)
url = f"{BASE_SCHEMA_URL}/{schema_version}.json"

# Get schema from Github
response = requests.get(url)
assert response.status_code == 200
schema = response.json()
# Get schema from Github
response = requests.get(url)
assert response.status_code == 200
schema = response.json()
aidanheerdegen marked this conversation as resolved.
Show resolved Hide resolved

# In schema version (1-0-0), required fields are name, experiment_uuid,
# description and long_description. As name & experiment_uuid are
# generated for running experiments, the required fields are removed
# from the schema validation for now
schema.pop('required')
# In schema version (1-0-0), required fields are name, experiment_uuid,
# description and long_description. As name & experiment_uuid are
# generated for running experiments, the required fields are removed
# from the schema validation for now
schema.pop('required')

# Experiment_uuid
jsonschema.validate(instance=metadata, schema=schema)
# Validate field names and types
jsonschema.validate(instance=metadata, schema=schema)


@pytest.mark.metadata
@pytest.mark.parametrize(
"field",
["description", "notes", "keywords", "nominal resolution", "version",
"reference", "license", "url", "model", "realm"]
)
def test_metadata_contains_fields(field, metadata):
assert field in metadata, f"{field} field shoud be defined in metadata"


@pytest.mark.access_om2_bgc
def test_metadata_realm(metadata):
assert ('realm' in metadata
and metadata['realm'] == ['ocean', 'seaIce', 'ocnBgchem']), (
'Expected access-om2-bgc metadata realm set to:\n' +
'realm:\n - ocean\n - seaIce\n - ocnBgchem'
)


@pytest.mark.access_om2
def test_metadata_access_om2_realm(metadata):
assert ('realm' in metadata
and metadata['realm'] == ['ocean', 'seaIce']), (
'Expected access-om2 metadata realm set to:\n' +
'realm:\n - ocean\n - seaIce\n'
)
@pytest.mark.parametrize(
"field",
["description", "notes", "keywords", "nominal resolution", "version",
"reference", "license", "url", "model", "realm"]
)
def test_metadata_contains_fields(self, field, metadata):
assert field in metadata, f"{field} field shoud be defined in metadata"
13 changes: 13 additions & 0 deletions test/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,16 @@ def wait_for_qsub(run_id):

if 'Job has finished' in qsub_out:
break


def get_git_branch_name(path):
"""Get the git branch name of the given git directory"""
try:
cmd = 'git rev-parse --abbrev-ref HEAD'
result = sp.check_output(cmd, shell=True,
cwd=path).strip()
# Decode byte string to string
branch_name = result.decode('utf-8')
return branch_name
except sp.CalledProcessError:
return None