Skip to content

Commit 7a64cf8

Browse files
jjokellaclaude
andauthored
Add normalize_namelists.py with user guide (#1)
`normalize_namelists.py`: Unifies indentation, quote style, and continuation-line alignment across eCLM namelist files. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d9be53f commit 7a64cf8

5 files changed

Lines changed: 217 additions & 4 deletions

File tree

docs/_toc.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ parts:
99
- file: users_guide/README
1010
title: Scripts for eCLM namelist generation and manipulation
1111
sections:
12-
- file: users_guide/example_site
12+
- file: users_guide/normalize_namelists

docs/users_guide/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,7 @@
22

33
This repository contains a selection of scripts for eCLM namelist file
44
generation and manipulation.
5+
6+
- [normalize_namelists.py](normalize_namelists.md): Unifies
7+
indentation, quote style, and continuation-line alignment across
8+
eCLM namelist files.

docs/users_guide/example_site.md

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# normalize_namelists.py
2+
3+
Normalizes style in eCLM namelist files so that diffs between runs are
4+
clean and human-readable.
5+
6+
## What it does
7+
8+
The script applies three transformations to every namelist file it
9+
finds:
10+
11+
1. **Indentation** — lines inside a `&group … /` block are re-indented
12+
to exactly two spaces. Group header (`&group`) and closing (`/`)
13+
lines are left as-is.
14+
15+
2. **Quote style** — string values delimited by double quotes are
16+
rewritten to use single quotes (`"value"``'value'`). The value
17+
content is never modified.
18+
19+
3. **Continuation alignment** — continuation lines of multi-line values are
20+
indented to align with the value start on the `key = value` line above:
21+
```
22+
hist_fincl1 = 'SOILWATER_10CM', 'H2OSOI',
23+
'SOILLIQ', 'SOILICE'
24+
```
25+
26+
Lines outside a group (blank lines, comment blocks appended by
27+
`modify_case_namelists.py`, etc.) are passed through unchanged.
28+
29+
## Files processed
30+
31+
The script looks for the following files in the target directory:
32+
33+
| File | Description |
34+
|-----------------------------------------------------|-----------------------------|
35+
| `drv_flds_in` | Driver field list |
36+
| `drv_in` | Driver namelist |
37+
| `lnd_in` | Land model namelist |
38+
| `datm_in` | Data atmosphere namelist |
39+
| `mosart_in` | River routing namelist |
40+
| `{atm,cpl,esp,glc,ice,lnd,ocn,rof,wav}_modelio.nml` | Per-component I/O namelists |
41+
42+
Files that are not present in the directory are silently skipped.
43+
44+
## Usage
45+
46+
```bash
47+
# Normalize all namelist files in the current directory
48+
python3 normalize_namelists.py
49+
50+
# Normalize files in a specific run directory
51+
python3 normalize_namelists.py /path/to/rundir
52+
53+
# Preview which files would change without writing anything
54+
python3 normalize_namelists.py --dry-run [rundir]
55+
```
56+
57+
### Options
58+
59+
| Option | Description |
60+
|-------------|------------------------------------------------------------------|
61+
| `rundir` | Path to the directory containing namelist files. Defaults to `.` |
62+
| `--dry-run` | Print which files would be modified without writing any changes |
63+
64+
## Output
65+
66+
For each file the script reports one of three outcomes:
67+
68+
```
69+
lnd_in: normalized # file was changed and written
70+
drv_in: no changes # file was already normalized
71+
datm_in: would be modified # --dry-run: file would change
72+
```

normalize_namelists.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
#!/usr/bin/env python3
2+
"""
3+
normalize_namelists.py - Unify style in eCLM-PDAF namelist files.
4+
5+
Applies two transformations to eCLM namelist files (drv_flds_in,
6+
drv_in, lnd_in, datm_in, mosart_in, and the *_modelio.nml files):
7+
8+
1. Indentation inside namelist groups (&group ... /) is set to exactly
9+
two spaces. Group header lines (&group) and closing slashes (/) are
10+
left as-is.
11+
12+
2. String values delimited by double quotes are rewritten to use single
13+
quotes. The value content itself is never modified, only the delimiter
14+
characters change ("value" -> 'value').
15+
16+
3. Continuation lines of multi-line values are indented to align with the
17+
value start on the key = value line above (one space after the = sign),
18+
e.g.:
19+
hist_fincl1 = 'SOILWATER_10CM', 'H2OSOI',
20+
'SOILLIQ', 'SOILICE'
21+
22+
Usage
23+
-----
24+
# Normalize all namelist files in the current directory
25+
python3 normalize_namelists.py
26+
27+
# Normalize files in a specific run directory
28+
python3 normalize_namelists.py /path/to/rundir
29+
30+
# Preview which files would change without writing anything
31+
python3 normalize_namelists.py --dry-run [rundir]
32+
"""
33+
import argparse
34+
import os
35+
import re
36+
37+
_MODELIO_COMPONENTS = ("atm", "cpl", "esp", "glc", "ice", "lnd", "ocn", "rof", "wav")
38+
_NAMELIST_FILES = [
39+
"drv_flds_in",
40+
"drv_in",
41+
"lnd_in",
42+
"datm_in",
43+
"mosart_in",
44+
] + [f"{c}_modelio.nml" for c in _MODELIO_COMPONENTS]
45+
46+
47+
def normalize_content(content):
48+
"""Return *content* with unified indentation and single-quoted string values.
49+
50+
The function processes the file line by line, tracking whether the current
51+
position is inside a namelist group. Only lines inside a group are
52+
modified; everything else (blank lines, trailing comment blocks added by
53+
modify_case_namelists.py, etc.) is passed through unchanged.
54+
"""
55+
lines = content.splitlines(keepends=True)
56+
result = []
57+
in_group = False
58+
continuation_indent = " " # updated each time a new key = value line is seen
59+
for line in lines:
60+
nl = "\n" if line.endswith("\n") else ""
61+
stripped = line.rstrip("\n")
62+
63+
# A line starting with & followed by a word character opens a group.
64+
if re.match(r"^\s*&\w", stripped):
65+
in_group = True
66+
continuation_indent = " "
67+
result.append(line)
68+
continue
69+
70+
# A line whose first non-space character is / closes the group.
71+
if re.match(r"^\s*/", stripped):
72+
in_group = False
73+
result.append(line)
74+
continue
75+
76+
if in_group and stripped.strip():
77+
# Strip existing indentation and re-apply exactly two spaces.
78+
body = stripped.lstrip()
79+
# Replace double-quoted strings with single-quoted ones.
80+
# The capture group preserves the value content verbatim so that
81+
# only the delimiter characters are changed, never the value itself.
82+
body = re.sub(r'"([^"]*)"', lambda m: "'" + m.group(1) + "'", body)
83+
84+
m = re.match(r"(\w+\s*=\s*)", body)
85+
if m:
86+
# Key = value line: record where the value starts so that
87+
# continuation lines can be aligned with it.
88+
continuation_indent = " " * (2 + len(m.group(1)))
89+
result.append(" " + body + nl)
90+
elif body.startswith("!"):
91+
# Comment line: not a continuation, reset continuation indent.
92+
continuation_indent = " "
93+
result.append(" " + body + nl)
94+
else:
95+
# Continuation line of a multi-line value: indent to align
96+
# with the value start on the key = value line above.
97+
result.append(continuation_indent + body + nl)
98+
else:
99+
# Empty lines inside a group and all lines outside a group are
100+
# left untouched.
101+
result.append(line)
102+
103+
return "".join(result)
104+
105+
106+
def main():
107+
parser = argparse.ArgumentParser(description=__doc__)
108+
parser.add_argument(
109+
"rundir",
110+
nargs="?",
111+
default=".",
112+
help="Directory containing namelist files (default: current directory)",
113+
)
114+
parser.add_argument(
115+
"--dry-run",
116+
action="store_true",
117+
help="Show which files would change without writing",
118+
)
119+
args = parser.parse_args()
120+
121+
for filename in _NAMELIST_FILES:
122+
path = os.path.join(args.rundir, filename)
123+
if not os.path.isfile(path):
124+
continue
125+
with open(path) as fh:
126+
original = fh.read()
127+
normalized = normalize_content(original)
128+
if normalized == original:
129+
print(f"{filename}: no changes")
130+
continue
131+
if args.dry_run:
132+
print(f"{filename}: would be modified")
133+
else:
134+
with open(path, "w") as fh:
135+
fh.write(normalized)
136+
print(f"{filename}: normalized")
137+
138+
139+
if __name__ == "__main__":
140+
main()

0 commit comments

Comments
 (0)