frequenz-gridpool 0.7.0__tar.gz → 0.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {frequenz_gridpool-0.7.0/src/frequenz_gridpool.egg-info → frequenz_gridpool-0.7.1}/PKG-INFO +27 -2
  2. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/README.md +26 -1
  3. frequenz_gridpool-0.7.1/RELEASE_NOTES.md +42 -0
  4. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz/gridpool/cli/__main__.py +126 -30
  5. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz/gridpool/cli/_dump_config.py +11 -6
  6. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz/gridpool/cli/_patch_config.py +63 -19
  7. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz/gridpool/config/__init__.py +2 -0
  8. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz/gridpool/config/_assets.py +80 -2
  9. frequenz_gridpool-0.7.1/src/frequenz/gridpool/config/_gridpool.py +17 -0
  10. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz/gridpool/config/_load.py +3 -0
  11. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1/src/frequenz_gridpool.egg-info}/PKG-INFO +27 -2
  12. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz_gridpool.egg-info/SOURCES.txt +1 -0
  13. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz_gridpool.egg-info/scm_file_list.json +1 -0
  14. frequenz_gridpool-0.7.1/src/frequenz_gridpool.egg-info/scm_version.json +8 -0
  15. frequenz_gridpool-0.7.0/RELEASE_NOTES.md +0 -119
  16. frequenz_gridpool-0.7.0/src/frequenz_gridpool.egg-info/scm_version.json +0 -8
  17. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/LICENSE +0 -0
  18. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/MANIFEST.in +0 -0
  19. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/pyproject.toml +0 -0
  20. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/setup.cfg +0 -0
  21. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz/gridpool/__init__.py +0 -0
  22. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz/gridpool/_graph_generator.py +0 -0
  23. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz/gridpool/cli/__init__.py +0 -0
  24. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz/gridpool/cli/_render_graph.py +0 -0
  25. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz/gridpool/config/_microgrid.py +0 -0
  26. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz/gridpool/config/_migrations.py +0 -0
  27. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz/gridpool/config/_topology.py +0 -0
  28. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz/gridpool/conftest.py +0 -0
  29. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz/gridpool/py.typed +0 -0
  30. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz_gridpool.egg-info/dependency_links.txt +0 -0
  31. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz_gridpool.egg-info/entry_points.txt +0 -0
  32. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz_gridpool.egg-info/requires.txt +0 -0
  33. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.7.1}/src/frequenz_gridpool.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frequenz-gridpool
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: High-level interface to grid pools for the Frequenz platform.
5
5
  Author-email: Frequenz Energy-as-a-Service GmbH <floss@frequenz.com>
6
6
  License-Expression: MIT
@@ -150,7 +150,7 @@ The following platforms are officially supported (tested):
150
150
 
151
151
  ## CLI
152
152
 
153
- This package ships the `gridpool-cli` command with three subcommands.
153
+ This package ships the `gridpool-cli` command with five subcommands.
154
154
 
155
155
  ### Setup
156
156
 
@@ -162,6 +162,9 @@ export ASSETS_API_AUTH_KEY="..."
162
162
  export ASSETS_API_SIGN_SECRET="..."
163
163
  ```
164
164
 
165
+ `FREQUENZ_API_KEY` and `FREQUENZ_API_SECRET` are accepted as fallbacks for
166
+ `ASSETS_API_AUTH_KEY` and `ASSETS_API_SIGN_SECRET`.
167
+
165
168
  ### Print component formulas
166
169
 
167
170
  ```bash
@@ -246,6 +249,28 @@ gridpool-cli generate-config <microgrid_id> \
246
249
  --prefer-meters-in-component-formulas > microgrid.toml
247
250
  ```
248
251
 
252
+ ### Validate config files
253
+
254
+ Check config files offline, without contacting the Assets API, and exit
255
+ non-zero on the first error, to gate config-repo CI:
256
+
257
+ ```bash
258
+ gridpool-cli validate microgrid.toml [more.toml ...]
259
+ ```
260
+
261
+ Each file is validated on its own first, so every record names its own key and
262
+ required fields; the files are then validated merged, for the cross-record
263
+ checks.
264
+
265
+ ### Look up a gridpool's enterprise
266
+
267
+ Print the enterprise ID that owns a gridpool, read from the config files
268
+ (merged as one stack); exits non-zero if the gridpool is not declared:
269
+
270
+ ```bash
271
+ gridpool-cli find-enterprise <gridpool_id> config.toml [more.toml ...]
272
+ ```
273
+
249
274
  ## Contributing
250
275
 
251
276
  If you want to know how to build this project and contribute to it, please
@@ -75,7 +75,7 @@ The following platforms are officially supported (tested):
75
75
 
76
76
  ## CLI
77
77
 
78
- This package ships the `gridpool-cli` command with three subcommands.
78
+ This package ships the `gridpool-cli` command with five subcommands.
79
79
 
80
80
  ### Setup
81
81
 
@@ -87,6 +87,9 @@ export ASSETS_API_AUTH_KEY="..."
87
87
  export ASSETS_API_SIGN_SECRET="..."
88
88
  ```
89
89
 
90
+ `FREQUENZ_API_KEY` and `FREQUENZ_API_SECRET` are accepted as fallbacks for
91
+ `ASSETS_API_AUTH_KEY` and `ASSETS_API_SIGN_SECRET`.
92
+
90
93
  ### Print component formulas
91
94
 
92
95
  ```bash
@@ -171,6 +174,28 @@ gridpool-cli generate-config <microgrid_id> \
171
174
  --prefer-meters-in-component-formulas > microgrid.toml
172
175
  ```
173
176
 
177
+ ### Validate config files
178
+
179
+ Check config files offline, without contacting the Assets API, and exit
180
+ non-zero on the first error, to gate config-repo CI:
181
+
182
+ ```bash
183
+ gridpool-cli validate microgrid.toml [more.toml ...]
184
+ ```
185
+
186
+ Each file is validated on its own first, so every record names its own key and
187
+ required fields; the files are then validated merged, for the cross-record
188
+ checks.
189
+
190
+ ### Look up a gridpool's enterprise
191
+
192
+ Print the enterprise ID that owns a gridpool, read from the config files
193
+ (merged as one stack); exits non-zero if the gridpool is not declared:
194
+
195
+ ```bash
196
+ gridpool-cli find-enterprise <gridpool_id> config.toml [more.toml ...]
197
+ ```
198
+
174
199
  ## Contributing
175
200
 
176
201
  If you want to know how to build this project and contribute to it, please
@@ -0,0 +1,42 @@
1
+ # Frequenz Gridpool Library Release Notes
2
+
3
+ ## Summary
4
+
5
+ This release rounds out `gridpool-cli` for the `assets` config layout and adds
6
+ gridpool enterprise ownership to the config model. The CLI can now generate,
7
+ validate, patch, and query merged config files.
8
+
9
+ ## Upgrading
10
+
11
+ - `generate-config` now writes the `assets.microgrids` layout and stamps
12
+ `assets.version`, instead of the legacy layout. Regenerate configs to get the
13
+ current format; generated files then load back without a migration.
14
+
15
+ - `generate-config --inplace` refuses legacy-layout files (top-level or
16
+ `meta`-nested), which it would duplicate rather than edit. Rebuild those from
17
+ scratch.
18
+
19
+ ## New Features
20
+
21
+ - `gridpool-cli validate <files>` checks config files offline and exits
22
+ non-zero on the first error, to gate config-repo CI. Each file must be valid
23
+ on its own, so a record names its own key and required fields; the files are
24
+ then checked merged, for the cross-record checks.
25
+
26
+ - `gridpool-cli` accepts `FREQUENZ_API_KEY` and `FREQUENZ_API_SECRET` as a
27
+ fallback pair for `ASSETS_API_AUTH_KEY` and `ASSETS_API_SIGN_SECRET`.
28
+
29
+ - Gridpools are described under `assets.gridpools`, each entry naming the
30
+ enterprise that owns the gridpool. `AssetsConfig.find_enterprise(gridpool_id)`
31
+ returns the configured owner.
32
+ `gridpool-cli find-enterprise <gridpool_id> <files>` prints it from the config.
33
+ `AssetsConfig.check` enforces the one-enterprise-per-gridpool invariant: a
34
+ gridpool's microgrids may not disagree on it, and a declared enterprise must
35
+ match the inferred one.
36
+
37
+ - A config derived from the Assets API now carries each microgrid's
38
+ `enterprise_id`.
39
+
40
+ - `generate-config --inplace` refreshes managed values while preserving
41
+ formatting; `--fill-missing` only adds absent values. Generated patches are
42
+ validated before writing.
@@ -5,11 +5,13 @@
5
5
 
6
6
  import os
7
7
  import tempfile
8
+ import tomllib
8
9
  from pathlib import Path
9
10
 
10
11
  import asyncclick as click
11
12
  from frequenz.client.assets import AssetsApiClient
12
13
  from frequenz.client.common.microgrid import MicrogridId
14
+ from marshmallow import ValidationError
13
15
 
14
16
  from frequenz.gridpool import ComponentGraphConfig, ComponentGraphGenerator
15
17
  from frequenz.gridpool.cli._dump_config import dump_map
@@ -17,12 +19,37 @@ from frequenz.gridpool.cli._patch_config import patch_file
17
19
  from frequenz.gridpool.cli._render_graph import ComponentGraphRenderer, RenderOptions
18
20
  from frequenz.gridpool.config import AssetsConfig, load_configs
19
21
 
22
+ _LOAD_ERRORS = (ValueError, TypeError, tomllib.TOMLDecodeError, ValidationError)
23
+ """Exceptions raised when a config file fails to load or validate."""
24
+
20
25
 
21
26
  @click.group()
22
27
  async def cli() -> None:
23
28
  """CLI tool for gridpool functionality."""
24
29
 
25
30
 
31
+ def _assets_credentials() -> tuple[str, str, str]:
32
+ """Resolve Assets API URL, auth key and sign secret from the environment.
33
+
34
+ `FREQUENZ_API_KEY` and `FREQUENZ_API_SECRET` are accepted as a fallback pair
35
+ for `ASSETS_API_AUTH_KEY` and `ASSETS_API_SIGN_SECRET`. The key and secret
36
+ are taken as a whole from one source, never mixed across the two.
37
+ """
38
+ url = os.environ.get("ASSETS_API_URL")
39
+ key = os.environ.get("ASSETS_API_AUTH_KEY")
40
+ secret = os.environ.get("ASSETS_API_SIGN_SECRET")
41
+ if not key and not secret:
42
+ key = os.environ.get("FREQUENZ_API_KEY")
43
+ secret = os.environ.get("FREQUENZ_API_SECRET")
44
+ if not url or not key or not secret:
45
+ raise click.ClickException(
46
+ "ASSETS_API_URL and auth credentials must be set: "
47
+ "ASSETS_API_AUTH_KEY (or FREQUENZ_API_KEY) and "
48
+ "ASSETS_API_SIGN_SECRET (or FREQUENZ_API_SECRET)."
49
+ )
50
+ return url, key, secret
51
+
52
+
26
53
  def _graph_config(prefer_meters: bool) -> ComponentGraphConfig | None:
27
54
  """Build the graph config for `--prefer-meters-in-component-formulas`.
28
55
 
@@ -55,13 +82,7 @@ async def print_formulas(
55
82
  prefer_meters_in_component_formulas: bool,
56
83
  ) -> None:
57
84
  """Fetch and print component graph formulas for a microgrid."""
58
- url = os.environ.get("ASSETS_API_URL")
59
- key = os.environ.get("ASSETS_API_AUTH_KEY")
60
- secret = os.environ.get("ASSETS_API_SIGN_SECRET")
61
- if not url or not key or not secret:
62
- raise click.ClickException(
63
- "ASSETS_API_URL, ASSETS_API_AUTH_KEY, ASSETS_API_SIGN_SECRET must be set."
64
- )
85
+ url, key, secret = _assets_credentials()
65
86
 
66
87
  async with AssetsApiClient(
67
88
  url,
@@ -90,6 +111,70 @@ async def print_formulas(
90
111
  )
91
112
 
92
113
 
114
+ @cli.command()
115
+ @click.argument(
116
+ "config_files",
117
+ nargs=-1,
118
+ required=True,
119
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
120
+ )
121
+ async def validate(config_files: tuple[Path, ...]) -> None:
122
+ """Validate each config file on its own, then the merged stack.
123
+
124
+ Each file must stand alone: every record names its own key and required
125
+ fields. The merged stack then adds the cross-record checks. Exits non-zero
126
+ on the first error, to gate CI.
127
+ """
128
+ for config_file in config_files:
129
+ try:
130
+ AssetsConfig.load_from_files([config_file])
131
+ except _LOAD_ERRORS as exc:
132
+ raise click.ClickException(f"{config_file}: {exc}") from exc
133
+
134
+ try:
135
+ config = AssetsConfig.load_from_files(list(config_files))
136
+ except _LOAD_ERRORS as exc:
137
+ raise click.ClickException(str(exc)) from exc
138
+
139
+ click.echo(
140
+ f"OK: {len(config_files)} file(s), {len(config.microgrids)} microgrid(s), "
141
+ f"{len(config.relations)} relation(s), "
142
+ f"{len(config.market_locations)} market location(s).",
143
+ err=True,
144
+ )
145
+
146
+
147
+ @cli.command("find-enterprise")
148
+ @click.argument("gridpool_id", type=int)
149
+ @click.argument(
150
+ "config_files",
151
+ nargs=-1,
152
+ required=True,
153
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
154
+ )
155
+ async def find_enterprise(gridpool_id: int, config_files: tuple[Path, ...]) -> None:
156
+ """Print the enterprise ID owning GRIDPOOL_ID, read from the config files.
157
+
158
+ The files are read as one merged stack. The owner is taken from the
159
+ `gridpools` entry without validating the full document. Exits non-zero if
160
+ the gridpool is not configured.
161
+ """
162
+ try:
163
+ # The declared `gridpools` entry is the source of truth for ownership,
164
+ # so read it even from a document whole-document validation would reject.
165
+ config = AssetsConfig.load_from_files(list(config_files), check=False)
166
+ enterprise_id = config.find_enterprise(gridpool_id)
167
+ except _LOAD_ERRORS as exc:
168
+ raise click.ClickException(str(exc)) from exc
169
+
170
+ if enterprise_id is None:
171
+ raise click.ClickException(
172
+ f"Could not determine the enterprise for gridpool {gridpool_id} "
173
+ "from the given config(s)."
174
+ )
175
+ click.echo(enterprise_id)
176
+
177
+
93
178
  @cli.command("render-graph")
94
179
  @click.argument("microgrid_id", type=int)
95
180
  @click.option(
@@ -107,13 +192,7 @@ async def print_formulas(
107
192
  )
108
193
  async def render_graph(microgrid_id: int, output: str, show: bool) -> None:
109
194
  """Render and save a component graph visualization for a microgrid."""
110
- url = os.environ.get("ASSETS_API_URL")
111
- key = os.environ.get("ASSETS_API_AUTH_KEY")
112
- secret = os.environ.get("ASSETS_API_SIGN_SECRET")
113
- if not url or not key or not secret:
114
- raise click.ClickException(
115
- "ASSETS_API_URL, ASSETS_API_AUTH_KEY, ASSETS_API_SIGN_SECRET must be set."
116
- )
195
+ url, key, secret = _assets_credentials()
117
196
 
118
197
  try:
119
198
  async with AssetsApiClient(
@@ -151,9 +230,16 @@ async def render_graph(microgrid_id: int, output: str, show: bool) -> None:
151
230
  "--inplace",
152
231
  is_flag=True,
153
232
  default=False,
154
- help="Patch --default in place instead of printing to stdout. Preserves "
155
- "existing comments, ordering and formatting in that file; only fills in "
156
- "values it is missing. Requires --default.",
233
+ help="Patch --default in place instead of printing to stdout, refreshing "
234
+ "the managed values while keeping its comments, ordering and formatting. "
235
+ "Requires --default.",
236
+ )
237
+ @click.option(
238
+ "--fill-missing",
239
+ is_flag=True,
240
+ default=False,
241
+ help="With --inplace, only add values --default is missing, leaving the "
242
+ "values already in it untouched.",
157
243
  )
158
244
  @click.option(
159
245
  "--prefer-meters-in-component-formulas",
@@ -162,11 +248,13 @@ async def render_graph(microgrid_id: int, output: str, show: bool) -> None:
162
248
  help="Read the meter before the component in the per-category formulas. "
163
249
  "This is the order used before component graph v0.5.0.",
164
250
  )
165
- async def generate_config(
251
+ async def generate_config( # pylint: disable=too-many-locals
166
252
  microgrid_ids: tuple[int, ...],
253
+ *,
167
254
  default_file: Path | None,
168
255
  override_file: Path | None,
169
256
  inplace: bool,
257
+ fill_missing: bool,
170
258
  prefer_meters_in_component_formulas: bool,
171
259
  ) -> None:
172
260
  """Generate microgrid config from the Assets API as TOML.
@@ -184,21 +272,17 @@ async def generate_config(
184
272
  component graph v0.5.0.
185
273
 
186
274
  With `--inplace`, `--default` is patched directly instead: candidate values
187
- come from the Assets API (with `--override` layered on top), and only
188
- leaves `--default` is missing are added, preserving its existing comments,
189
- field order and formatting. If no microgrid IDs are given, every microgrid
190
- already in `--default` is processed.
275
+ come from the Assets API (with `--override` layered on top) and refresh the
276
+ managed values, keeping the file's comments, field order and formatting.
277
+ With `--fill-missing`, only values `--default` lacks are added. If no
278
+ microgrid IDs are given, every microgrid already in `--default` is processed.
191
279
  """
192
280
  if inplace and default_file is None:
193
281
  raise click.ClickException("--inplace requires --default.")
282
+ if fill_missing and not inplace:
283
+ raise click.ClickException("--fill-missing requires --inplace.")
194
284
 
195
- url = os.environ.get("ASSETS_API_URL")
196
- key = os.environ.get("ASSETS_API_AUTH_KEY")
197
- secret = os.environ.get("ASSETS_API_SIGN_SECRET")
198
- if not url or not key or not secret:
199
- raise click.ClickException(
200
- "ASSETS_API_URL, ASSETS_API_AUTH_KEY, ASSETS_API_SIGN_SECRET must be set."
201
- )
285
+ url, key, secret = _assets_credentials()
202
286
 
203
287
  ids = list(dict.fromkeys(microgrid_ids)) or None
204
288
  if inplace and ids is None:
@@ -236,13 +320,25 @@ async def generate_config(
236
320
 
237
321
  if inplace:
238
322
  assert default_file is not None
239
- patched = patch_file(default_file, configs)
323
+ try:
324
+ patched = patch_file(default_file, configs, fill_missing=fill_missing)
325
+ except ValueError as exc:
326
+ raise click.ClickException(str(exc)) from exc
240
327
  fd, tmp_name = tempfile.mkstemp(
241
328
  dir=default_file.parent, prefix=f".{default_file.name}."
242
329
  )
243
330
  try:
244
331
  with os.fdopen(fd, "w") as tmp_file:
245
332
  tmp_file.write(patched)
333
+ # Guard against a patch that corrupts the file (e.g. bad splicing)
334
+ # before it overwrites the user's config.
335
+ try:
336
+ AssetsConfig.load_from_files(Path(tmp_name))
337
+ except _LOAD_ERRORS as exc:
338
+ raise click.ClickException(
339
+ f"Refusing to write {default_file}: the patched result is "
340
+ f"invalid: {exc}"
341
+ ) from exc
246
342
  os.replace(tmp_name, default_file)
247
343
  except BaseException:
248
344
  os.remove(tmp_name)
@@ -5,9 +5,11 @@
5
5
 
6
6
  Renders a `{microgrid_id: MicrogridConfig}` mapping as dotted-key TOML, e.g.:
7
7
 
8
- 115.microgrid_id = 115
9
- 115.name = "Demo Grid"
10
- 115.latitude = 52.52
8
+ assets.version = 1
9
+
10
+ assets.microgrids.115.microgrid_id = 115
11
+ assets.microgrids.115.name = "Demo Grid"
12
+ assets.microgrids.115.latitude = 52.52
11
13
 
12
14
  This is the inverse of `AssetsConfig.load_from_files`. Value rendering (quoting,
13
15
  escaping, key-quoting, list/number/datetime formatting) is delegated to
@@ -21,6 +23,9 @@ import tomlkit
21
23
  from tomlkit.items import Integer, Trivia
22
24
 
23
25
  from frequenz.gridpool.config import MicrogridConfig
26
+ from frequenz.gridpool.config._migrations import _CURRENT_VERSION
27
+
28
+ _MICROGRID_PREFIX = ["assets", "microgrids"]
24
29
 
25
30
 
26
31
  def _is_empty(value: Any) -> bool:
@@ -83,14 +88,14 @@ def dump_map(configs: dict[int, MicrogridConfig]) -> str:
83
88
  """
84
89
  schema = MicrogridConfig.Schema()
85
90
  doc = tomlkit.document()
91
+ doc.append(tomlkit.key(["assets", "version"]), _CURRENT_VERSION)
86
92
  for mid in sorted(configs):
87
93
  dumped = schema.dump(configs[mid])
88
94
  assert isinstance(dumped, dict)
89
- leaves = _iter_leaves([str(mid)], dumped)
95
+ leaves = _iter_leaves([*_MICROGRID_PREFIX, str(mid)], dumped)
90
96
  if not leaves:
91
97
  continue
92
- if doc.body:
93
- doc.add(tomlkit.nl())
98
+ doc.add(tomlkit.nl())
94
99
  for path, value in leaves:
95
100
  doc.append(tomlkit.key(path), _format_value(value))
96
101
  return tomlkit.dumps(doc)
@@ -4,8 +4,9 @@
4
4
  """Patch existing dotted-key TOML files with new microgrid config values.
5
5
 
6
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.
7
+ edits the document in place, so its comments, field order and number formatting
8
+ survive. By default it refreshes the managed leaves, overwriting them; with
9
+ `fill_missing` it only adds the leaves the document lacks.
9
10
  """
10
11
 
11
12
  from collections.abc import Mapping
@@ -17,33 +18,42 @@ from tomlkit import TOMLDocument
17
18
 
18
19
  from frequenz.gridpool.config import MicrogridConfig
19
20
 
20
- from ._dump_config import _format_value, _iter_leaves
21
+ from ._dump_config import _MICROGRID_PREFIX, _format_value, _iter_leaves
21
22
 
22
23
 
23
- def patch_file(path: Path, configs: dict[int, MicrogridConfig]) -> str:
24
- """Patch the TOML file at `path` with any leaves missing from `configs`.
24
+ def patch_file(
25
+ path: Path, configs: dict[int, MicrogridConfig], *, fill_missing: bool = False
26
+ ) -> str:
27
+ """Patch the TOML file at `path` with the values in `configs`.
25
28
 
26
29
  Args:
27
30
  path: Path to the existing TOML file to patch.
28
31
  configs: Mapping from microgrid ID to `MicrogridConfig`.
32
+ fill_missing: Only add leaves the file lacks, leaving existing values
33
+ untouched; by default the managed leaves are overwritten.
29
34
 
30
35
  Returns:
31
36
  The patched TOML text; the caller is responsible for writing it back.
32
37
  """
33
- return patch_text(path.read_text(), configs)
38
+ return patch_text(path.read_text(), configs, fill_missing=fill_missing)
34
39
 
35
40
 
36
- def patch_text(original: str, configs: dict[int, MicrogridConfig]) -> str:
37
- """Patch dotted-key TOML text with any leaves missing from `configs`.
41
+ def patch_text(
42
+ original: str, configs: dict[int, MicrogridConfig], *, fill_missing: bool = False
43
+ ) -> str:
44
+ """Patch dotted-key TOML text with the values in `configs`.
38
45
 
39
46
  Args:
40
47
  original: The existing TOML text to patch.
41
48
  configs: Mapping from microgrid ID to `MicrogridConfig`.
49
+ fill_missing: Only add leaves the text lacks, leaving existing values
50
+ untouched; by default the managed leaves are overwritten.
42
51
 
43
52
  Returns:
44
53
  The patched TOML text.
45
54
  """
46
55
  doc = tomlkit.parse(original)
56
+ _reject_legacy_layout(doc)
47
57
  schema = MicrogridConfig.Schema()
48
58
 
49
59
  # Leaves needing a whole new sub-table can't be inserted via item assignment
@@ -59,12 +69,12 @@ def patch_text(original: str, configs: dict[int, MicrogridConfig]) -> str:
59
69
  if not leaves:
60
70
  continue
61
71
 
62
- if mid not in doc:
72
+ if not _leaf_exists(doc, mid, []):
63
73
  _append_new_entry(doc, mid, leaves)
64
74
  continue
65
75
 
66
76
  for path, value in leaves:
67
- if _leaf_exists(doc, mid, path):
77
+ if fill_missing and _leaf_exists(doc, mid, path):
68
78
  continue
69
79
  if not _insert_leaf(doc, mid, path, value):
70
80
  orphans.setdefault(mid, []).append((path, value))
@@ -75,10 +85,44 @@ def patch_text(original: str, configs: dict[int, MicrogridConfig]) -> str:
75
85
  return text
76
86
 
77
87
 
88
+ def _reject_legacy_layout(doc: TOMLDocument) -> None:
89
+ """Refuse a document that is not in the current `assets.microgrids` layout.
90
+
91
+ Patching navigates by `assets.microgrids.<id>`, so a top-level or
92
+ `meta`-nested entry would be duplicated rather than edited. Such a file
93
+ must be rebuilt with `generate-config` (without `--inplace`) first.
94
+
95
+ Args:
96
+ doc: The parsed document to check.
97
+
98
+ Raises:
99
+ ValueError: If the document uses a deprecated layout.
100
+ """
101
+ hint = "rebuild it with generate-config (without --inplace) first"
102
+ if stray := sorted(k for k in doc if k != "assets"):
103
+ raise ValueError(
104
+ f"Cannot patch in place: deprecated top-level keys {stray}; {hint}."
105
+ )
106
+ assets = doc.get("assets", {})
107
+ microgrids = assets.get("microgrids", {}) if isinstance(assets, Mapping) else {}
108
+ if not isinstance(microgrids, Mapping):
109
+ microgrids = {}
110
+ metas = sorted(
111
+ mid
112
+ for mid, entry in microgrids.items()
113
+ if isinstance(entry, Mapping) and "meta" in entry
114
+ )
115
+ if metas:
116
+ raise ValueError(
117
+ f"Cannot patch in place: microgrids {metas} nest fields under the "
118
+ f"deprecated `meta` table; {hint}."
119
+ )
120
+
121
+
78
122
  def _leaf_exists(doc: TOMLDocument, mid: str, path: list[str]) -> bool:
79
- """Whether the dotted key `mid.path...` already has a value in `doc`."""
123
+ """Whether the dotted key `assets.microgrids.mid.path...` has a value in `doc`."""
80
124
  node: Any = doc
81
- for key in (mid, *path):
125
+ for key in (*_MICROGRID_PREFIX, mid, *path):
82
126
  if not isinstance(node, Mapping) or key not in node:
83
127
  return False
84
128
  node = node[key]
@@ -100,14 +144,14 @@ def _insert_leaf(doc: TOMLDocument, mid: str, path: list[str], value: Any) -> bo
100
144
  """
101
145
  node: Any = doc
102
146
  matched = 0
103
- for key in (mid, *path[:-1]):
147
+ full_path = [*_MICROGRID_PREFIX, mid, *path]
148
+ for key in full_path[:-1]:
104
149
  nxt = node[key] if isinstance(node, Mapping) and key in node else None
105
150
  if not isinstance(nxt, Mapping):
106
151
  break
107
152
  node = nxt
108
153
  matched += 1
109
154
 
110
- full_path = [mid, *path]
111
155
  if matched != len(full_path) - 1:
112
156
  return False
113
157
  node[full_path[-1]] = _format_value(value)
@@ -128,14 +172,14 @@ def _append_new_entry(
128
172
  if doc.body:
129
173
  doc.add(tomlkit.nl())
130
174
  for path, value in leaves:
131
- doc.append(tomlkit.key([mid, *path]), _format_value(value))
175
+ doc.append(tomlkit.key([*_MICROGRID_PREFIX, mid, *path]), _format_value(value))
132
176
 
133
177
 
134
178
  def _render_lines(mid: str, leaves: list[tuple[list[str], Any]]) -> list[str]:
135
- """Render `(path, value)` leaves as standalone `mid.path = value` lines."""
179
+ """Render leaves as standalone `assets.microgrids.mid.path = value` lines."""
136
180
  tmp = tomlkit.document()
137
181
  for path, value in leaves:
138
- tmp.append(tomlkit.key([mid, *path]), _format_value(value))
182
+ tmp.append(tomlkit.key([*_MICROGRID_PREFIX, mid, *path]), _format_value(value))
139
183
  return tomlkit.dumps(tmp).splitlines(keepends=True)
140
184
 
141
185
 
@@ -159,8 +203,8 @@ def _splice_orphans(text: str, orphans: dict[str, list[tuple[list[str], Any]]])
159
203
 
160
204
 
161
205
  def _last_line_index_for_mid(lines: list[str], mid: str) -> int:
162
- """Index of the last line belonging to `mid` (its key starts with `mid.`)."""
163
- prefix = f"{mid}."
206
+ """Index of the last line belonging to `mid` (its key starts with the prefix)."""
207
+ prefix = f"{'.'.join(_MICROGRID_PREFIX)}.{mid}."
164
208
  for idx in range(len(lines) - 1, -1, -1):
165
209
  if lines[idx].lstrip().startswith(prefix):
166
210
  return idx
@@ -6,6 +6,7 @@
6
6
  from frequenz.microgrid_component_graph import ComponentGraphConfig, FormulaOverrides
7
7
 
8
8
  from ._assets import AssetsConfig
9
+ from ._gridpool import GridpoolConfig
9
10
  from ._load import load_configs
10
11
  from ._microgrid import (
11
12
  BatteryConfig,
@@ -32,6 +33,7 @@ __all__ = [
32
33
  "ComponentTypeConfig",
33
34
  "DeliveryAreaConfig",
34
35
  "FormulaOverrides",
36
+ "GridpoolConfig",
35
37
  "MarketLocationConfig",
36
38
  "MicrogridConfig",
37
39
  "PVConfig",
@@ -15,6 +15,7 @@ from frequenz.client.assets import MarketParticipationType
15
15
  from marshmallow import Schema
16
16
  from marshmallow_dataclass import dataclass
17
17
 
18
+ from ._gridpool import GridpoolConfig
18
19
  from ._microgrid import MicrogridConfig
19
20
  from ._migrations import _CURRENT_VERSION, migrate
20
21
  from ._topology import DeliveryAreaConfig, MarketLocationConfig, RelationConfig
@@ -89,6 +90,9 @@ class AssetsConfig:
89
90
  microgrids: dict[int, MicrogridConfig] = field(default_factory=dict)
90
91
  """Microgrids, keyed by microgrid ID."""
91
92
 
93
+ gridpools: dict[int, GridpoolConfig] = field(default_factory=dict)
94
+ """Gridpools, keyed by gridpool ID."""
95
+
92
96
  market_locations: dict[str, MarketLocationConfig] = field(default_factory=dict)
93
97
  """Market locations, keyed by their identifier."""
94
98
 
@@ -123,6 +127,11 @@ class AssetsConfig:
123
127
  raise ValueError(
124
128
  f"Microgrid ID mismatch: key {mid} != {cfg.microgrid_id}"
125
129
  )
130
+ for gpid, gridpool in self.gridpools.items():
131
+ if int(gridpool.gridpool_id) != gpid:
132
+ raise ValueError(
133
+ f"Gridpool ID mismatch: key {gpid} != {gridpool.gridpool_id}"
134
+ )
126
135
 
127
136
  def check(self) -> None:
128
137
  """Check the document as a whole, once every layer has been merged.
@@ -131,8 +140,9 @@ class AssetsConfig:
131
140
  ValueError: If an entry's identifier disagrees with the key it is
132
141
  filed under, a relation names fewer than two sides, its key
133
142
  disagrees with its fields, a gridpool relation names no delivery
134
- area, one market location is placed in two of them, or a legacy
135
- microgrid gridpool ID disagrees with its relations.
143
+ area, one market location is placed in two of them, a legacy
144
+ microgrid gridpool ID disagrees with its relations, or a
145
+ gridpool's declared and inferred enterprise disagree.
136
146
  """
137
147
  for key, location in self.market_locations.items():
138
148
  if location.id is None:
@@ -143,6 +153,7 @@ class AssetsConfig:
143
153
  )
144
154
  self._check_relations()
145
155
  self._check_legacy_gridpool_ids()
156
+ self._check_gridpool_enterprises()
146
157
 
147
158
  def _check_relations(self) -> None:
148
159
  """Check the relations are complete, well-keyed and area-consistent.
@@ -213,6 +224,61 @@ class AssetsConfig:
213
224
  f"{sorted(relation_gids)}; remove gid when several apply"
214
225
  )
215
226
 
227
+ def _derive_enterprise(self, gridpool_id: int) -> int | None:
228
+ """Infer a gridpool's enterprise from the microgrids its relations name.
229
+
230
+ Args:
231
+ gridpool_id: The gridpool whose enterprise to infer.
232
+
233
+ Returns:
234
+ The inferred enterprise ID, or `None` if none can be inferred.
235
+
236
+ Raises:
237
+ ValueError: If the related microgrids disagree on the enterprise.
238
+ """
239
+ enterprises: set[int] = set()
240
+ for mid in self.find_microgrids(gridpool_id=gridpool_id):
241
+ microgrid = self.microgrids.get(mid)
242
+ if microgrid is not None and microgrid.enterprise_id is not None:
243
+ enterprises.add(microgrid.enterprise_id)
244
+ if not enterprises:
245
+ return None
246
+ if len(enterprises) > 1:
247
+ raise ValueError(
248
+ f"Gridpool {gridpool_id}: its microgrids disagree on the owning "
249
+ f"enterprise: {sorted(enterprises)}"
250
+ )
251
+ return enterprises.pop()
252
+
253
+ def _check_gridpool_enterprises(self) -> None:
254
+ """Check declared and inferable gridpool enterprises agree.
255
+
256
+ A gridpool owns one enterprise, so its microgrids must not disagree on
257
+ it, and a declared `gridpools` entry must match what they imply.
258
+
259
+ Raises:
260
+ ValueError: If a gridpool's microgrids disagree on the enterprise,
261
+ or a declared enterprise differs from the inferred one.
262
+ """
263
+ gridpool_ids = set(self.gridpools) | {
264
+ relation.gridpool_id
265
+ for relation in self.relations.values()
266
+ if relation.gridpool_id is not None
267
+ }
268
+ for gpid in gridpool_ids:
269
+ inferred = self._derive_enterprise(gpid)
270
+ declared = self.gridpools.get(gpid)
271
+ if (
272
+ declared is not None
273
+ and inferred is not None
274
+ and declared.enterprise_id != inferred
275
+ ):
276
+ raise ValueError(
277
+ f"Gridpool {gpid}: declared enterprise "
278
+ f"{declared.enterprise_id} disagrees with its microgrids' "
279
+ f"enterprise {inferred}"
280
+ )
281
+
216
282
  def find_relations(
217
283
  self,
218
284
  *,
@@ -350,6 +416,18 @@ class AssetsConfig:
350
416
  )
351
417
  )
352
418
 
419
+ def find_enterprise(self, gridpool_id: int) -> int | None:
420
+ """Find the configured enterprise owning `gridpool_id`.
421
+
422
+ Args:
423
+ gridpool_id: The gridpool to look up.
424
+
425
+ Returns:
426
+ The owning enterprise ID, or `None` when the gridpool is not configured.
427
+ """
428
+ gridpool = self.gridpools.get(gridpool_id)
429
+ return gridpool.enterprise_id if gridpool is not None else None
430
+
353
431
  @classmethod
354
432
  def _warn_unknown_entities(cls, assets: dict[str, Any], source: Path) -> None:
355
433
  """Warn about entity tables that this version drops on load."""
@@ -0,0 +1,17 @@
1
+ # License: MIT
2
+ # Copyright © 2026 Frequenz Energy-as-a-Service GmbH
3
+
4
+ """Data model for a gridpool."""
5
+
6
+ from marshmallow_dataclass import dataclass
7
+
8
+
9
+ @dataclass
10
+ class GridpoolConfig:
11
+ """Configuration of a gridpool."""
12
+
13
+ gridpool_id: int
14
+ """ID of the gridpool."""
15
+
16
+ enterprise_id: int
17
+ """Enterprise that owns the gridpool."""
@@ -219,6 +219,9 @@ async def _build_config_from_metadata(
219
219
  location = mgrid.location if mgrid.location else None
220
220
  return MicrogridConfig(
221
221
  microgrid_id=microgrid_id,
222
+ # The Assets API always sets an EnterpriseId; an unassigned enterprise
223
+ # comes back as the proto default 0, which we treat as absent.
224
+ enterprise_id=int(mgrid.enterprise_id) or None,
222
225
  latitude=location.latitude if location else None,
223
226
  longitude=location.longitude if location else None,
224
227
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frequenz-gridpool
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: High-level interface to grid pools for the Frequenz platform.
5
5
  Author-email: Frequenz Energy-as-a-Service GmbH <floss@frequenz.com>
6
6
  License-Expression: MIT
@@ -150,7 +150,7 @@ The following platforms are officially supported (tested):
150
150
 
151
151
  ## CLI
152
152
 
153
- This package ships the `gridpool-cli` command with three subcommands.
153
+ This package ships the `gridpool-cli` command with five subcommands.
154
154
 
155
155
  ### Setup
156
156
 
@@ -162,6 +162,9 @@ export ASSETS_API_AUTH_KEY="..."
162
162
  export ASSETS_API_SIGN_SECRET="..."
163
163
  ```
164
164
 
165
+ `FREQUENZ_API_KEY` and `FREQUENZ_API_SECRET` are accepted as fallbacks for
166
+ `ASSETS_API_AUTH_KEY` and `ASSETS_API_SIGN_SECRET`.
167
+
165
168
  ### Print component formulas
166
169
 
167
170
  ```bash
@@ -246,6 +249,28 @@ gridpool-cli generate-config <microgrid_id> \
246
249
  --prefer-meters-in-component-formulas > microgrid.toml
247
250
  ```
248
251
 
252
+ ### Validate config files
253
+
254
+ Check config files offline, without contacting the Assets API, and exit
255
+ non-zero on the first error, to gate config-repo CI:
256
+
257
+ ```bash
258
+ gridpool-cli validate microgrid.toml [more.toml ...]
259
+ ```
260
+
261
+ Each file is validated on its own first, so every record names its own key and
262
+ required fields; the files are then validated merged, for the cross-record
263
+ checks.
264
+
265
+ ### Look up a gridpool's enterprise
266
+
267
+ Print the enterprise ID that owns a gridpool, read from the config files
268
+ (merged as one stack); exits non-zero if the gridpool is not declared:
269
+
270
+ ```bash
271
+ gridpool-cli find-enterprise <gridpool_id> config.toml [more.toml ...]
272
+ ```
273
+
249
274
  ## Contributing
250
275
 
251
276
  If you want to know how to build this project and contribute to it, please
@@ -14,6 +14,7 @@ src/frequenz/gridpool/cli/_patch_config.py
14
14
  src/frequenz/gridpool/cli/_render_graph.py
15
15
  src/frequenz/gridpool/config/__init__.py
16
16
  src/frequenz/gridpool/config/_assets.py
17
+ src/frequenz/gridpool/config/_gridpool.py
17
18
  src/frequenz/gridpool/config/_load.py
18
19
  src/frequenz/gridpool/config/_microgrid.py
19
20
  src/frequenz/gridpool/config/_migrations.py
@@ -45,6 +45,7 @@
45
45
  "src/frequenz/gridpool/cli/_render_graph.py",
46
46
  "src/frequenz/gridpool/config/__init__.py",
47
47
  "src/frequenz/gridpool/config/_assets.py",
48
+ "src/frequenz/gridpool/config/_gridpool.py",
48
49
  "src/frequenz/gridpool/config/_load.py",
49
50
  "src/frequenz/gridpool/config/_microgrid.py",
50
51
  "src/frequenz/gridpool/config/_migrations.py",
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.7.1",
3
+ "distance": 0,
4
+ "node": "gb1e6ec5ad7f5fe27fd1db03ffd398de766bc012c",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-08-31"
8
+ }
@@ -1,119 +0,0 @@
1
- # Frequenz Gridpool Library Release Notes
2
-
3
- ## Summary
4
-
5
- <!-- Here goes a general summary of what this release is about -->
6
-
7
- ## Upgrading
8
-
9
- - `MicrogridConfig.load_from_file` is replaced by `AssetsConfig.load_from_files`,
10
- which loads one or more files, merged into one document, and returns the whole
11
- document rather than just its microgrids:
12
-
13
- ```python
14
- configs = AssetsConfig.load_from_files(path).microgrids
15
- ```
16
-
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.
23
-
24
- - `Metadata` is removed; its fields (`microgrid_id`, `name`, `gid`, coordinates,
25
- times) now sit directly on `MicrogridConfig`:
26
-
27
- ```python
28
- MicrogridConfig(microgrid_id=1, name="Grid") # was meta=Metadata(...)
29
- ```
30
-
31
- In TOML they move up one level, `assets.microgrids.1.name` rather than
32
- `assets.microgrids.1.meta.name`. A file still nesting a microgrid's fields
33
- under `meta` loads, lifted with a deprecation warning.
34
-
35
- - A microgrid's `delivery_area` is removed. Move its value to a relation's
36
- `delivery_area.code`, and set `delivery_area.code_type` when the code is not
37
- EIC. When relations are present, the legacy `gid` must be their sole
38
- gridpool ID; remove it for a microgrid that participates in several gridpools.
39
-
40
- - `load_configs_from_files` is removed. Use `AssetsConfig.load_from_files`,
41
- which returns the whole document rather than just its microgrids: replace
42
- `load_configs_from_files(files)` with
43
- `AssetsConfig.load_from_files(files).microgrids`, or use the returned
44
- `AssetsConfig` directly to keep relations and market locations.
45
-
46
- - `load_configs_from_api` is now private. For an API-only load call
47
- `load_configs(assets_client=..., microgrid_ids=...)` and read its `.microgrids`.
48
-
49
- - `merge_config_maps` and `merge_microgrid_configs` are removed. Layering is now
50
- done on the raw tables before loading, inside `load_configs` and
51
- `AssetsConfig.load_from_files`; pass all the layers to one of those instead of
52
- merging loaded objects.
53
-
54
- - Relation validity bounds and `at` query instants must include a UTC offset.
55
-
56
- - The implementation modules `config.load` and `config.microgrid` are now
57
- private. Import their public names from `frequenz.gridpool.config` instead.
58
-
59
- - Microgrids are now keyed by `int` microgrid ID, not `str`. This covers
60
- `AssetsConfig.microgrids`, including documents returned by `load_configs`.
61
- Index the mapping by integer ID:
62
-
63
- ```python
64
- configs[1] # was configs["1"]
65
- ```
66
-
67
- - The `AC_ACTIVE_POWER` deprecation warning is dropped. Use `AC_POWER_ACTIVE`
68
- as the formula metric key; the old name is no longer flagged on load.
69
-
70
-
71
- ## New Features
72
-
73
- - `AssetsConfig` gives the `assets` namespace a type, so the entities still to
74
- come are added as fields rather than as more dict lookups. Microgrid IDs are
75
- checked during construction. `AssetsConfig.check()` performs the topology-wide
76
- checks after all layers have been merged; the file loaders call it unless
77
- `AssetsConfig.load_from_files` is passed `check=False`.
78
-
79
- File loaders ignore unknown entity tables with a warning, so a reader keeps
80
- working against files that already carry newer entities.
81
-
82
- The `assets` table may carry a `version`; on load a document is run through a
83
- migration pipeline that brings older layouts up to the current format, so
84
- legacy files keep working. The version tracks the assets format alone, not
85
- the whole document.
86
-
87
- - Market topology is described under `assets.relations`, based on the Assets
88
- API `MarketTopologyRelation`: each record links at least two of a gridpool, a
89
- microgrid and a market location, filed under a
90
- `G<gridpool_id>M<microgrid_id>L<market_location_id>` key derived from its own
91
- sides. A relation naming a gridpool sits in a `delivery_area` that rides on
92
- the relation, so a gridpool-to-microgrid relation with no market location still
93
- carries one. A relation's validity lives in `validity`, each entry a half-open
94
- `[start, end)` datetime period it applies over. Use-case-specific periods
95
- qualify a relation; separate relations let one microgrid participate in
96
- several gridpools. The config extends the API with plain periods for relations
97
- that do not distinguish use cases. A gridpool-free microgrid-to-market-location
98
- relation may also carry a delivery area for a direct mapping. Market locations
99
- live under `assets.market_locations` as self-describing entries carrying their
100
- own identifier, how to read it (`MALO_ID` by default), and the Assets API market
101
- area (`EU_DE` by default). A relation's `delivery_area` is a `code` plus a
102
- `code_type` that defaults to EIC, so an EIC area is just
103
- `delivery_area.code = "..."`; EIC codes are check-character-validated. Raw
104
- market-location IDs must be unique within a document, including across market
105
- areas.
106
-
107
- `AssetsConfig` answers the common lookups with `find_relations` and the
108
- projections `find_delivery_areas`, `find_market_locations` and
109
- `find_microgrids`, each filtered by the other sides and an instant.
110
-
111
- Time-varying enterprise ownership is outside this change;
112
- `MicrogridConfig.enterprise_id` remains as a scalar field.
113
-
114
- ## Bug Fixes
115
-
116
- - Layering config files no longer resets a field a later file leaves unset back
117
- to its default. The raw tables are merged before they are loaded.
118
-
119
- <!-- Here goes notable bug fixes that are worth a special mention or explanation -->
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.7.0",
3
- "distance": 0,
4
- "node": "g6029140c644abcdc4a9fd0a3b2080bec673339c1",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-08-27"
8
- }