From df7d2ccc7bd42b1e1ffc06c18b3998217c66704d Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Wed, 24 Jun 2026 19:36:46 +0000 Subject: [PATCH 1/4] Build against component-graph v0.6.0 Update the frequenz-microgrid-component-graph dependency to v0.6.0. This release rewrites the formula fallback engine and adds the component-first default for per-category formulas: the component reading is the primary source and the meter is the fallback. Update the steam-boiler and wind-turbine formula tests to match the new default, add a test that pins the consumer formula's new meter-subtraction grouping, and document the Python-visible changes (formula terms, consumer formula grouping, validation error format) in the release notes and in the phantom-loads stub docstring. Signed-off-by: Sahas Subramanian --- Cargo.lock | 60 +++++++++---------- Cargo.toml | 2 +- RELEASE_NOTES.md | 8 +++ .../microgrid_component_graph/__init__.pyi | 5 ++ tests/test_microgrid_component_graph.py | 40 ++++++++++++- 5 files changed, 81 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aa971be..72b2ba9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,9 +16,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "frequenz-microgrid-component-graph" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2ae40700292e149155d32a2267f93c48fdf1749a2176bbd7f4b0b0fa643396c" +checksum = "7271ff70b07a8106977e472d30bd3316ae2e46b22c1aaf9bdf6851d083916370" dependencies = [ "petgraph", "tracing", @@ -34,9 +34,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -46,9 +46,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "indexmap" -version = "2.12.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown", @@ -56,15 +56,15 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.177" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "petgraph" @@ -78,21 +78,21 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -156,18 +156,18 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] [[package]] name = "syn" -version = "2.0.108" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -176,15 +176,15 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.13.3" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -193,9 +193,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -204,15 +204,15 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/Cargo.toml b/Cargo.toml index d96ca99..25f58ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] pyo3 = "0.29.0" -frequenz-microgrid-component-graph = "0.5" +frequenz-microgrid-component-graph = "0.6.0" diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index fe1e234..26096f0 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -8,6 +8,14 @@ - Bumped PyO3 to 0.29 to pull in the fix for RUSTSEC out-of-bounds read advisory GHSA-36hh-v3qg-5jq4 (`nth`/`nth_back` on list/tuple iterators). +- Updated the `frequenz-microgrid-component-graph` Rust crate to v0.6.0. This changes some outputs that are visible from Python: + + - The formula fallback engine was rewritten. Formulas can now contain meter-subtraction and diamond terms. Exact formula strings can differ from the previous release, even for the same graph. + + - The consumer formula now measures the non-consumer components behind one internal meter as one group: it subtracts `COALESCE(#meter, component readings...)` instead of each component on its own. Note: if such a meter also carries a load that is not in the component graph, that load is now subtracted together with the group while the meter reading is used. + + - Graph validation failures now raise `InvalidGraphError` with a message that starts with `Graph validation failed:`, followed by one indented line per problem. The old format was `InvalidGraph: `. Note: some errors found while building the graph, for example a missing grid component, still use the old format. Code that matches on the message text of validation failures must be updated. + ## New Features diff --git a/python/frequenz/microgrid_component_graph/__init__.pyi b/python/frequenz/microgrid_component_graph/__init__.pyi index e3d17d0..a2717c6 100644 --- a/python/frequenz/microgrid_component_graph/__init__.pyi +++ b/python/frequenz/microgrid_component_graph/__init__.pyi @@ -49,6 +49,11 @@ class ComponentGraphConfig: the measurements of successor meters from the measurements of their predecessor meters. When `false`, consumer formula is generated by excluding production and battery components from the grid measurements. + The non-consumer components behind one internal meter are excluded + as one group: the meter reading first, the component readings as + the fallback. While the meter reading is used, a load behind + that meter that is not in the component graph is then also + excluded together with the group. prefer_meters_in_component_formulas: Default policy for the per-category formulas. When `True` (the default), the meter measurement is the primary source and the device measurement is the fallback for diff --git a/tests/test_microgrid_component_graph.py b/tests/test_microgrid_component_graph.py index 7d6c301..e9e2c22 100644 --- a/tests/test_microgrid_component_graph.py +++ b/tests/test_microgrid_component_graph.py @@ -141,10 +141,11 @@ def test_wind_turbine_graph() -> None: } # 3. Test Formula Generation - # References the Meter (ID 2) measuring the Turbine (ID 3). + # Component-first by default: the turbine (ID 3) is the primary source, + # the meter (ID 2) the fallback. assert ( graph.wind_turbine_formula(wind_turbine_ids={ComponentId(3)}) - == "COALESCE(#2, #3, 0.0)" + == "COALESCE(#3, #2, 0.0)" ) # 4. Test Topology (Successors/Predecessors) @@ -179,10 +180,43 @@ def test_steam_boiler_graph() -> None: } assert ( graph.steam_boiler_formula(steam_boiler_ids={ComponentId(3)}) - == "COALESCE(#2, #3, 0.0)" + == "COALESCE(#3, #2, 0.0)" ) +def test_consumer_formula_meter_subtraction() -> None: + """Test the consumer formula's meter-subtraction grouping. + + The non-consumer components behind one internal meter are subtracted + as one group: the meter reading first, the component readings as the + fallback. + """ + graph: microgrid_component_graph.ComponentGraph[ + Component, ComponentConnection, ComponentId + ] = microgrid_component_graph.ComponentGraph( + components={ + GridConnectionPoint( + id=ComponentId(1), + microgrid_id=MicrogridId(1), + rated_fuse_current=100, + ), + Meter(id=ComponentId(2), microgrid_id=MicrogridId(1)), + Meter(id=ComponentId(3), microgrid_id=MicrogridId(1)), + SolarInverter(id=ComponentId(4), microgrid_id=MicrogridId(1)), + }, + connections={ + # Grid -> Grid Meter -> PV Meter -> PV Inverter + ComponentConnection(source=ComponentId(1), destination=ComponentId(2)), + ComponentConnection(source=ComponentId(2), destination=ComponentId(3)), + ComponentConnection(source=ComponentId(3), destination=ComponentId(4)), + }, + ) + + # The PV group behind meter #3 is subtracted from the grid meter #2 as + # one COALESCE term, and consumption is clamped at zero. + assert graph.consumer_formula() == "MAX(#2 - COALESCE(#3, #4, 0.0), 0.0)" + + def test_relay_is_passthrough() -> None: """`Relay` maps to cg's `Breaker`, a pass-through category. From cdcb3cf2ffb01eceb779d71f44e83ea927002813 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Wed, 24 Jun 2026 20:42:44 +0000 Subject: [PATCH 2/4] Default to component-first per-category formulas component-graph flipped the prefer_meters_in_component_formulas default to false (component reading primary, meter as fallback). Mirror it in the binding so an explicit ComponentGraphConfig() matches both the no-config path and the upstream default, instead of forcing the old meter-first behavior. Update the type stub and the preference tests. Signed-off-by: Sahas Subramanian --- RELEASE_NOTES.md | 2 ++ .../microgrid_component_graph/__init__.pyi | 10 +++++----- src/graph.rs | 2 +- tests/test_formula_preferences.py | 18 +++++++++--------- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 26096f0..ce106a5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -16,6 +16,8 @@ - Graph validation failures now raise `InvalidGraphError` with a message that starts with `Graph validation failed:`, followed by one indented line per problem. The old format was `InvalidGraph: `. Note: some errors found while building the graph, for example a missing grid component, still use the old format. Code that matches on the message text of validation failures must be updated. +- `prefer_meters_in_component_formulas` now defaults to `False`: per-category formulas use the component reading as the primary source and the meter as the fallback. Set it to `True` to restore the old meter-first order. This changes only the order of the sources; the new fallback terms from v0.6.0 are used either way. + ## New Features diff --git a/python/frequenz/microgrid_component_graph/__init__.pyi b/python/frequenz/microgrid_component_graph/__init__.pyi index a2717c6..34e99ad 100644 --- a/python/frequenz/microgrid_component_graph/__init__.pyi +++ b/python/frequenz/microgrid_component_graph/__init__.pyi @@ -25,7 +25,7 @@ class ComponentGraphConfig: allow_unspecified_inverters: bool = False, disable_fallback_components: bool = False, include_phantom_loads_in_consumer_formula: bool = False, - prefer_meters_in_component_formulas: bool = True, + prefer_meters_in_component_formulas: bool = False, formula_overrides: FormulaOverrides | None = None, ) -> None: """Initialize this instance. @@ -55,11 +55,11 @@ class ComponentGraphConfig: that meter that is not in the component graph is then also excluded together with the group. prefer_meters_in_component_formulas: Default policy for the per-category - formulas. When `True` (the default), the meter measurement is the - primary source and the device measurement is the fallback for + formulas. When `False` (the default), the device measurement is the + primary source and the meter measurement is the fallback for `battery_formula`, `chp_formula`, `pv_formula`, `wind_turbine_formula`, - `ev_charger_formula`, and `steam_boiler_formula`. When `False`, the - device is primary and the meter is the fallback. Has no effect on + `ev_charger_formula`, and `steam_boiler_formula`. When `True`, the + meter is primary and the device is the fallback. Has no effect on `grid_formula`, `consumer_formula`, `producer_formula`, or any of the coalesce formulas. formula_overrides: Per-formula overrides for the meter/device preference; diff --git a/src/graph.rs b/src/graph.rs index 6685eb1..aba7d25 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -32,7 +32,7 @@ impl ComponentGraphConfig { allow_unspecified_inverters = false, disable_fallback_components = false, include_phantom_loads_in_consumer_formula = false, - prefer_meters_in_component_formulas = true, + prefer_meters_in_component_formulas = false, formula_overrides = None, ))] fn new( diff --git a/tests/test_formula_preferences.py b/tests/test_formula_preferences.py index bc699f6..50e5d09 100644 --- a/tests/test_formula_preferences.py +++ b/tests/test_formula_preferences.py @@ -7,8 +7,8 @@ for each per-category formula method and assert the actual formula output for the four meter/device-preference combinations: - * default config -> meter primary - * global False -> device primary + * default config -> device primary + * global True -> meter primary * per-formula override = True -> meter primary (override wins) * per-formula override = False -> device primary (override wins) @@ -190,26 +190,26 @@ def _steam_boiler_graph( @pytest.mark.parametrize("build_graph,method,override_field", _CATEGORIES) -def test_default_config_prefers_meter( +def test_default_config_prefers_device( build_graph: GraphBuilder, method: str, override_field: str, # pylint: disable=unused-argument ) -> None: - """Default config selects the meter as the primary source.""" + """Default config selects the device as the primary source.""" formula = getattr(build_graph(None), method)(None) - assert formula == _METER_PRIMARY + assert formula == _DEVICE_PRIMARY @pytest.mark.parametrize("build_graph,method,override_field", _CATEGORIES) -def test_global_false_prefers_device( +def test_global_true_prefers_meter( build_graph: GraphBuilder, method: str, override_field: str, # pylint: disable=unused-argument ) -> None: - """Setting `prefer_meters_in_component_formulas=False` selects the device.""" - config = ComponentGraphConfig(prefer_meters_in_component_formulas=False) + """Setting `prefer_meters_in_component_formulas=True` selects the meter.""" + config = ComponentGraphConfig(prefer_meters_in_component_formulas=True) formula = getattr(build_graph(config), method)(None) - assert formula == _DEVICE_PRIMARY + assert formula == _METER_PRIMARY @pytest.mark.parametrize("build_graph,method,override_field", _CATEGORIES) From d2a28b343c420034d8e0bf9f90400861afe10262 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Wed, 24 Jun 2026 20:43:36 +0000 Subject: [PATCH 3/4] Rename device -> component in the binding docs and tests Pure terminology: the per-category formulas' non-meter source is a component, matching component-graph's vocabulary (the flag is prefer_meters_in_component_formulas). Rename _DEVICE_PRIMARY and the "device" wording in the type stub and preference tests to "component"; no behavior change. Signed-off-by: Sahas Subramanian --- .../microgrid_component_graph/__init__.pyi | 10 ++++---- tests/test_formula_preferences.py | 24 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/python/frequenz/microgrid_component_graph/__init__.pyi b/python/frequenz/microgrid_component_graph/__init__.pyi index 34e99ad..b1fdb79 100644 --- a/python/frequenz/microgrid_component_graph/__init__.pyi +++ b/python/frequenz/microgrid_component_graph/__init__.pyi @@ -55,25 +55,25 @@ class ComponentGraphConfig: that meter that is not in the component graph is then also excluded together with the group. prefer_meters_in_component_formulas: Default policy for the per-category - formulas. When `False` (the default), the device measurement is the + formulas. When `False` (the default), the component measurement is the primary source and the meter measurement is the fallback for `battery_formula`, `chp_formula`, `pv_formula`, `wind_turbine_formula`, `ev_charger_formula`, and `steam_boiler_formula`. When `True`, the - meter is primary and the device is the fallback. Has no effect on + meter is primary and the component is the fallback. Has no effect on `grid_formula`, `consumer_formula`, `producer_formula`, or any of the coalesce formulas. - formula_overrides: Per-formula overrides for the meter/device preference; + formula_overrides: Per-formula overrides for the meter/component preference; see `FormulaOverrides`. Each entry, when set, takes precedence over `prefer_meters_in_component_formulas` for that formula. """ class FormulaOverrides: - """Per-formula overrides for the meter/device preference. + """Per-formula overrides for the meter/component preference. Each parameter is `None` by default, meaning the corresponding formula follows the global `prefer_meters_in_component_formulas` setting on `ComponentGraphConfig`. Setting `True` forces the meter as primary - for that formula; `False` forces the device. + for that formula; `False` forces the component. """ def __init__( diff --git a/tests/test_formula_preferences.py b/tests/test_formula_preferences.py index 50e5d09..ad19354 100644 --- a/tests/test_formula_preferences.py +++ b/tests/test_formula_preferences.py @@ -3,14 +3,14 @@ """Behavioral tests for `ComponentGraphConfig` formula preferences. -These tests build a small, controllable graph (Grid -> Meter -> Device) +These tests build a small, controllable graph (Grid -> Meter -> Component) for each per-category formula method and assert the actual formula -output for the four meter/device-preference combinations: +output for the four meter/component-preference combinations: - * default config -> device primary + * default config -> component primary * global True -> meter primary * per-formula override = True -> meter primary (override wins) - * per-formula override = False -> device primary (override wins) + * per-formula override = False -> component primary (override wins) The drift test in `test_stub_drift.py` only checks signatures; this file catches actual mis-wiring -- a swapped override, an inverted @@ -46,9 +46,9 @@ _MGRID = MicrogridId(1) # In every per-category topology built below, component #2 is the meter -# and #3 is the device that appears in the formula. +# and #3 is the component that appears in the formula. _METER_PRIMARY = "COALESCE(#2, #3, 0.0)" -_DEVICE_PRIMARY = "COALESCE(#3, #2, 0.0)" +_COMPONENT_PRIMARY = "COALESCE(#3, #2, 0.0)" # Each per-category graph builder takes an optional `config` and returns a # graph rooted at the same Grid -> Meter pair, so the assertion strings @@ -104,7 +104,7 @@ def _battery_graph( config: ComponentGraphConfig | None = None, ) -> ComponentGraph[Any, Any, Any]: # Grid -> Meter -> BatteryInverter -> Battery; the formula references - # the inverter (#3) as the device, the battery (#4) doesn't appear. + # the inverter (#3) as the component, the battery (#4) doesn't appear. return ComponentGraph( components={ _grid(), @@ -190,14 +190,14 @@ def _steam_boiler_graph( @pytest.mark.parametrize("build_graph,method,override_field", _CATEGORIES) -def test_default_config_prefers_device( +def test_default_config_prefers_component( build_graph: GraphBuilder, method: str, override_field: str, # pylint: disable=unused-argument ) -> None: - """Default config selects the device as the primary source.""" + """Default config selects the component as the primary source.""" formula = getattr(build_graph(None), method)(None) - assert formula == _DEVICE_PRIMARY + assert formula == _COMPONENT_PRIMARY @pytest.mark.parametrize("build_graph,method,override_field", _CATEGORIES) @@ -229,13 +229,13 @@ def test_override_true_wins_over_global_false( def test_override_false_wins_over_global_true( build_graph: GraphBuilder, method: str, override_field: str ) -> None: - """A `False` per-formula override flips to device despite a `True` global.""" + """A `False` per-formula override flips to component despite a `True` global.""" config = ComponentGraphConfig( prefer_meters_in_component_formulas=True, formula_overrides=FormulaOverrides(**{override_field: False}), ) formula = getattr(build_graph(config), method)(None) - assert formula == _DEVICE_PRIMARY + assert formula == _COMPONENT_PRIMARY def _empty_graph() -> ComponentGraph[Any, Any, Any]: From a9fd1a7077723f90821a047305e429ab81a04f4f Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 16 Jul 2026 12:32:51 +0000 Subject: [PATCH 4/4] Compare default values in the stub-drift test The stub-drift test compared only method and parameter names, so a default changed on one side only (the Rust pyo3 signature or the type stub) would pass CI while type checkers report the wrong default. The prefer_meters_in_component_formulas flip is exactly this kind of change. Add a test that compares the __init__ default values from the stub AST against the runtime signature. Defaults that are not plain literals are marked and fail the comparison loudly; classes without an introspectable runtime signature can not be checked; the known set of those classes is pinned, so a new silent skip also fails the test. Signed-off-by: Sahas Subramanian --- tests/test_stub_drift.py | 85 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tests/test_stub_drift.py b/tests/test_stub_drift.py index 32b7354..6a4654a 100644 --- a/tests/test_stub_drift.py +++ b/tests/test_stub_drift.py @@ -83,3 +83,88 @@ def test_stub_and_runtime_init_params_agree() -> None: f" stub: {stub_params}\n" f" runtime: {runtime_params}" ) + + +_NON_LITERAL = "" + +# pyo3 exposes no introspectable signature for these classes (their +# `text_signature` holds an expression `inspect` can not parse), so the +# defaults test can not cover them. The test pins this set: a class that +# starts (or stops) being skipped fails the run instead of being skipped +# silently. +_KNOWN_NOT_INTROSPECTABLE = frozenset({"ComponentGraph"}) + + +def _literal_or_marker(node: ast.expr) -> object: + """Evaluate a stub default, or return the ``_NON_LITERAL`` marker.""" + try: + return ast.literal_eval(node) + except (TypeError, ValueError): + return _NON_LITERAL + + +def _parse_stub_init_defaults() -> dict[str, dict[str, object]]: + """Parse the stub into ``{ClassName: {param_name: default_value}}``. + + Only ``__init__`` parameters with a default are included. A default + that is not a plain literal (like ``False`` or ``None``) is stored as + the ``_NON_LITERAL`` marker; it never equals a runtime value, so a + comparison against it fails loudly. + """ + tree = ast.parse(_STUB_PATH.read_text()) + out: dict[str, dict[str, object]] = {} + for node in tree.body: + if not isinstance(node, ast.ClassDef): + continue + for item in node.body: + if not isinstance(item, ast.FunctionDef) or item.name != "__init__": + continue + args = item.args + defaults: dict[str, object] = {} + positional = args.posonlyargs + args.args + for arg, default in zip(positional[-len(args.defaults) :], args.defaults): + defaults[arg.arg] = _literal_or_marker(default) + for kwarg, kw_default in zip(args.kwonlyargs, args.kw_defaults): + if kw_default is not None: + defaults[kwarg.arg] = _literal_or_marker(kw_default) + out[node.name] = defaults + return out + + +def test_stub_and_runtime_init_defaults_agree() -> None: + """`__init__` default values in the stub must match the runtime signature. + + The parameter-name test above can not catch a default changed on only + one side -- for example a flag like `prefer_meters_in_component_formulas` + flipped in the Rust `#[pyo3(signature = ...)]` but not in the stub. + Type checkers and IDEs would then report the wrong default for a flag + that inverts behavior. + + Classes without an introspectable runtime signature can not be checked. + The known set of such classes is pinned in `_KNOWN_NOT_INTROSPECTABLE`, + so a new silent skip fails the test. + """ + stub_defaults = _parse_stub_init_defaults() + skipped: set[str] = set() + for cls in _PUBLIC_RUNTIME_CLASSES: + try: + runtime_sig = inspect.signature(cls) + except ValueError: + skipped.add(cls.__name__) + continue + runtime_defaults = { + name: param.default + for name, param in runtime_sig.parameters.items() + if param.default is not inspect.Parameter.empty + } + cls_stub_defaults = stub_defaults.get(cls.__name__, {}) + assert cls_stub_defaults == runtime_defaults, ( + f"{cls.__name__}.__init__ default drift:\n" + f" stub: {cls_stub_defaults}\n" + f" runtime: {runtime_defaults}" + ) + assert skipped == _KNOWN_NOT_INTROSPECTABLE, ( + "classes skipped by the defaults check changed: " + f"{sorted(skipped)} (expected {sorted(_KNOWN_NOT_INTROSPECTABLE)}); " + "update `_KNOWN_NOT_INTROSPECTABLE` and check those stub defaults by hand" + )