Skip to content

Commit b59f4d6

Browse files
authored
feat(cli): add --inplace patching to generate-config (#109)
Regenerating a config file from scratch drops its comments and reorders every field (tomllib read + fresh tomlkit rebuild). --inplace instead patches --default via tomlkit.parse(), only inserting missing leaves, so comments, order and formatting survive untouched. Also render whole-number floats (e.g. peak/rated power) as underscore-grouped ints (`1_736_680`, not `1736680.0`) to match the existing hand-written convention and avoid spurious diffs.
2 parents 1f38b42 + aa4fc07 commit b59f4d6

6 files changed

Lines changed: 389 additions & 17 deletions

File tree

RELEASE_NOTES.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010

1111
## New Features
1212

13-
<!-- Here goes the main new features and examples or instructions on how to use them -->
13+
* Added an `--inplace` flag to the `generate-config` CLI command: patches `--default` directly instead of printing to stdout, only filling in values it's missing so existing comments, field order and formatting survive untouched.
14+
* `generate-config` now renders whole-number values (e.g. peak/rated power) as underscore-grouped ints (`1_736_680`) instead of floats (`1736680.0`), avoiding spurious diffs.
1415

1516
## Bug Fixes
1617

src/frequenz/gridpool/cli/__main__.py

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44
"""CLI tool for gridpool functionality."""
55

66
import os
7+
import tempfile
78
from pathlib import Path
89

910
import asyncclick as click
1011
from frequenz.client.assets import AssetsApiClient
1112
from frequenz.client.common.microgrid import MicrogridId
1213

13-
from frequenz.gridpool import ComponentGraphGenerator, load_configs
14+
from frequenz.gridpool import ComponentGraphGenerator, MicrogridConfig, load_configs
1415
from frequenz.gridpool.cli._dump_config import dump_map
16+
from frequenz.gridpool.cli._patch_config import patch_file
1517
from frequenz.gridpool.cli._render_graph import ComponentGraphRenderer, RenderOptions
1618

1719

@@ -123,23 +125,39 @@ async def render_graph(microgrid_id: int, output: str, show: bool) -> None:
123125
default=None,
124126
help="Config file whose values override the Assets API (highest precedence).",
125127
)
128+
@click.option(
129+
"--inplace",
130+
is_flag=True,
131+
default=False,
132+
help="Patch --default in place instead of printing to stdout. Preserves "
133+
"existing comments, ordering and formatting in that file; only fills in "
134+
"values it is missing. Requires --default.",
135+
)
126136
async def generate_config(
127137
microgrid_ids: tuple[int, ...],
128138
default_file: Path | None,
129139
override_file: Path | None,
140+
inplace: bool,
130141
) -> None:
131142
"""Generate microgrid config from the Assets API as TOML.
132143
133144
Derives metadata, formulas and component IDs for the given microgrid IDs and
134145
prints the result as dotted-key TOML to stdout.
135146
136-
`--default` and `--override` each take a config file and are layered with the
137-
Assets API by precedence: `--default` < Assets API < `--override`. So a file
138-
passed as `--override` keeps its values where it has them (the API only fills
139-
gaps), while a file passed as `--default` is overridden by the API. If no
140-
microgrid IDs are given, they are taken from the supplied files. Files are
141-
only read; redirect stdout to save the result.
147+
`--default` and `--override` each take a config file, layered with the Assets
148+
API by precedence: `--default` < Assets API < `--override`. If no microgrid
149+
IDs are given, they are taken from the supplied files. Files are only read;
150+
redirect stdout to save the result.
151+
152+
With `--inplace`, `--default` is patched directly instead: candidate values
153+
come from the Assets API (with `--override` layered on top), and only
154+
leaves `--default` is missing are added, preserving its existing comments,
155+
field order and formatting. If no microgrid IDs are given, every microgrid
156+
already in `--default` is processed.
142157
"""
158+
if inplace and default_file is None:
159+
raise click.ClickException("--inplace requires --default.")
160+
143161
url = os.environ.get("ASSETS_API_URL")
144162
key = os.environ.get("ASSETS_API_AUTH_KEY")
145163
secret = os.environ.get("ASSETS_API_SIGN_SECRET")
@@ -148,18 +166,46 @@ async def generate_config(
148166
"ASSETS_API_URL, ASSETS_API_AUTH_KEY, ASSETS_API_SIGN_SECRET must be set."
149167
)
150168

169+
ids = list(dict.fromkeys(microgrid_ids)) or None
170+
if inplace and ids is None:
171+
assert default_file is not None
172+
ids = sorted(int(mid) for mid in MicrogridConfig.load_from_file(default_file))
173+
151174
async with AssetsApiClient(url, auth_key=key, sign_secret=secret) as client:
152-
configs = await load_configs(
153-
default_files=default_file,
154-
assets_client=client,
155-
override_files=override_file,
156-
microgrid_ids=list(dict.fromkeys(microgrid_ids)) or None,
157-
)
175+
if inplace:
176+
# default_file is the patch target here, not a merge input.
177+
configs = await load_configs(
178+
assets_client=client,
179+
override_files=override_file,
180+
microgrid_ids=ids,
181+
)
182+
else:
183+
configs = await load_configs(
184+
default_files=default_file,
185+
assets_client=client,
186+
override_files=override_file,
187+
microgrid_ids=ids,
188+
)
158189

159190
if not configs:
160191
raise click.ClickException("No microgrids could be loaded; nothing to write.")
161192

162-
click.echo(dump_map(configs), nl=False)
193+
if inplace:
194+
assert default_file is not None
195+
patched = patch_file(default_file, configs)
196+
fd, tmp_name = tempfile.mkstemp(
197+
dir=default_file.parent, prefix=f".{default_file.name}."
198+
)
199+
try:
200+
with os.fdopen(fd, "w") as tmp_file:
201+
tmp_file.write(patched)
202+
os.replace(tmp_name, default_file)
203+
except BaseException:
204+
os.remove(tmp_name)
205+
raise
206+
click.echo(f"Patched {default_file}", err=True)
207+
else:
208+
click.echo(dump_map(configs), nl=False)
163209

164210

165211
def main() -> None:

src/frequenz/gridpool/cli/_dump_config.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from typing import Any
1919

2020
import tomlkit
21+
from tomlkit.items import Integer, Trivia
2122

2223
from frequenz.gridpool import MicrogridConfig
2324

@@ -27,6 +28,24 @@ def _is_empty(value: Any) -> bool:
2728
return value is None or value == {} or value == []
2829

2930

31+
def _format_value(value: Any) -> Any:
32+
"""Render whole-number floats and ints as underscore-grouped ints, e.g. `1_736_680`.
33+
34+
Args:
35+
value: The value about to be written to the TOML document.
36+
37+
Returns:
38+
The formatted value, or `value` itself if it is not a whole number.
39+
"""
40+
if isinstance(value, float) and value.is_integer():
41+
value = int(value)
42+
if isinstance(value, int) and not isinstance(value, bool):
43+
# tomlkit.integer() just does int(raw), dropping underscores; build
44+
# the Integer item directly instead.
45+
return Integer(value, Trivia(), f"{value:_d}")
46+
return value
47+
48+
3049
def _iter_leaves(
3150
prefix: list[str], data: dict[str, Any]
3251
) -> list[tuple[list[str], Any]]:
@@ -73,5 +92,5 @@ def dump_map(configs: dict[str, MicrogridConfig]) -> str:
7392
if doc.body:
7493
doc.add(tomlkit.nl())
7594
for path, value in leaves:
76-
doc.append(tomlkit.key(path), value)
95+
doc.append(tomlkit.key(path), _format_value(value))
7796
return tomlkit.dumps(doc)
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Patch existing dotted-key TOML files with new microgrid config values.
5+
6+
Unlike `dump_map`, which rebuilds a TOML document from scratch, this module
7+
only adds missing leaves and missing microgrid entries. Values already on
8+
disk always win, so comments, field order and number formatting survive.
9+
"""
10+
11+
from collections.abc import Mapping
12+
from pathlib import Path
13+
from typing import Any
14+
15+
import tomlkit
16+
from tomlkit import TOMLDocument
17+
18+
from frequenz.gridpool import MicrogridConfig
19+
20+
from ._dump_config import _format_value, _iter_leaves
21+
22+
23+
def patch_file(path: Path, configs: dict[str, MicrogridConfig]) -> str:
24+
"""Patch the TOML file at `path` with any leaves missing from `configs`.
25+
26+
Args:
27+
path: Path to the existing TOML file to patch.
28+
configs: Mapping from microgrid ID (as string) to `MicrogridConfig`.
29+
30+
Returns:
31+
The patched TOML text; the caller is responsible for writing it back.
32+
"""
33+
return patch_text(path.read_text(), configs)
34+
35+
36+
def patch_text(original: str, configs: dict[str, MicrogridConfig]) -> str:
37+
"""Patch dotted-key TOML text with any leaves missing from `configs`.
38+
39+
Args:
40+
original: The existing TOML text to patch.
41+
configs: Mapping from microgrid ID (as string) to `MicrogridConfig`.
42+
43+
Returns:
44+
The patched TOML text.
45+
"""
46+
doc = tomlkit.parse(original)
47+
schema = MicrogridConfig.Schema()
48+
49+
# Leaves needing a whole new sub-table can't be inserted via item assignment
50+
# without tomlkit falling back to bracket-header syntax, so they are spliced
51+
# into the rendered text instead.
52+
orphans: dict[str, list[tuple[list[str], Any]]] = {}
53+
54+
for mid, cfg in configs.items():
55+
dumped = schema.dump(cfg)
56+
assert isinstance(dumped, dict)
57+
leaves = _iter_leaves([], dumped)
58+
if not leaves:
59+
continue
60+
61+
if mid not in doc:
62+
_append_new_entry(doc, mid, leaves)
63+
continue
64+
65+
for path, value in leaves:
66+
if _leaf_exists(doc, mid, path):
67+
continue
68+
if not _insert_leaf(doc, mid, path, value):
69+
orphans.setdefault(mid, []).append((path, value))
70+
71+
text = tomlkit.dumps(doc)
72+
if orphans:
73+
text = _splice_orphans(text, orphans)
74+
return text
75+
76+
77+
def _leaf_exists(doc: TOMLDocument, mid: str, path: list[str]) -> bool:
78+
"""Whether the dotted key `mid.path...` already has a value in `doc`."""
79+
node: Any = doc
80+
for key in (mid, *path):
81+
if not isinstance(node, Mapping) or key not in node:
82+
return False
83+
node = node[key]
84+
return True
85+
86+
87+
def _insert_leaf(doc: TOMLDocument, mid: str, path: list[str], value: Any) -> bool:
88+
"""Insert a leaf directly onto its existing parent table, if there is one.
89+
90+
Args:
91+
doc: The document being patched, mutated in place.
92+
mid: Microgrid ID the leaf belongs to.
93+
path: Dotted key path under `mid` for the leaf.
94+
value: The leaf's value.
95+
96+
Returns:
97+
`True` if the leaf was inserted, `False` if a whole new sub-table is
98+
needed instead (left for the caller to handle).
99+
"""
100+
node: Any = doc
101+
matched = 0
102+
for key in (mid, *path[:-1]):
103+
nxt = node[key] if isinstance(node, Mapping) and key in node else None
104+
if not isinstance(nxt, Mapping):
105+
break
106+
node = nxt
107+
matched += 1
108+
109+
full_path = [mid, *path]
110+
if matched != len(full_path) - 1:
111+
return False
112+
node[full_path[-1]] = _format_value(value)
113+
return True
114+
115+
116+
def _append_new_entry(
117+
doc: TOMLDocument, mid: str, leaves: list[tuple[list[str], Any]]
118+
) -> None:
119+
"""Append a brand-new microgrid entry at the end of the document.
120+
121+
Args:
122+
doc: The document being patched, mutated in place.
123+
mid: Microgrid ID of the new entry.
124+
leaves: Flattened `(path, value)` pairs for the new entry, in
125+
dataclass field order.
126+
"""
127+
if doc.body:
128+
doc.add(tomlkit.nl())
129+
for path, value in leaves:
130+
doc.append(tomlkit.key([mid, *path]), _format_value(value))
131+
132+
133+
def _render_lines(mid: str, leaves: list[tuple[list[str], Any]]) -> list[str]:
134+
"""Render `(path, value)` leaves as standalone `mid.path = value` lines."""
135+
tmp = tomlkit.document()
136+
for path, value in leaves:
137+
tmp.append(tomlkit.key([mid, *path]), _format_value(value))
138+
return tomlkit.dumps(tmp).splitlines(keepends=True)
139+
140+
141+
def _splice_orphans(text: str, orphans: dict[str, list[tuple[list[str], Any]]]) -> str:
142+
"""Insert each microgrid's orphaned leaves right after its own last line.
143+
144+
Args:
145+
text: The already-rendered document text.
146+
orphans: Mapping from microgrid ID to its `(path, value)` leaves
147+
that need a brand-new sub-table.
148+
149+
Returns:
150+
`text` with the orphaned leaves inserted.
151+
"""
152+
lines = text.splitlines(keepends=True)
153+
for mid, leaves in orphans.items():
154+
insert_at = _last_line_index_for_mid(lines, mid)
155+
new_lines = _render_lines(mid, leaves)
156+
lines[insert_at + 1 : insert_at + 1] = new_lines
157+
return "".join(lines)
158+
159+
160+
def _last_line_index_for_mid(lines: list[str], mid: str) -> int:
161+
"""Index of the last line belonging to `mid` (its key starts with `mid.`)."""
162+
prefix = f"{mid}."
163+
for idx in range(len(lines) - 1, -1, -1):
164+
if lines[idx].lstrip().startswith(prefix):
165+
return idx
166+
raise ValueError(f"No existing line found for microgrid {mid!r}.")

tests/test_dump_config.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from frequenz.gridpool import MicrogridConfig
99
from frequenz.gridpool.cli._dump_config import dump_map
10-
from frequenz.gridpool.config.microgrid import ComponentTypeConfig, Metadata
10+
from frequenz.gridpool.config.microgrid import ComponentTypeConfig, Metadata, PVConfig
1111

1212

1313
def test_dump_map_round_trips() -> None:
@@ -42,3 +42,20 @@ def test_dump_map_omits_empty_and_none() -> None:
4242
def test_dump_map_empty() -> None:
4343
"""An empty mapping serializes to an empty string."""
4444
assert dump_map({}) == ""
45+
46+
47+
def test_dump_map_renders_whole_floats_as_underscored_ints() -> None:
48+
"""Whole-number float fields (e.g. peak/rated power) render as `1_736_680`, not `1736680.0`."""
49+
configs = {
50+
"10": MicrogridConfig(
51+
meta=Metadata(microgrid_id=10, latitude=52.5),
52+
pv={"1": PVConfig(peak_power=1_736_680.0, rated_power=1_400_000.0)},
53+
)
54+
}
55+
56+
text = dump_map(configs)
57+
58+
assert "10.pv.1.peak_power = 1_736_680\n" in text
59+
assert "10.pv.1.rated_power = 1_400_000\n" in text
60+
# Genuinely fractional floats are left alone.
61+
assert "10.meta.latitude = 52.5\n" in text

0 commit comments

Comments
 (0)