frequenz-gridpool 0.7.0__tar.gz → 0.8.0__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 (35) hide show
  1. {frequenz_gridpool-0.7.0/src/frequenz_gridpool.egg-info → frequenz_gridpool-0.8.0}/PKG-INFO +34 -8
  2. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/README.md +26 -1
  3. frequenz_gridpool-0.8.0/RELEASE_NOTES.md +57 -0
  4. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/pyproject.toml +11 -7
  5. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz/gridpool/cli/__main__.py +126 -30
  6. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz/gridpool/cli/_dump_config.py +12 -9
  7. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz/gridpool/cli/_patch_config.py +65 -23
  8. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz/gridpool/config/__init__.py +2 -0
  9. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz/gridpool/config/_assets.py +89 -6
  10. frequenz_gridpool-0.8.0/src/frequenz/gridpool/config/_fields.py +29 -0
  11. frequenz_gridpool-0.8.0/src/frequenz/gridpool/config/_gridpool.py +17 -0
  12. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz/gridpool/config/_load.py +3 -0
  13. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz/gridpool/config/_microgrid.py +10 -8
  14. frequenz_gridpool-0.8.0/src/frequenz/gridpool/config/_migrations.py +58 -0
  15. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz/gridpool/config/_topology.py +4 -2
  16. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0/src/frequenz_gridpool.egg-info}/PKG-INFO +34 -8
  17. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz_gridpool.egg-info/SOURCES.txt +2 -0
  18. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz_gridpool.egg-info/requires.txt +7 -6
  19. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz_gridpool.egg-info/scm_file_list.json +2 -0
  20. frequenz_gridpool-0.8.0/src/frequenz_gridpool.egg-info/scm_version.json +8 -0
  21. frequenz_gridpool-0.7.0/RELEASE_NOTES.md +0 -119
  22. frequenz_gridpool-0.7.0/src/frequenz/gridpool/config/_migrations.py +0 -139
  23. frequenz_gridpool-0.7.0/src/frequenz_gridpool.egg-info/scm_version.json +0 -8
  24. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/LICENSE +0 -0
  25. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/MANIFEST.in +0 -0
  26. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/setup.cfg +0 -0
  27. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz/gridpool/__init__.py +0 -0
  28. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz/gridpool/_graph_generator.py +0 -0
  29. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz/gridpool/cli/__init__.py +0 -0
  30. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz/gridpool/cli/_render_graph.py +0 -0
  31. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz/gridpool/conftest.py +0 -0
  32. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz/gridpool/py.typed +0 -0
  33. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz_gridpool.egg-info/dependency_links.txt +0 -0
  34. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/src/frequenz_gridpool.egg-info/entry_points.txt +0 -0
  35. {frequenz_gridpool-0.7.0 → frequenz_gridpool-0.8.0}/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.8.0
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
@@ -19,6 +19,7 @@ Classifier: Typing :: Typed
19
19
  Requires-Python: <4,>=3.11
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
+ Requires-Dist: marshmallow<5,>=3.18.0
22
23
  Requires-Dist: marshmallow-dataclass<9,>=8.7.1
23
24
  Requires-Dist: typing-extensions<5,>=4.14.1
24
25
  Requires-Dist: frequenz-microgrid-component-graph<0.6,>=0.5.0
@@ -32,7 +33,7 @@ Requires-Dist: matplotlib<4,>=3.7.0; extra == "render-graph"
32
33
  Requires-Dist: networkx<4,>=3.0; extra == "render-graph"
33
34
  Provides-Extra: dev-flake8
34
35
  Requires-Dist: flake8==7.3.0; extra == "dev-flake8"
35
- Requires-Dist: flake8-datetimez==20.10.0; extra == "dev-flake8"
36
+ Requires-Dist: flake8-datetimez==26.8.1; extra == "dev-flake8"
36
37
  Requires-Dist: flake8-docstrings==1.7.0; extra == "dev-flake8"
37
38
  Requires-Dist: flake8-pyproject==1.2.4; extra == "dev-flake8"
38
39
  Requires-Dist: pydoclint==0.9.1; extra == "dev-flake8"
@@ -41,7 +42,7 @@ Provides-Extra: dev-formatting
41
42
  Requires-Dist: black==26.5.1; extra == "dev-formatting"
42
43
  Requires-Dist: isort==8.0.1; extra == "dev-formatting"
43
44
  Provides-Extra: dev-mkdocs
44
- Requires-Dist: Markdown==3.10.2; extra == "dev-mkdocs"
45
+ Requires-Dist: Markdown==3.10.3; extra == "dev-mkdocs"
45
46
  Requires-Dist: black==26.5.1; extra == "dev-mkdocs"
46
47
  Requires-Dist: mike==2.2.0; extra == "dev-mkdocs"
47
48
  Requires-Dist: mkdocs-gen-files==0.6.1; extra == "dev-mkdocs"
@@ -49,21 +50,21 @@ Requires-Dist: mkdocs-literate-nav==0.6.3; extra == "dev-mkdocs"
49
50
  Requires-Dist: mkdocs-macros-plugin==1.5.0; extra == "dev-mkdocs"
50
51
  Requires-Dist: mkdocs-material==9.7.7; extra == "dev-mkdocs"
51
52
  Requires-Dist: mkdocstrings[python]==1.0.6; extra == "dev-mkdocs"
52
- Requires-Dist: mkdocstrings-python==2.0.5; extra == "dev-mkdocs"
53
+ Requires-Dist: mkdocstrings-python==2.0.7; extra == "dev-mkdocs"
53
54
  Requires-Dist: frequenz-repo-config[lib]==0.18.0; extra == "dev-mkdocs"
54
55
  Provides-Extra: dev-mypy
55
- Requires-Dist: mypy==2.3.0; extra == "dev-mypy"
56
+ Requires-Dist: mypy==2.3.1; extra == "dev-mypy"
56
57
  Requires-Dist: types-networkx>=3.6.1.20251220; extra == "dev-mypy"
57
58
  Requires-Dist: types-Markdown==3.10.2.20260712; extra == "dev-mypy"
58
59
  Requires-Dist: frequenz-gridpool[dev-mkdocs,dev-noxfile,dev-pytest]; extra == "dev-mypy"
59
60
  Provides-Extra: dev-noxfile
60
- Requires-Dist: nox==2026.7.11; extra == "dev-noxfile"
61
+ Requires-Dist: nox==2026.8.17; extra == "dev-noxfile"
61
62
  Requires-Dist: frequenz-repo-config[lib]==0.18.0; extra == "dev-noxfile"
62
63
  Provides-Extra: dev-pylint
63
64
  Requires-Dist: frequenz-gridpool[dev-mkdocs,dev-noxfile,dev-pytest]; extra == "dev-pylint"
64
65
  Provides-Extra: dev-pytest
65
66
  Requires-Dist: pytest==9.1.1; extra == "dev-pytest"
66
- Requires-Dist: pylint==4.0.6; extra == "dev-pytest"
67
+ Requires-Dist: pylint==4.0.7; extra == "dev-pytest"
67
68
  Requires-Dist: frequenz-repo-config[extra-lint-examples]==0.18.0; extra == "dev-pytest"
68
69
  Requires-Dist: pytest-mock==3.15.1; extra == "dev-pytest"
69
70
  Requires-Dist: pytest-asyncio==1.4.0; extra == "dev-pytest"
@@ -150,7 +151,7 @@ The following platforms are officially supported (tested):
150
151
 
151
152
  ## CLI
152
153
 
153
- This package ships the `gridpool-cli` command with three subcommands.
154
+ This package ships the `gridpool-cli` command with five subcommands.
154
155
 
155
156
  ### Setup
156
157
 
@@ -162,6 +163,9 @@ export ASSETS_API_AUTH_KEY="..."
162
163
  export ASSETS_API_SIGN_SECRET="..."
163
164
  ```
164
165
 
166
+ `FREQUENZ_API_KEY` and `FREQUENZ_API_SECRET` are accepted as fallbacks for
167
+ `ASSETS_API_AUTH_KEY` and `ASSETS_API_SIGN_SECRET`.
168
+
165
169
  ### Print component formulas
166
170
 
167
171
  ```bash
@@ -246,6 +250,28 @@ gridpool-cli generate-config <microgrid_id> \
246
250
  --prefer-meters-in-component-formulas > microgrid.toml
247
251
  ```
248
252
 
253
+ ### Validate config files
254
+
255
+ Check config files offline, without contacting the Assets API, and exit
256
+ non-zero on the first error, to gate config-repo CI:
257
+
258
+ ```bash
259
+ gridpool-cli validate microgrid.toml [more.toml ...]
260
+ ```
261
+
262
+ Each file is validated on its own first, so every record names its own key and
263
+ required fields; the files are then validated merged, for the cross-record
264
+ checks.
265
+
266
+ ### Look up a gridpool's enterprise
267
+
268
+ Print the enterprise ID that owns a gridpool, read from the config files
269
+ (merged as one stack); exits non-zero if the gridpool is not declared:
270
+
271
+ ```bash
272
+ gridpool-cli find-enterprise <gridpool_id> config.toml [more.toml ...]
273
+ ```
274
+
249
275
  ## Contributing
250
276
 
251
277
  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,57 @@
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
+ - 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
+
19
+ - `generate-config` now writes the `assets.microgrids` layout and stamps
20
+ `assets.version`, instead of the legacy layout. Regenerate configs to get the
21
+ current format; generated files then load back without a migration.
22
+
23
+ - `generate-config --inplace` refuses legacy-layout files (top-level or
24
+ `meta`-nested), which it would duplicate rather than edit. Rebuild those from
25
+ scratch.
26
+
27
+ ## New Features
28
+
29
+ - `gridpool-cli validate <files>` checks config files offline and exits
30
+ non-zero on the first error, to gate config-repo CI. Each file must be valid
31
+ on its own, so a record names its own key and required fields; the files are
32
+ then checked merged, for the cross-record checks.
33
+
34
+ - `gridpool-cli` accepts `FREQUENZ_API_KEY` and `FREQUENZ_API_SECRET` as a
35
+ fallback pair for `ASSETS_API_AUTH_KEY` and `ASSETS_API_SIGN_SECRET`.
36
+
37
+ - Gridpools are described under `assets.gridpools`, each entry naming the
38
+ enterprise that owns the gridpool. `AssetsConfig.find_enterprise(gridpool_id)`
39
+ returns the configured owner.
40
+ `gridpool-cli find-enterprise <gridpool_id> <files>` prints it from the config.
41
+ `AssetsConfig.check` enforces the one-enterprise-per-gridpool invariant: a
42
+ gridpool's microgrids may not disagree on it, and a declared enterprise must
43
+ match the inferred one.
44
+
45
+ - A config derived from the Assets API now carries each microgrid's
46
+ `enterprise_id`.
47
+
48
+ - `generate-config --inplace` refreshes managed values while preserving
49
+ formatting; `--fill-missing` only adds absent values. Generated patches are
50
+ 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.
@@ -3,7 +3,7 @@
3
3
 
4
4
  [build-system]
5
5
  requires = [
6
- "setuptools == 83.0.0",
6
+ "setuptools == 84.0.0",
7
7
  "setuptools_scm[toml] == 10.2.1",
8
8
  "frequenz-repo-config[lib] == 0.18.0",
9
9
  ]
@@ -26,6 +26,10 @@ classifiers = [
26
26
  ]
27
27
  requires-python = ">= 3.11, < 4"
28
28
  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",
29
33
  "marshmallow-dataclass >= 8.7.1, < 9",
30
34
  "typing-extensions >= 4.14.1, < 5",
31
35
  "frequenz-microgrid-component-graph >= 0.5.0, < 0.6",
@@ -53,7 +57,7 @@ render-graph = [
53
57
 
54
58
  dev-flake8 = [
55
59
  "flake8 == 7.3.0",
56
- "flake8-datetimez == 20.10.0",
60
+ "flake8-datetimez == 26.8.1",
57
61
  "flake8-docstrings == 1.7.0",
58
62
  "flake8-pyproject == 1.2.4", # For reading the flake8 config from pyproject.toml
59
63
  "pydoclint == 0.9.1",
@@ -61,7 +65,7 @@ dev-flake8 = [
61
65
  ]
62
66
  dev-formatting = ["black == 26.5.1", "isort == 8.0.1"]
63
67
  dev-mkdocs = [
64
- "Markdown == 3.10.2",
68
+ "Markdown == 3.10.3",
65
69
  "black == 26.5.1",
66
70
  "mike == 2.2.0",
67
71
  "mkdocs-gen-files == 0.6.1",
@@ -69,18 +73,18 @@ dev-mkdocs = [
69
73
  "mkdocs-macros-plugin == 1.5.0",
70
74
  "mkdocs-material == 9.7.7",
71
75
  "mkdocstrings[python] == 1.0.6",
72
- "mkdocstrings-python == 2.0.5",
76
+ "mkdocstrings-python == 2.0.7",
73
77
  "frequenz-repo-config[lib] == 0.18.0",
74
78
  ]
75
79
  dev-mypy = [
76
- "mypy == 2.3.0",
80
+ "mypy == 2.3.1",
77
81
  "types-networkx>=3.6.1.20251220",
78
82
  "types-Markdown == 3.10.2.20260712",
79
83
  # For checking the noxfile, docs/ script, and tests
80
84
  "frequenz-gridpool[dev-mkdocs,dev-noxfile,dev-pytest]",
81
85
  ]
82
86
  dev-noxfile = [
83
- "nox == 2026.7.11",
87
+ "nox == 2026.8.17",
84
88
  "frequenz-repo-config[lib] == 0.18.0",
85
89
  ]
86
90
  dev-pylint = [
@@ -90,7 +94,7 @@ dev-pylint = [
90
94
  ]
91
95
  dev-pytest = [
92
96
  "pytest == 9.1.1",
93
- "pylint == 4.0.6", # We need this to check for the examples
97
+ "pylint == 4.0.7", # We need this to check for the examples
94
98
  "frequenz-repo-config[extra-lint-examples] == 0.18.0",
95
99
  "pytest-mock == 3.15.1",
96
100
  "pytest-asyncio == 1.4.0",
@@ -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
@@ -15,12 +17,16 @@ escaping, key-quoting, list/number/datetime formatting) is delegated to
15
17
  fields are done here, since TOML has no null and `tomlkit` rejects it.
16
18
  """
17
19
 
20
+ from dataclasses import asdict
18
21
  from typing import Any
19
22
 
20
23
  import tomlkit
21
24
  from tomlkit.items import Integer, Trivia
22
25
 
23
26
  from frequenz.gridpool.config import MicrogridConfig
27
+ from frequenz.gridpool.config._migrations import _CURRENT_VERSION
28
+
29
+ _MICROGRID_PREFIX = ["assets", "microgrids"]
24
30
 
25
31
 
26
32
  def _is_empty(value: Any) -> bool:
@@ -81,16 +87,13 @@ def dump_map(configs: dict[int, MicrogridConfig]) -> str:
81
87
  The TOML representation as a string, with one blank line between
82
88
  microgrids and entries sorted by numeric microgrid ID.
83
89
  """
84
- 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
- dumped = schema.dump(configs[mid])
88
- assert isinstance(dumped, dict)
89
- leaves = _iter_leaves([str(mid)], dumped)
93
+ leaves = _iter_leaves([*_MICROGRID_PREFIX, str(mid)], asdict(configs[mid]))
90
94
  if not leaves:
91
95
  continue
92
- if doc.body:
93
- doc.add(tomlkit.nl())
96
+ doc.add(tomlkit.nl())
94
97
  for path, value in leaves:
95
98
  doc.append(tomlkit.key(path), _format_value(value))
96
99
  return tomlkit.dumps(doc)