Skip to content

Commit a4d0d73

Browse files
Add Fortran namelist parser using the new Lark-based infrastructure.
1 parent 92b2cce commit a4d0d73

File tree

3 files changed

+255
-1
lines changed

3 files changed

+255
-1
lines changed

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ classifiers = [
1414
"Topic :: Utilities",
1515
]
1616
dependencies = [
17-
"f90nml",
1817
"lark",
1918
"ruamel.yaml",
2019
]

src/access/parsers/fortran_nml.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Copyright 2025 ACCESS-NRI and contributors. See the top-level COPYRIGHT file for details.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Parser for Fortran namelists.
5+
6+
Fortran namelists allow format-free I/O of variables by key-value assignements. Initially they were an extension to the
7+
languages, but became part of the standard in Fortran 90.
8+
"""
9+
10+
from access.parsers.config import ConfigParser
11+
12+
13+
class FortranNMLParser(ConfigParser):
14+
"""Fortran Namelist parser.
15+
16+
Note: Currently array qualifiers, substrings and derived types are not implemented in the grammar.
17+
"""
18+
19+
@property
20+
def case_sensitive_keys(self) -> bool:
21+
return False
22+
23+
@property
24+
def grammar(self) -> str:
25+
return """
26+
?start: namelists
27+
28+
?namelists: random_text? namelist (random_text? namelist)* random_text?
29+
30+
namelist.2: nml_start key line_end? nml_lines nml_end -> key_block
31+
nml_start: ws* "&"
32+
nml_end: ws* ("/"|/&end/i) line_end
33+
nml_lines: (nml_line | empty_line)* -> block
34+
35+
?nml_line: assignment (ws* "," assignment)* (ws* separator)? line_end?
36+
37+
?assignment: key_value | key_list | key_null
38+
39+
key_value: ws* key ws* "=" ws* value
40+
key_list: ws* key ws* "=" ws* value ((line_break|ws* separator) ws* value)+
41+
key_null: ws* key ws* "=" ws*
42+
line_break: ws* separator line_end
43+
44+
?value: logical
45+
| integer
46+
| float
47+
| double
48+
| complex
49+
| double_complex
50+
| string
51+
52+
empty_line: line_end
53+
line_end: (fortran_comment|ws*) NEWLINE
54+
separator: ","
55+
56+
random_text: (/.+/|NEWLINE)*
57+
ANYTHING: /.+/
58+
59+
%import config.key
60+
%import config.logical
61+
%import config.integer
62+
%import config.float
63+
%import config.double
64+
%import config.complex
65+
%import config.double_complex
66+
%import config.string
67+
%import config.fortran_comment
68+
%import config.ws
69+
%import config.NEWLINE
70+
"""

tests/test_fortran_nml.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# Copyright 2025 ACCESS-NRI and contributors. See the top-level COPYRIGHT file for details.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import pytest
5+
from lark.exceptions import UnexpectedCharacters, UnexpectedEOF
6+
7+
from access.parsers.fortran_nml import FortranNMLParser
8+
9+
10+
@pytest.fixture(scope="module")
11+
def parser():
12+
"""Fixture instantiating the parser."""
13+
return FortranNMLParser()
14+
15+
16+
@pytest.fixture()
17+
def fortran_nml():
18+
"""Fixture returning a dict holding the parsed content of a Fortran namelist file."""
19+
return {
20+
"LIST_A": {
21+
"VAR": 4,
22+
"LIST": [1, 2, 3],
23+
"FLOAT": 1800.0,
24+
"COMPLEX": 3.0 + 4.0j,
25+
"NOVALUE": None,
26+
},
27+
"LIST_B": {
28+
"BOOL": True,
29+
"LIST": ["a", "b", "c"],
30+
"DOUBLE": 1.0e-10,
31+
"STRING": "a string",
32+
"ANOTHER_LIST": [1, 2, 3, 4, 5, 6],
33+
},
34+
"LIST_C": {},
35+
}
36+
37+
38+
@pytest.fixture()
39+
def fortran_nml_file():
40+
"""Fixture returning the content of a Fortran namelist file."""
41+
return """
42+
&LIST_A ! This is a comment
43+
Var = 4 , ! This is a comment after an assignment
44+
LIST = 1, 2, 3
45+
46+
! This is another comment
47+
48+
Float = 1800.0 ,
49+
COMPLEX = (3.0, 4.0),
50+
51+
NOVALUE =
52+
/
53+
54+
! Yet another comment
55+
This is some random text
56+
57+
&LIST_B
58+
Bool = .true.
59+
LIST = "a", "b", "c", DOUBLE = 1d-10
60+
STRING="a string"
61+
ANOTHER_LIST = 1, 2,
62+
3, ! Comment in line break
63+
4, 5, 6
64+
/
65+
66+
&LIST_C
67+
/
68+
"""
69+
70+
71+
@pytest.fixture()
72+
def modified_fortran_nml_file():
73+
"""Fixture returning the content of the previous Fortran namelist file, but with some modifications."""
74+
return """
75+
&LIST_A ! This is a comment
76+
Var = 6 , ! This is a comment after an assignment
77+
LIST = 1, 2, 3
78+
79+
! This is another comment
80+
81+
Float = 900.0 ,
82+
COMPLEX = (3.0, 4.0),
83+
84+
NOVALUE =
85+
/
86+
87+
! Yet another comment
88+
This is some random text
89+
90+
&LIST_B
91+
Bool = .false.
92+
LIST = "a", "b", "c", DOUBLE = 1d-10
93+
STRING="another string"
94+
ANOTHER_LIST = 1, 2,
95+
3, ! Comment in line break
96+
4, 5, 6
97+
/
98+
99+
&LIST_C
100+
/
101+
"""
102+
103+
104+
def test_valid_fortran_nml(parser):
105+
"""Test the basic grammar constructs"""
106+
assert dict(parser.parse("&LIST TEST='a'/")) == {"LIST": {"TEST": "a"}}
107+
assert dict(parser.parse("&LIST\nTEST='a'/")) == {"LIST": {"TEST": "a"}}
108+
assert dict(parser.parse("&LIST\nTEST='a'\n/")) == {"LIST": {"TEST": "a"}}
109+
assert dict(parser.parse("&LIST\nTEST='a'\n&end")) == {"LIST": {"TEST": "a"}}
110+
assert dict(parser.parse("&LIST\nTEST='a'\n&End")) == {"LIST": {"TEST": "a"}}
111+
assert dict(parser.parse(" &LIST\nTEST='a'/")) == {"LIST": {"TEST": "a"}}
112+
assert dict(parser.parse("&LIST\nTEST = 'a' /")) == {"LIST": {"TEST": "a"}}
113+
assert dict(parser.parse("&LIST\nTEST = 'a'/")) == {"LIST": {"TEST": "a"}}
114+
assert dict(parser.parse("&LIST\nTEST= 'a'/")) == {"LIST": {"TEST": "a"}}
115+
assert dict(parser.parse("&LIST\nTEST='a',\n/")) == {"LIST": {"TEST": "a"}}
116+
assert dict(parser.parse("&LIST\nTEST='a' , \n/")) == {"LIST": {"TEST": "a"}}
117+
assert dict(parser.parse("&LIST\nTEST = .true.\n/")) == {"LIST": {"TEST": True}}
118+
assert dict(parser.parse("&LIST\nTEST = .false.\n/")) == {"LIST": {"TEST": False}}
119+
assert dict(parser.parse("&LIST\nTEST='a', 'b'\n/")) == {"LIST": {"TEST": ["a", "b"]}}
120+
assert dict(parser.parse("&LIST\nTEST='a','b'\n/")) == {"LIST": {"TEST": ["a", "b"]}}
121+
assert dict(parser.parse("&LIST\nTEST='a','b',\n/")) == {"LIST": {"TEST": ["a", "b"]}}
122+
assert dict(parser.parse("&LIST\nTEST='a', \n'b', \n'c'\n/")) == {"LIST": {"TEST": ["a", "b", "c"]}}
123+
assert dict(parser.parse("&LIST\nVAR1=1, VAR2=2\n/")) == {"LIST": {"VAR1": 1, "VAR2": 2}}
124+
assert dict(parser.parse("&LIST\nVAR1=1, VAR2=2,\n/")) == {"LIST": {"VAR1": 1, "VAR2": 2}}
125+
assert dict(parser.parse("&LIST\nVAR1=1, 2, VAR2=3\n/")) == {"LIST": {"VAR1": [1, 2], "VAR2": 3}}
126+
assert dict(parser.parse("&LIST\nVAR1=1, VAR2=2, 3\n/")) == {"LIST": {"VAR1": 1, "VAR2": [2, 3]}}
127+
assert dict(parser.parse("&LIST\nVAR1=1, 2, VAR2=3, 4\n/")) == {"LIST": {"VAR1": [1, 2], "VAR2": [3, 4]}}
128+
assert dict(parser.parse("&LIST\nVAR1=1, VAR2=2, VAR3=3\n/")) == {"LIST": {"VAR1": 1, "VAR2": 2, "VAR3": 3}}
129+
assert dict(parser.parse("&LIST\nVAR1=1, VAR2=2, VAR3=3,\n/")) == {"LIST": {"VAR1": 1, "VAR2": 2, "VAR3": 3}}
130+
assert dict(parser.parse(" &LIST\nTEST = \n /")) == {"LIST": {"TEST": None}}
131+
132+
133+
def test_invalid_fortran_nml(parser):
134+
"""Test checking that the parser catches malformed expressions"""
135+
with pytest.raises(UnexpectedEOF):
136+
parser.parse("&LIST\nTEST : 'a'\n/")
137+
138+
with pytest.raises(UnexpectedEOF):
139+
parser.parse("&LIST\nTEST = true\n/")
140+
141+
with pytest.raises(UnexpectedEOF):
142+
parser.parse("&LIST\nTEST = false\n/")
143+
144+
with pytest.raises(UnexpectedEOF):
145+
parser.parse("%TEST\na=1\n%TEST")
146+
147+
with pytest.raises(UnexpectedEOF):
148+
parser.parse("TEST%\na=1\nTEST%")
149+
150+
with pytest.raises(UnexpectedEOF):
151+
parser.parse("&TEST\na=1 2\n/")
152+
153+
with pytest.raises(UnexpectedEOF):
154+
parser.parse("&TEST\na=1 \n2\n/")
155+
156+
with pytest.raises(UnexpectedEOF):
157+
parser.parse("BLOCK\n TEST ='a'/")
158+
159+
with pytest.raises(UnexpectedEOF):
160+
parser.parse("&BLOCK\n VAR1=1\n&e")
161+
162+
163+
def test_fortran_nml_parse(parser, fortran_nml, fortran_nml_file):
164+
"""Test parsing a file."""
165+
config = parser.parse(fortran_nml_file)
166+
assert dict(config) == fortran_nml
167+
168+
169+
def test_fortran_nml_roundtrip(parser, fortran_nml_file):
170+
"""Test round-trip parsing."""
171+
config = parser.parse(fortran_nml_file)
172+
173+
assert str(config) == fortran_nml_file
174+
175+
176+
def test_fortran_nml_roundtrip_with_mutation(parser, fortran_nml_file, modified_fortran_nml_file):
177+
"""Test round-trip parsing with mutation of the config."""
178+
config = parser.parse(fortran_nml_file)
179+
180+
config["LIST_A"]["VAR"] = 6
181+
config["LIST_A"]["FLOAT"] = 900.0
182+
config["LIST_B"]["BOOL"] = False
183+
config["LIST_B"]["STRING"] = "another string"
184+
185+
assert str(config) == modified_fortran_nml_file

0 commit comments

Comments
 (0)