Skip to content

Commit 92e742b

Browse files
authored
Consolidate the config loaders around one raw-table merge (#127)
Collapses the config package's merge machinery onto a single raw-table deep-merge and cleans up the loader API around it. - Drop the object-merge helpers: `merge_microgrid_configs`, `merge_config_maps` and the nested per-object merge are gone. `load_configs` now assembles its layers (default files < Assets API < override files) as raw assets tables, deep-merges them, and loads once. The nested `assets.microgrids.<id>` structure gives per-ID merge and unique-ID passthrough for free; a None-skip in `_assets._deep_merge` (a no-op on real TOML, which has no null) reproduces the old "an unset override keeps the base value" semantics for the API layer. - Validate the merged whole. Because it loads once at the end, `load_configs` validates the merged document instead of each layer independently — so an override that is only complete after merging is now legal (see also #126). - load_configs returns the whole `AssetsConfig`. It already built the full merged document internally, then discarded everything but `.microgrids`, silently dropping the relations and `market_locations` the file layers carry. It now returns the document; callers wanting only the microgrid map read `.microgrids`. - `load_configs_from_api` is now private (`_load_microgrids_from_api`). An API-only load is just `load_configs(assets_client=..., microgrid_ids=...).microgrids`, so the standalone loader no longer needs to be public. ## Breaking changes (0.x) - `merge_config_maps` / `merge_microgrid_configs` removed. - `load_configs` returns `AssetsConfig`, not `dict[str, MicrogridConfig]`. - `load_configs_from_api` no longer public.
2 parents 0878c3e + 2c6262a commit 92e742b

9 files changed

Lines changed: 238 additions & 256 deletions

File tree

RELEASE_NOTES.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414
configs = AssetsConfig.load_from_files(path).microgrids
1515
```
1616

17-
`load_configs` keeps its existing interface. `load_from_files` layers files
18-
field by field instead of replacing a complete microgrid entry; fields omitted
19-
by a later file retain the value from the earlier layer and cannot be removed
20-
by omission.
17+
`load_configs` now returns the whole `AssetsConfig` rather than just its
18+
microgrids, so the file layers' `relations` and `market_locations` survive the
19+
merge; replace `load_configs(...)` with `load_configs(...).microgrids` where
20+
only the microgrid map is needed. `load_from_files` layers files field by field
21+
instead of replacing a complete microgrid entry; fields omitted by a later file
22+
retain the value from the earlier layer and cannot be removed by omission.
2123

2224
- `Metadata.delivery_area` is removed. Move its value to a relation's
2325
`delivery_area.code`, and set `delivery_area.code_type` when the code is not
@@ -30,6 +32,14 @@
3032
`AssetsConfig.load_from_files(files).microgrids`, or use the returned
3133
`AssetsConfig` directly to keep relations and market locations.
3234

35+
- `load_configs_from_api` is now private. For an API-only load call
36+
`load_configs(assets_client=..., microgrid_ids=...)` and read its `.microgrids`.
37+
38+
- `merge_config_maps` and `merge_microgrid_configs` are removed. Layering is now
39+
done on the raw tables before loading, inside `load_configs` and
40+
`AssetsConfig.load_from_files`; pass all the layers to one of those instead of
41+
merging loaded objects.
42+
3343
- Relation validity bounds and `at` query instants must include a UTC offset.
3444

3545
- The implementation modules `config.load` and `config.microgrid` are now

src/frequenz/gridpool/cli/__main__.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -210,24 +210,28 @@ async def generate_config(
210210
async with AssetsApiClient(url, auth_key=key, sign_secret=secret) as client:
211211
if inplace:
212212
# default_file is the patch target here, not a merge input.
213-
configs = await load_configs(
214-
assets_client=client,
215-
override_files=override_file,
216-
microgrid_ids=ids,
217-
component_graph_config=_graph_config(
218-
prefer_meters_in_component_formulas
219-
),
220-
)
213+
configs = (
214+
await load_configs(
215+
assets_client=client,
216+
override_files=override_file,
217+
microgrid_ids=ids,
218+
component_graph_config=_graph_config(
219+
prefer_meters_in_component_formulas
220+
),
221+
)
222+
).microgrids
221223
else:
222-
configs = await load_configs(
223-
default_files=default_file,
224-
assets_client=client,
225-
override_files=override_file,
226-
microgrid_ids=ids,
227-
component_graph_config=_graph_config(
228-
prefer_meters_in_component_formulas
229-
),
230-
)
224+
configs = (
225+
await load_configs(
226+
default_files=default_file,
227+
assets_client=client,
228+
override_files=override_file,
229+
microgrid_ids=ids,
230+
component_graph_config=_graph_config(
231+
prefer_meters_in_component_formulas
232+
),
233+
)
234+
).microgrids
231235

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

src/frequenz/gridpool/config/__init__.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@
66
from frequenz.microgrid_component_graph import ComponentGraphConfig, FormulaOverrides
77

88
from ._assets import AssetsConfig
9-
from ._load import (
10-
load_configs,
11-
load_configs_from_api,
12-
)
9+
from ._load import load_configs
1310
from ._microgrid import (
1411
BatteryConfig,
1512
ComponentCategory,
@@ -19,8 +16,6 @@
1916
MicrogridConfig,
2017
PVConfig,
2118
WindConfig,
22-
merge_config_maps,
23-
merge_microgrid_configs,
2419
)
2520
from ._topology import (
2621
DeliveryAreaConfig,
@@ -46,7 +41,4 @@
4641
"ValidityConfig",
4742
"WindConfig",
4843
"load_configs",
49-
"load_configs_from_api",
50-
"merge_config_maps",
51-
"merge_microgrid_configs",
5244
]

src/frequenz/gridpool/config/_assets.py

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,57 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any
2727
Nested tables are merged recursively; any other value in `override` replaces
2828
the one in `base`. Merging the raw tables, before they are loaded, keeps a
2929
field left unset in an override from resetting the base value to its default.
30+
31+
A `None` override is skipped so the base value survives. TOML has no null, so
32+
this only matters for a dumped-object layer (e.g. the Assets API) where unset
33+
fields carry `None`; on real file tables it is a no-op.
3034
"""
3135
result = dict(base)
3236
for key, value in override.items():
37+
if value is None:
38+
continue
3339
if isinstance(value, dict) and isinstance(result.get(key), dict):
3440
result[key] = _deep_merge(result[key], value)
3541
else:
3642
result[key] = value
3743
return result
3844

3945

46+
def _merge_file_tables(
47+
config_files: str | Path | list[str | Path],
48+
) -> dict[str, Any]:
49+
"""Read and deep-merge the raw `assets` tables of one or more files.
50+
51+
Later files win, entry by entry. Paths that are not files are skipped with a
52+
warning. Merging before loading lets an override leave a field unset without
53+
resetting the base value.
54+
55+
Args:
56+
config_files: A path or list of paths to TOML config files.
57+
58+
Returns:
59+
The merged raw `assets` table, unvalidated.
60+
61+
Raises:
62+
ValueError: If no config files are given.
63+
"""
64+
if isinstance(config_files, (str, Path)):
65+
paths = [Path(config_files)]
66+
else:
67+
paths = [Path(f) for f in config_files]
68+
if not paths:
69+
raise ValueError("No config files provided. Please provide at least one.")
70+
71+
merged: dict[str, Any] = {}
72+
for config_path in paths:
73+
if not config_path.is_file():
74+
_logger.warning("Config path %s is not a file, skipping.", config_path)
75+
continue
76+
# pylint: disable-next=protected-access
77+
merged = _deep_merge(merged, AssetsConfig._read_assets_table(config_path))
78+
return merged
79+
80+
4081
@dataclass
4182
class AssetsConfig:
4283
"""Entities described by a config document, keyed by their ID."""
@@ -386,24 +427,8 @@ def load_from_files(
386427
387428
Returns:
388429
The merged document.
389-
390-
Raises:
391-
ValueError: If no config files are given.
392430
"""
393-
if isinstance(config_files, (str, Path)):
394-
paths = [Path(config_files)]
395-
else:
396-
paths = [Path(f) for f in config_files]
397-
if not paths:
398-
raise ValueError("No config files provided. Please provide at least one.")
399-
400-
merged: dict[str, Any] = {}
401-
for config_path in paths:
402-
if not config_path.is_file():
403-
_logger.warning("Config path %s is not a file, skipping.", config_path)
404-
continue
405-
merged = _deep_merge(merged, cls._read_assets_table(config_path))
406-
431+
merged = _merge_file_tables(config_files)
407432
loaded = cls.Schema().load(merged)
408433
assert isinstance(loaded, cls)
409434
if check:

src/frequenz/gridpool/config/_load.py

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import logging
77
from pathlib import Path
8+
from typing import Any
89

910
from frequenz.client.assets import AssetsApiClient
1011
from frequenz.client.common.microgrid import MicrogridId
@@ -24,12 +25,11 @@
2425
pv_inverter_ids,
2526
pv_meter_ids,
2627
)
27-
from ._assets import AssetsConfig
28+
from ._assets import AssetsConfig, _deep_merge, _merge_file_tables
2829
from ._microgrid import (
2930
ComponentTypeConfig,
3031
Metadata,
3132
MicrogridConfig,
32-
merge_config_maps,
3333
)
3434

3535
_logger = logging.getLogger(__name__)
@@ -41,7 +41,7 @@ async def load_configs(
4141
override_files: str | Path | list[str | Path] | None = None,
4242
microgrid_ids: list[int] | None = None,
4343
component_graph_config: ComponentGraphConfig | None = None,
44-
) -> dict[str, "MicrogridConfig"]:
44+
) -> AssetsConfig:
4545
"""Load configs from up to three sources and merge them in layers.
4646
4747
Combines up to three sources, listed here from lowest to highest
@@ -81,14 +81,14 @@ async def load_configs(
8181
Requires an `assets_client`.
8282
8383
Returns:
84-
dict[str, MicrogridConfig]:
85-
Mapping from microgrid ID (as string) to the merged
86-
`MicrogridConfig` instance.
84+
The merged document. Use `.microgrids` for just the microgrid map; the
85+
file layers may also contribute `relations` and `market_locations`,
86+
which the Assets API layer does not provide.
8787
8888
Raises:
89-
ValueError: If none of the three sources is provided, or if
90-
`microgrid_ids` or `component_graph_config` is given without an
91-
`assets_client`.
89+
ValueError: If none of the three sources is provided, if `microgrid_ids`
90+
or `component_graph_config` is given without an `assets_client`, or
91+
if a file's `assets.microgrids` is not a table.
9292
"""
9393
if default_files is None and assets_client is None and override_files is None:
9494
raise ValueError("At least one config source must be provided.")
@@ -99,28 +99,46 @@ async def load_configs(
9999
if component_graph_config is not None and assets_client is None:
100100
raise ValueError("component_graph_config requires an assets_client.")
101101

102-
configs: dict[str, MicrogridConfig] = {}
102+
default_table: dict[str, Any] = {}
103103
if default_files is not None:
104-
configs = AssetsConfig.load_from_files(default_files).microgrids
104+
default_table = _merge_file_tables(default_files)
105105

106-
override_configs: dict[str, MicrogridConfig] = {}
106+
override_table: dict[str, Any] = {}
107107
if override_files is not None:
108-
override_configs = AssetsConfig.load_from_files(override_files).microgrids
108+
override_table = _merge_file_tables(override_files)
109109

110+
merged = default_table
110111
if assets_client is not None:
111112
if microgrid_ids is None:
112-
microgrid_ids = sorted({int(mid) for mid in (*configs, *override_configs)})
113-
assets_configs = await load_configs_from_api(
113+
file_ids: set[str] = set()
114+
for table in (default_table, override_table):
115+
microgrids = table.get("microgrids", {})
116+
if not isinstance(microgrids, dict):
117+
raise ValueError(
118+
f"`assets.microgrids` must be a table, got {type(microgrids)}"
119+
)
120+
file_ids |= set(microgrids)
121+
microgrid_ids = sorted(int(mid) for mid in file_ids)
122+
assets_configs = await _load_microgrids_from_api(
114123
assets_client=assets_client,
115124
microgrid_ids=microgrid_ids,
116125
component_graph_config=component_graph_config,
117126
)
118-
configs = merge_config_maps(base=configs, override=assets_configs)
127+
schema = MicrogridConfig.Schema()
128+
api_table: dict[str, Any] = {
129+
"microgrids": {mid: schema.dump(cfg) for mid, cfg in assets_configs.items()}
130+
}
131+
merged = _deep_merge(merged, api_table)
132+
133+
merged = _deep_merge(merged, override_table)
119134

120-
return merge_config_maps(base=configs, override=override_configs)
135+
loaded = AssetsConfig.Schema().load(merged)
136+
assert isinstance(loaded, AssetsConfig)
137+
loaded.check()
138+
return loaded
121139

122140

123-
async def load_configs_from_api(
141+
async def _load_microgrids_from_api(
124142
assets_client: AssetsApiClient,
125143
microgrid_ids: list[int],
126144
component_graph_config: ComponentGraphConfig | None = None,
@@ -129,8 +147,8 @@ async def load_configs_from_api(
129147
130148
For each microgrid, fetches its location metadata (latitude, longitude) and
131149
then derives the per-type formulas and meter/inverter/component IDs from its
132-
component graph. This is the canonical single-source loader so that callers
133-
(e.g. the forecast pipeline) do not have to re-implement this logic.
150+
component graph. Builds the Assets API layer that `load_configs` merges; for
151+
an API-only load call `load_configs(assets_client=..., microgrid_ids=...)`.
134152
135153
The two steps fail independently: a microgrid whose metadata cannot be
136154
fetched is skipped, while one whose component graph cannot be derived is

src/frequenz/gridpool/config/_microgrid.py

Lines changed: 0 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
import logging
77
import re
8-
from copy import deepcopy
98
from dataclasses import field
109
from datetime import datetime
1110
from typing import Any, ClassVar, Literal, Self, Type, cast, get_args
@@ -331,66 +330,3 @@ def _load_table_entries(cls, data: dict[str, Any]) -> dict[str, Self]:
331330
mgrids[mid] = mgrid
332331

333332
return mgrids
334-
335-
336-
def merge_microgrid_configs(
337-
base: MicrogridConfig,
338-
override: MicrogridConfig,
339-
) -> MicrogridConfig:
340-
"""Merge two `MicrogridConfig` objects.
341-
342-
The *override* config takes precedence over *base*. Nested dictionaries
343-
are merged recursively. If a field in *override* is `None` the value
344-
from *base* is retained, so partial overrides never nullify existing data.
345-
346-
Args:
347-
base: The base MicrogridConfig.
348-
override: The overriding MicrogridConfig.
349-
350-
Returns:
351-
A new MicrogridConfig representing the merged result.
352-
"""
353-
schema = MicrogridConfig.Schema()
354-
base_dict = schema.dump(base)
355-
override_dict = schema.dump(override)
356-
357-
def _deep_merge(a: dict[Any, Any], b: dict[Any, Any]) -> dict[Any, Any]:
358-
result = deepcopy(a)
359-
for k, v in b.items():
360-
if v is None:
361-
continue
362-
if isinstance(v, dict) and isinstance(result.get(k), dict):
363-
result[k] = _deep_merge(result[k], v)
364-
else:
365-
result[k] = v
366-
return result
367-
368-
merged = schema.load(_deep_merge(base_dict, override_dict))
369-
assert isinstance(merged, MicrogridConfig)
370-
return merged
371-
372-
373-
def merge_config_maps(
374-
base: dict[str, MicrogridConfig],
375-
override: dict[str, MicrogridConfig],
376-
) -> dict[str, MicrogridConfig]:
377-
"""Merge two dictionaries of `MicrogridConfig` objects.
378-
379-
For microgrid IDs present in both maps the configs are merged via
380-
`merge_microgrid_configs`. IDs that exist only in one map are
381-
included unchanged.
382-
383-
Args:
384-
base: The base dictionary of MicrogridConfig objects.
385-
override: The overriding dictionary of MicrogridConfig objects.
386-
387-
Returns:
388-
A new dictionary representing the merged result.
389-
"""
390-
merged = dict(base)
391-
for mid, cfg in override.items():
392-
if mid in merged:
393-
merged[mid] = merge_microgrid_configs(merged[mid], cfg)
394-
else:
395-
merged[mid] = cfg
396-
return merged

tests/test_config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,9 @@ async def test_merge_prefixed_base_with_legacy_override(tmp_path: Path) -> None:
237237
tmp_path, "override.toml", '1.meta.microgrid_id = 1\n1.meta.name = "Renamed"\n'
238238
)
239239

240-
configs = await load_configs(default_files=base, override_files=override)
240+
configs = (
241+
await load_configs(default_files=base, override_files=override)
242+
).microgrids
241243

242244
assert configs["1"].meta.name == "Renamed"
243245
assert configs["1"].component_type_ids("pv") == [101, 102]

0 commit comments

Comments
 (0)