Skip to content

Commit 54f9a31

Browse files
authored
feat(config)!: drop legacy layouts and support marshmallow 3 (#132)
Dropping the legacy microgrid config layouts (breaking) and fixing config loading under marshmallow 3. ## Breaking change The top-level microgrid-ID and `meta`-nested layouts are no longer migrated on load. A file with no `assets` table now loads as empty and contributes nothing to a merge, instead of having its top-level keys read as microgrid IDs. This lets a mixed list of files (some carrying an `[app]` table for their own config, some an `assets` table) be passed to `AssetsConfig.load_from_files` / `load_configs`, which reads only the `assets`-bearing ones. Rebuild any legacy-layout files with `generate-config`. `_STEPS` is now empty; `assets.version` validation and stamping remain. ## Bug fix `tomllib` parses TOML datetimes to native `datetime` objects, which marshmallow 3's `DateTime` field rejected (marshmallow 4 accepts them). A `TomlAwareDateTime` field passes an already-parsed `datetime` through and otherwise defers to the parent for ISO strings, applied to every bare datetime config field sourced from TOML. ## Build Now declares `marshmallow` as a direct dependency. It was already used transitively via `marshmallow-dataclass` but never declared, so the minimum-version job left it free to resolve to marshmallow 4. Declaring it pins the lower bound so the marshmallow-3 path is tested.
2 parents 2bd87b1 + 7092052 commit 54f9a31

12 files changed

Lines changed: 147 additions & 212 deletions

File tree

‎RELEASE_NOTES.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ validate, patch, and query merged config files.
88

99
## Upgrading
1010

11+
- The legacy config layouts are no longer migrated on load: top-level microgrid
12+
IDs and `meta`-nested microgrid fields are gone. A file with no `assets` table
13+
now loads as empty and contributes nothing to a merge, rather than having its
14+
top-level keys read as microgrid IDs. This lets a mixed list of files (some
15+
carrying an `[app]` table for their own config, some an `assets` table) be
16+
passed to `AssetsConfig.load_from_files` / `load_configs`, which reads only the
17+
`assets`-bearing ones. Rebuild any legacy-layout files with `generate-config`.
18+
1119
- `generate-config` now writes the `assets.microgrids` layout and stamps
1220
`assets.version`, instead of the legacy layout. Regenerate configs to get the
1321
current format; generated files then load back without a migration.
@@ -40,3 +48,10 @@ validate, patch, and query merged config files.
4048
- `generate-config --inplace` refreshes managed values while preserving
4149
formatting; `--fill-missing` only adds absent values. Generated patches are
4250
validated before writing.
51+
52+
## Bug Fixes
53+
54+
- Config files with validity periods now load under marshmallow 3, not only
55+
marshmallow 4. `tomllib` yields native `datetime` objects, which marshmallow
56+
3's `DateTime` field rejected. `marshmallow` is now a direct dependency so the
57+
minimum-version test exercises this path.

‎pyproject.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ classifiers = [
2626
]
2727
requires-python = ">= 3.11, < 4"
2828
dependencies = [
29+
# Used transitively via marshmallow-dataclass (every config schema is a
30+
# `marshmallow.Schema`); declared directly so `pytest_min` pins its lower
31+
# bound and the minimum-version path is actually tested.
32+
"marshmallow >= 3.18.0, < 5",
2933
"marshmallow-dataclass >= 8.7.1, < 9",
3034
"typing-extensions >= 4.14.1, < 5",
3135
"frequenz-microgrid-component-graph >= 0.5.0, < 0.6",

‎src/frequenz/gridpool/cli/_dump_config.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
fields are done here, since TOML has no null and `tomlkit` rejects it.
1818
"""
1919

20+
from dataclasses import asdict
2021
from typing import Any
2122

2223
import tomlkit
@@ -86,13 +87,10 @@ def dump_map(configs: dict[int, MicrogridConfig]) -> str:
8687
The TOML representation as a string, with one blank line between
8788
microgrids and entries sorted by numeric microgrid ID.
8889
"""
89-
schema = MicrogridConfig.Schema()
9090
doc = tomlkit.document()
9191
doc.append(tomlkit.key(["assets", "version"]), _CURRENT_VERSION)
9292
for mid in sorted(configs):
93-
dumped = schema.dump(configs[mid])
94-
assert isinstance(dumped, dict)
95-
leaves = _iter_leaves([*_MICROGRID_PREFIX, str(mid)], dumped)
93+
leaves = _iter_leaves([*_MICROGRID_PREFIX, str(mid)], asdict(configs[mid]))
9694
if not leaves:
9795
continue
9896
doc.add(tomlkit.nl())

‎src/frequenz/gridpool/cli/_patch_config.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"""
1111

1212
from collections.abc import Mapping
13+
from dataclasses import asdict
1314
from pathlib import Path
1415
from typing import Any
1516

@@ -54,7 +55,6 @@ def patch_text(
5455
"""
5556
doc = tomlkit.parse(original)
5657
_reject_legacy_layout(doc)
57-
schema = MicrogridConfig.Schema()
5858

5959
# Leaves needing a whole new sub-table can't be inserted via item assignment
6060
# without tomlkit falling back to bracket-header syntax, so they are spliced
@@ -63,9 +63,7 @@ def patch_text(
6363

6464
for microgrid_id, cfg in configs.items():
6565
mid = str(microgrid_id)
66-
dumped = schema.dump(cfg)
67-
assert isinstance(dumped, dict)
68-
leaves = _iter_leaves([], dumped)
66+
leaves = _iter_leaves([], asdict(cfg))
6967
if not leaves:
7068
continue
7169

‎src/frequenz/gridpool/config/_assets.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -443,23 +443,28 @@ def _read_assets_table(cls, config_path: Path) -> dict[str, Any]:
443443
"""Read the raw `assets` table from a TOML file.
444444
445445
The document is migrated to the current format before its `assets`
446-
table is returned for merging.
446+
table is returned for merging. A file with no `assets` table contributes
447+
nothing to the merge, so consumers can pass a mixed list of files and
448+
gridpool reads only the `assets`-bearing ones.
447449
448450
Args:
449451
config_path: The path to the TOML configuration file.
450452
451453
Returns:
452-
The raw `assets` table, unvalidated, for merging before it is loaded.
454+
The raw `assets` table, unvalidated, for merging before it is loaded,
455+
or an empty table if the file has none.
453456
454457
Raises:
455-
TypeError: If `assets` is not a table.
458+
TypeError: If `assets` is present but not a table.
456459
"""
457460
with config_path.open("rb") as f:
458461
data: dict[str, Any] = tomllib.load(f)
459462

460463
data = migrate(data, config_path)
461464

462-
assets = data["assets"]
465+
assets = data.get("assets")
466+
if assets is None:
467+
return {}
463468
if not isinstance(assets, dict):
464469
raise TypeError(
465470
f"{config_path}: `assets` must be a table, got {type(assets)}"
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# License: MIT
2+
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Reusable marshmallow fields for config data models."""
5+
6+
from datetime import datetime
7+
from typing import Any
8+
9+
import marshmallow
10+
11+
12+
class TomlAwareDateTime(marshmallow.fields.DateTime):
13+
"""A `DateTime` that also accepts an already-parsed `datetime`.
14+
15+
marshmallow 3's `DateTime` deserialises only ISO strings, but `tomllib`
16+
yields native `datetime` objects before the value reaches marshmallow, so
17+
those inputs must pass through unchanged. marshmallow 4 accepts them itself;
18+
this keeps both behaving the same.
19+
"""
20+
21+
def _deserialize(self, value: Any, *args: Any, **kwargs: Any) -> datetime:
22+
if isinstance(value, datetime):
23+
return value
24+
return super()._deserialize(value, *args, **kwargs)
25+
26+
27+
def toml_datetime_metadata() -> dict[str, Any]:
28+
"""Field metadata mapping a TOML-sourced `datetime` to `TomlAwareDateTime`."""
29+
return {"marshmallow_field": TomlAwareDateTime(allow_none=True)}

‎src/frequenz/gridpool/config/_microgrid.py‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from marshmallow import Schema
1212
from marshmallow_dataclass import dataclass
1313

14+
from ._fields import toml_datetime_metadata
15+
1416
ComponentType = Literal["grid", "pv", "battery", "consumption", "chp", "ev"]
1517
"""Valid component types."""
1618

@@ -98,10 +100,10 @@ def is_valid_type(cls, ctype: str) -> bool:
98100
class PVConfig:
99101
"""Configuration of a PV system in a microgrid."""
100102

101-
start_time: datetime | None = None
103+
start_time: datetime | None = field(default=None, metadata=toml_datetime_metadata())
102104
"""Start time of the PV system installation."""
103105

104-
end_time: datetime | None = None
106+
end_time: datetime | None = field(default=None, metadata=toml_datetime_metadata())
105107
"""End time of the PV system installation."""
106108

107109
peak_power: float | None = None
@@ -119,10 +121,10 @@ class WindConfig:
119121
# pylint: disable=too-many-instance-attributes
120122
"""Configuration of a wind turbine in a microgrid."""
121123

122-
start_time: datetime | None = None
124+
start_time: datetime | None = field(default=None, metadata=toml_datetime_metadata())
123125
"""Start time of the wind turbine installation."""
124126

125-
end_time: datetime | None = None
127+
end_time: datetime | None = field(default=None, metadata=toml_datetime_metadata())
126128
"""End time of the wind turbine installation."""
127129

128130
turbine_model: str | None = None
@@ -151,10 +153,10 @@ class WindConfig:
151153
class BatteryConfig:
152154
"""Configuration of a battery in a microgrid."""
153155

154-
start_time: datetime | None = None
156+
start_time: datetime | None = field(default=None, metadata=toml_datetime_metadata())
155157
"""Start time of the battery installation."""
156158

157-
end_time: datetime | None = None
159+
end_time: datetime | None = field(default=None, metadata=toml_datetime_metadata())
158160
"""End time of the battery installation."""
159161

160162
capacity: float | None = None
@@ -187,10 +189,10 @@ class MicrogridConfig:
187189
altitude: float | None = None
188190
"""Geographic altitude of the microgrid."""
189191

190-
start_time: datetime | None = None
192+
start_time: datetime | None = field(default=None, metadata=toml_datetime_metadata())
191193
"""Start time of the microgrid operation."""
192194

193-
end_time: datetime | None = None
195+
end_time: datetime | None = field(default=None, metadata=toml_datetime_metadata())
194196
"""End time of the microgrid operation."""
195197

196198
pv: dict[str, PVConfig] | None = None

‎src/frequenz/gridpool/config/_migrations.py‎

Lines changed: 7 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,16 @@
33

44
"""Format migrations for raw config documents.
55
6-
Each step rewrites the raw `dict` a file parses to, before it is loaded into the
7-
typed model. Steps are self-detecting and idempotent, so the `version` field
8-
does not gate whether a step runs; it only records how old a file may be, which
9-
bounds when a step may be dropped.
6+
Loading validates `assets.version` and stamps the current version, so a file
7+
written for a newer reader is rejected rather than silently misread. Each
8+
migration step rewrites the raw `dict` before it is loaded into the typed
9+
model; steps are self-detecting and idempotent, so the `version` field does not
10+
gate whether a step runs. No legacy layouts remain, so `_STEPS` is empty.
1011
"""
1112

12-
import logging
1313
from pathlib import Path
1414
from typing import Any, Callable
1515

16-
_logger = logging.getLogger(__name__)
17-
1816
_CURRENT_VERSION = 1
1917

2018

@@ -27,7 +25,7 @@ def migrate(raw: dict[str, Any], source: Path) -> dict[str, Any]:
2725
2826
Args:
2927
raw: The raw document as parsed from a file.
30-
source: The file it was parsed from, for warnings and errors.
28+
source: The file it was parsed from, named in error messages.
3129
3230
Returns:
3331
The document in the current format.
@@ -56,84 +54,5 @@ def migrate(raw: dict[str, Any], source: Path) -> dict[str, Any]:
5654
return raw
5755

5856

59-
def _nest_top_level_microgrids(raw: dict[str, Any], source: Path) -> dict[str, Any]:
60-
"""Wrap deprecated top-level microgrid entries under `assets.microgrids`.
61-
62-
Args:
63-
raw: The raw document.
64-
source: The file it was parsed from.
65-
66-
Returns:
67-
The document with its entries under `assets`.
68-
69-
Raises:
70-
ValueError: If both layouts are present, a half-migrated file rather
71-
than a merge.
72-
"""
73-
if "assets" in raw:
74-
stray = sorted(k for k in raw if k != "assets")
75-
if stray:
76-
raise ValueError(
77-
f"{source}: keys {stray} sit outside `assets` while the file "
78-
"already has an `assets` table; move them under `assets.microgrids`."
79-
)
80-
return raw
81-
82-
_logger.warning(
83-
"%s: top-level microgrid IDs are deprecated, "
84-
"nest the entries under `assets.microgrids` instead.",
85-
source,
86-
)
87-
return {"assets": {"microgrids": dict(raw)}}
88-
89-
90-
def _lift_microgrid_meta(raw: dict[str, Any], source: Path) -> dict[str, Any]:
91-
"""Merge a deprecated nested `meta` table onto its microgrid entry.
92-
93-
Earlier files nested a microgrid's fields under `meta`; they now sit
94-
directly on the entry.
95-
96-
Args:
97-
raw: The raw document.
98-
source: The file it was parsed from.
99-
100-
Returns:
101-
The document with microgrid fields directly on each entry.
102-
103-
Raises:
104-
TypeError: If a `meta` value is not a table.
105-
ValueError: If a field is set both under `meta` and on the entry.
106-
"""
107-
assets = raw.get("assets")
108-
if not isinstance(assets, dict):
109-
return raw
110-
microgrids = assets.get("microgrids")
111-
if not isinstance(microgrids, dict):
112-
return raw
113-
for mid, entry in microgrids.items():
114-
if not isinstance(entry, dict) or "meta" not in entry:
115-
continue
116-
meta = entry.pop("meta")
117-
if not isinstance(meta, dict):
118-
raise TypeError(f"{source}: microgrid {mid} `meta` must be a table")
119-
_logger.warning(
120-
"%s: microgrid %s nests fields under `meta`; that is deprecated, "
121-
"set them directly on the entry instead.",
122-
source,
123-
mid,
124-
)
125-
for key, value in meta.items():
126-
if key in entry:
127-
raise ValueError(
128-
f"{source}: microgrid {mid} sets `{key}` both under `meta` "
129-
"and directly; keep only the direct one"
130-
)
131-
entry[key] = value
132-
return raw
133-
134-
135-
_STEPS: list[tuple[int, Callable[[dict[str, Any], Path], dict[str, Any]]]] = [
136-
(0, _nest_top_level_microgrids),
137-
(0, _lift_microgrid_meta),
138-
]
57+
_STEPS: list[tuple[int, Callable[[dict[str, Any], Path], dict[str, Any]]]] = []
13958
"""Migration steps as `(from_version, step)`, oldest first."""

‎src/frequenz/gridpool/config/_topology.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from marshmallow import Schema
2727
from marshmallow_dataclass import dataclass
2828

29+
from ._fields import toml_datetime_metadata
30+
2931
_EIC_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-"
3032
_MARKET_AREA_EU_DE = 101
3133

@@ -165,10 +167,10 @@ class ValidityConfig:
165167
participation: MarketParticipationType | None = None
166168
"""The market use case, unset for a plain validity window."""
167169

168-
start: datetime | None = None
170+
start: datetime | None = field(default=None, metadata=toml_datetime_metadata())
169171
"""Inclusive start of the period."""
170172

171-
end: datetime | None = None
173+
end: datetime | None = field(default=None, metadata=toml_datetime_metadata())
172174
"""Exclusive end of the period."""
173175

174176
Schema: ClassVar[Type[Schema]] = Schema

0 commit comments

Comments
 (0)