Skip to content

Commit 8fb4a8a

Browse files
Modify Payu config parser to have a similar API to the other config parsers.
1 parent a4d0d73 commit 8fb4a8a

File tree

2 files changed

+59
-57
lines changed

2 files changed

+59
-57
lines changed
Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,53 @@
11
# Copyright 2025 ACCESS-NRI and contributors. See the top-level COPYRIGHT file for details.
22
# SPDX-License-Identifier: Apache-2.0
33

4-
"""Utilities to handle payu configuration files.
4+
"""Utilities to handle YAML-based configuration files.
55
6-
Configuration for payu experiments are stored using YAML. Documentation about these files can be found here:
7-
8-
https://payu.readthedocs.io/en/latest/config.html
9-
10-
Round-trip parsing is supported by using the ruamel.yaml parser.
6+
The ruamel.yaml parser provides round-trip parsing of YAML files and has all capabilities we require. Here we simply
7+
provide wrappers around the ruamel.yaml classes so that the API is the same/similar to the other parsers.
118
"""
129

13-
from pathlib import Path
10+
from io import StringIO
11+
from typing import Any
12+
1413
from ruamel.yaml import YAML, CommentedMap
1514

1615

17-
def read_payu_config_yaml(file_name: str) -> CommentedMap:
18-
"""Read a payu configuration file.
16+
class YAMLConfig(dict):
17+
"""Class to store a YAML configuration as a dict.
18+
19+
The YAML parsers generates an instance of CommentedMap, which in turn also behaves like a dictionary. Unfortunately
20+
we cannot simply subclass CommentedMap. This is because the dump method of YAML calls the __str__ method of
21+
CommentedMap, which leads to a infinite recursion when calling the __str__ method of this class. This means that,
22+
instead, we need to keep a copy of the CommentedMap and sync it with the dict.
23+
"""
1924

20-
This function uses ruamel to parse the YAML file, so that we can do round-trip parsing.
25+
def __init__(self, map: CommentedMap) -> None:
26+
self.map = map
27+
super().__init__(map)
2128

22-
Args:
23-
file_name: Name of file to read.
29+
def __str__(self) -> str:
30+
output = StringIO("")
31+
YAML().dump(self.map, output)
32+
return output.getvalue()
2433

25-
Returns:
26-
dict: Payu configuration.
27-
"""
28-
fname = Path(file_name)
29-
if not fname.is_file():
30-
raise FileNotFoundError(f"File not found: {fname.as_posix()}")
34+
def __setitem__(self, key: str, value: Any) -> None:
35+
super().__setitem__(key, value)
36+
self.map[key] = value
3137

32-
config = YAML().load(fname)
38+
def __getitem__(self, key: str) -> Any:
39+
return self.map[key]
3340

34-
return config
41+
def __delitem__(self, key: str) -> None:
42+
super().__delitem__(key)
43+
del self.map[key]
3544

3645

37-
def write_payu_config_yaml(config: [dict | CommentedMap], file: Path):
38-
"""Write a Payu configuration to a file.
46+
class YAMLParser:
47+
"""Wrapper class to the ruamel.yaml parser."""
3948

40-
Args:
41-
config (dict| CommentedMap): Payu configuration.
42-
file(Path): File to write to.
43-
"""
44-
YAML().dump(config, file)
49+
def __init__(self) -> None:
50+
self.parser = YAML()
51+
52+
def parse(self, stream: str) -> YAMLConfig:
53+
return YAMLConfig(self.parser.load(stream))

tests/test_payu_config_yaml.py

Lines changed: 23 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@
22
# SPDX-License-Identifier: Apache-2.0
33

44
import pytest
5-
from unittest.mock import mock_open, patch
6-
from pathlib import Path
75

8-
from access.parsers.payu_config_yaml import read_payu_config_yaml, write_payu_config_yaml
6+
from access.parsers.payu_config_yaml import YAMLParser
7+
8+
9+
@pytest.fixture(scope="module")
10+
def parser():
11+
"""Fixture instantiating the parser."""
12+
return YAMLParser()
913

1014

1115
@pytest.fixture()
1216
def simple_payu_config():
17+
"""Fixture returning a dictionary storing a payu config file."""
1318
return {
1419
"project": "x77",
1520
"ncpus": 48,
@@ -29,6 +34,7 @@ def simple_payu_config():
2934

3035
@pytest.fixture()
3136
def simple_payu_config_file():
37+
"""Fixture returning the contents of a simple payu config file."""
3238
return """project: x77
3339
ncpus: 48
3440
jobfs: 10GB
@@ -45,7 +51,8 @@ def simple_payu_config_file():
4551

4652

4753
@pytest.fixture()
48-
def complex_payu_config_file():
54+
def payu_config_file():
55+
"""Fixture returning the contents of a more complex payu config file."""
4956
return """# PBS configuration
5057
5158
# If submitting to a different project to your default, uncomment line below
@@ -75,6 +82,7 @@ def complex_payu_config_file():
7582

7683
@pytest.fixture()
7784
def modified_payu_config_file():
85+
"""Fixture returning the contents the previous payu config file after introducing some modifications."""
7886
return """# PBS configuration
7987
8088
# If submitting to a different project to your default, uncomment line below
@@ -93,7 +101,6 @@ def modified_payu_config_file():
93101
94102
model: access-om3
95103
96-
exe: /some/path/to/access-om3-MOM6-CICE6
97104
input:
98105
- /some/other/path/to/inputs/1deg/mom # MOM6 inputs
99106
- /some/path/to/inputs/1deg/cice # CICE inputs
@@ -102,33 +109,19 @@ def modified_payu_config_file():
102109
"""
103110

104111

105-
@patch("pathlib.Path.is_file", new=lambda file: True)
106-
def test_read_payu_config(simple_payu_config, simple_payu_config_file):
107-
with patch("io.open", mock_open(read_data=simple_payu_config_file)) as m:
108-
config = read_payu_config_yaml(file_name="simple_payu_config_file")
109-
110-
assert config == simple_payu_config
111-
112-
113-
def test_write_payu_config(simple_payu_config, simple_payu_config_file):
114-
with patch("io.open", mock_open()) as m:
115-
write_payu_config_yaml(simple_payu_config, Path("config_file"))
116-
117-
assert simple_payu_config_file == "".join(call.args[0] for call in m().write.mock_calls)
118-
112+
def test_read_payu_config(parser, simple_payu_config, simple_payu_config_file):
113+
"""Test parsing of a simple file."""
114+
config = parser.parse(simple_payu_config_file)
119115

120-
@patch("pathlib.Path.is_file", new=lambda file: True)
121-
def test_round_trip_payu_config(complex_payu_config_file, modified_payu_config_file):
122-
with patch("io.open", mock_open(read_data=complex_payu_config_file)) as m:
123-
config = read_payu_config_yaml(file_name="complex_config_file")
116+
assert config == simple_payu_config
124117

125-
config["ncpus"] = 64
126-
config["input"][0] = "/some/other/path/to/inputs/1deg/mom"
127-
write_payu_config_yaml(config, Path("some_other_config_file"))
128118

129-
assert modified_payu_config_file == "".join(call.args[0] for call in m().write.mock_calls)
119+
def test_round_trip_payu_config(parser, payu_config_file, modified_payu_config_file):
120+
"""Test round-trip parsing of a more complex file with mutation of the config."""
121+
config = parser.parse(payu_config_file)
130122

123+
config["ncpus"] = 64
124+
config["input"][0] = "/some/other/path/to/inputs/1deg/mom"
125+
del config["exe"]
131126

132-
def test_read_missing_payu_config():
133-
with pytest.raises(FileNotFoundError):
134-
read_payu_config_yaml(file_name="garbage")
127+
assert modified_payu_config_file == str(config)

0 commit comments

Comments
 (0)