Skip to content

Commit 5168df0

Browse files
jjokellaclaude
andcommitted
Introduce modify_stream_files.py with docs
Modifies field values inside eCLM DATM stream XML files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1a08f25 commit 5168df0

4 files changed

Lines changed: 269 additions & 0 deletions

File tree

docs/_toc.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ parts:
1111
sections:
1212
- file: users_guide/normalize_namelists
1313
- file: users_guide/normalize_stream_files
14+
- file: users_guide/modify_stream_files

docs/users_guide/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@ generation and manipulation.
99

1010
- [normalize_stream_files.py](normalize_stream_files.md): Unifies
1111
style (indentation) in eCLM DATM stream XML files.
12+
13+
- [modify_stream_files.py](modify_stream_files.md): Modifies field
14+
values inside eCLM DATM stream XML files.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# modify_stream_files.py
2+
3+
Modifies field values inside eCLM DATM stream XML files.
4+
5+
## What it does
6+
7+
The script rewrites the text content of specific XML elements in one
8+
or more stream files. Only the elements targeted by the supplied
9+
options are changed; everything else is left untouched.
10+
11+
Supported elements:
12+
13+
| Option | XML path |
14+
|------------------------------|-------------------------------|
15+
| `--datasource` | `<dataSource>` |
16+
| `--domaininfo-filepath` | `<domainInfo><filePath>` |
17+
| `--domaininfo-filenames` | `<domainInfo><fileNames>` |
18+
| `--domaininfo-variablenames` | `<domainInfo><variableNames>` |
19+
| `--fieldinfo-filepath` | `<fieldInfo><filePath>` |
20+
| `--fieldinfo-filenames` | `<fieldInfo><fileNames>` |
21+
| `--fieldinfo-variablenames` | `<fieldInfo><variableNames>` |
22+
| `--fieldinfo-offset` | `<fieldInfo><offset>` |
23+
24+
## Usage
25+
26+
```bash
27+
python3 modify_stream_files.py \
28+
--rundir /path/to/rundir \
29+
--file user_datm.streams.txt.CLMCRUNCEPv7.Solar \
30+
--domaininfo-filepath ./input_clm \
31+
--domaininfo-filenames domain.lnd.DE-RuS.240717.nc \
32+
--fieldinfo-filepath ./forcings \
33+
--fieldinfo-filenames "2022-01.nc\n2022-02.nc\n2022-03.nc"
34+
```
35+
36+
Multiple stream files can be modified in one call by listing them after
37+
`--file`:
38+
39+
```bash
40+
python3 modify_stream_files.py \
41+
--rundir /path/to/rundir \
42+
--file user_datm.streams.txt.CLMCRUNCEPv7.Solar \
43+
user_datm.streams.txt.CLMCRUNCEPv7.Precip \
44+
--fieldinfo-filepath ./forcings
45+
```
46+
47+
### Options
48+
49+
| Option | Description |
50+
|------------------------------|-----------------------------------------------------------------|
51+
| `-r`, `--rundir` | Directory containing the stream files. Defaults to `.` |
52+
| `-f`, `--file` | One or more stream filenames to modify (relative to `--rundir`) |
53+
| `--datasource` | `<dataSource>` text content |
54+
| `--domaininfo-filepath` | `<domainInfo><filePath>` — directory of the domain file |
55+
| `--domaininfo-filenames` | `<domainInfo><fileNames>` — domain filename(s) |
56+
| `--domaininfo-variablenames` | `<domainInfo><variableNames>` — variable name mapping pairs |
57+
| `--fieldinfo-filepath` | `<fieldInfo><filePath>` — directory of the forcing/field files |
58+
| `--fieldinfo-filenames` | `<fieldInfo><fileNames>` — field filename(s) |
59+
| `--fieldinfo-variablenames` | `<fieldInfo><variableNames>` — variable name mapping pairs |
60+
| `--fieldinfo-offset` | `<fieldInfo><offset>` |
61+
62+
### Multi-line values
63+
64+
Use `\n` in any string value to insert a newline in the output. This
65+
is the standard way to supply a list of files to
66+
`--fieldinfo-filenames` or `--domaininfo-filenames`:
67+
68+
```bash
69+
--fieldinfo-filenames " 2022-01.nc\n 2022-02.nc\n 2022-03.nc"
70+
```
71+
72+
produces:
73+
74+
```xml
75+
<fileNames>
76+
2022-01.nc
77+
2022-02.nc
78+
2022-03.nc
79+
</fileNames>
80+
```

modify_stream_files.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
"""
4+
Modify DATM XML stream files for eCLM runs.
5+
6+
Uses lxml for structured XML modification (preferred over regex
7+
because stream files are nested XML whose elements span multiple lines
8+
with varying indentation).
9+
10+
Usage example:
11+
python modify_stream_files.py \\
12+
--rundir ../namelist_eCLM_bgcmode-template \\
13+
--file user_datm.streams.txt.CLMCRUNCEPv7.Solar \\
14+
--domaininfo-filepath ./input_clm \\
15+
--domaininfo-filenames domain.lnd.DE-RuS.240717.nc \\
16+
--fieldinfo-filepath ./forcings \\
17+
--fieldinfo-filenames "2022-01.nc\\n2022-02.nc\\n2022-03.nc"
18+
19+
Newlines in values:
20+
Use the escape sequence \\n in any value to insert a real newline in
21+
the output, e.g. for multi-file <fileNames> entries.
22+
"""
23+
import argparse
24+
import os
25+
import shlex
26+
import sys
27+
from io import BytesIO
28+
29+
from lxml import etree
30+
31+
32+
# Maps argparse dest names to XPath expressions inside the stream file root.
33+
_XPATH_MAP = {
34+
"datasource": "dataSource",
35+
"domaininfo_filepath": "domainInfo/filePath",
36+
"domaininfo_filenames": "domainInfo/fileNames",
37+
"domaininfo_variablenames": "domainInfo/variableNames",
38+
"fieldinfo_filepath": "fieldInfo/filePath",
39+
"fieldinfo_filenames": "fieldInfo/fileNames",
40+
"fieldinfo_variablenames": "fieldInfo/variableNames",
41+
"fieldinfo_offset": "fieldInfo/offset",
42+
}
43+
44+
45+
def _set_element_text(root, xpath, new_text):
46+
"""Set the text of the first element matched by xpath.
47+
48+
The surrounding whitespace (indentation before the content, trailing
49+
newline/indent before the closing tag) is preserved from the original
50+
element text so that the file formatting is not disturbed.
51+
"""
52+
elements = root.xpath(xpath)
53+
if not elements:
54+
return False
55+
el = elements[0]
56+
old = el.text or ""
57+
stripped = old.strip()
58+
if stripped:
59+
prefix = old[: len(old) - len(old.lstrip())]
60+
suffix = old[len(old.rstrip()):]
61+
# For multi-line values, reduce the prefix to a bare newline so that
62+
# placeholder indentation (e.g. a tab before __forclist__) does not
63+
# carry over to the first line of the new value.
64+
if "\n" in new_text:
65+
prefix = prefix.rstrip(" \t") or "\n"
66+
else:
67+
# Element was empty — use a reasonable default indent
68+
prefix = "\n "
69+
suffix = "\n "
70+
el.text = prefix + new_text + suffix
71+
return True
72+
73+
74+
def modify_stream_file(path, **kwargs):
75+
"""Modify a single XML stream file in-place.
76+
77+
kwargs keys correspond to _XPATH_MAP entries; None values are skipped.
78+
"""
79+
# Preserve the original XML declaration (some files have encoding=,
80+
# others do not).
81+
with open(path, "r", encoding="utf-8") as fh:
82+
first_line = fh.readline()
83+
original_decl = first_line.rstrip("\n") if first_line.startswith("<?xml") else None
84+
85+
tree = etree.parse(path)
86+
root = tree.getroot()
87+
88+
for key, value in kwargs.items():
89+
if value is None:
90+
continue
91+
xpath = _XPATH_MAP.get(key)
92+
if xpath is None:
93+
continue
94+
_set_element_text(root, xpath, value)
95+
96+
# Serialise via a byte buffer so we can control the declaration.
97+
fbuffer = BytesIO()
98+
tree.write(fbuffer, xml_declaration=True, encoding="UTF-8")
99+
fstr = fbuffer.getvalue().decode("utf-8")
100+
101+
# Strip lxml's generated declaration and restore the original one.
102+
if fstr.startswith("<?xml"):
103+
fstr = fstr[fstr.index("?>") + 2:].lstrip("\n")
104+
if original_decl is not None:
105+
fstr = original_decl + "\n" + fstr
106+
107+
if not fstr.endswith("\n"):
108+
fstr += "\n"
109+
110+
with open(path, "w", encoding="utf-8") as fh:
111+
fh.write(fstr)
112+
113+
114+
if __name__ == "__main__":
115+
parser = argparse.ArgumentParser(
116+
description="Modify DATM XML stream files for eCLM runs",
117+
formatter_class=argparse.RawDescriptionHelpFormatter,
118+
)
119+
parser.add_argument(
120+
"-r", "--rundir",
121+
default=".",
122+
help="Directory containing the stream files to modify (default: current directory)",
123+
)
124+
parser.add_argument(
125+
"-f", "--file",
126+
nargs="+",
127+
required=True,
128+
metavar="STREAMFILE",
129+
help="Stream file(s) to modify (filenames relative to --rundir)",
130+
)
131+
132+
group_domaininfo = parser.add_argument_group(
133+
"domaininfo", "stream file <domainInfo> section"
134+
)
135+
group_domaininfo.add_argument(
136+
"--domaininfo-filepath", type=str, default=None,
137+
help="<domainInfo><filePath> — directory of the domain file",
138+
)
139+
group_domaininfo.add_argument(
140+
"--domaininfo-filenames", type=str, default=None,
141+
help="<domainInfo><fileNames> — domain filename(s), use \\n to separate multiple",
142+
)
143+
group_domaininfo.add_argument(
144+
"--domaininfo-variablenames", type=str, default=None,
145+
help="<domainInfo><variableNames> — variable name mapping pairs",
146+
)
147+
148+
group_fieldinfo = parser.add_argument_group(
149+
"fieldinfo", "stream file <fieldInfo> section"
150+
)
151+
group_fieldinfo.add_argument(
152+
"--fieldinfo-filepath", type=str, default=None,
153+
help="<fieldInfo><filePath> — directory of the forcing/field files",
154+
)
155+
group_fieldinfo.add_argument(
156+
"--fieldinfo-filenames", type=str, default=None,
157+
help="<fieldInfo><fileNames> — field filename(s), use \\n to separate multiple",
158+
)
159+
group_fieldinfo.add_argument(
160+
"--fieldinfo-variablenames", type=str, default=None,
161+
help="<fieldInfo><variableNames> — variable name mapping pairs",
162+
)
163+
group_fieldinfo.add_argument(
164+
"--fieldinfo-offset", type=str, default=None,
165+
help="<fieldInfo><offset>",
166+
)
167+
168+
parser.add_argument(
169+
"--datasource", type=str, default=None,
170+
help="<dataSource> text content",
171+
)
172+
173+
args = parser.parse_args()
174+
175+
# Expand \n escapes in all string values (same convention as
176+
# modify_case_namelists.py).
177+
kwargs = {
178+
k: v.replace("\\n", "\n") if isinstance(v, str) else v
179+
for k, v in vars(args).items()
180+
if k in _XPATH_MAP
181+
}
182+
183+
for filename in args.file:
184+
path = os.path.join(args.rundir, filename)
185+
modify_stream_file(path, **kwargs)

0 commit comments

Comments
 (0)