Repository navigation
fix(compare): skip malformed compare_list entries instead of crashing, with one shared check - #5488
Merged
Merged
Conversation
#5472) Compare.run_all() indexed tariff["id"] directly, so a compare_list entry missing its id line raised KeyError every 5-minute cycle - and because the crash skipped predbat.py's compare_active reset, the comparison (and the crash) kept re-triggering until the config was fixed. The id lookup and its warning are now in their own get_tariff_id() method, so the guard can be tested directly rather than only by inspecting run_all()'s source text (code review for #5472 found the source-inspection test alone would pass a guard that checked the wrong field, e.g. tariff.get("name")). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
The key skip and exception-reset paths need behavioral regression tests rather than source inspection or no coverage.
2 open findings
What changed in this PR
Prevents malformed tariff comparisons from crashing repeatedly and ensures the activation flag resets after failures.
Changes:
- Skips comparison entries without an ID and logs a warning.
- Resets
compare_activeusingfinally. - Adds helper and source-inspection tests.
| File | Description |
|---|---|
apps/predbat/compare.py |
Validates tariff IDs before comparison. |
apps/predbat/predbat.py |
Reliably resets the comparison trigger. |
apps/predbat/tests/test_compare.py |
Tests tariff-ID handling and wiring. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ive reset (#5472) Replace T28's source-text check with a run_all() run against the real fixture (run_single/save_yaml stubbed) showing only the valid tariff is compared and persisted. Extract the compare_active set/clear into PredBat.run_comparison() so T29 can make run_all() raise and check the flag is cleared, without driving all of update_pred(). Both tests fail with the fixes reverted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…try (#5472) get_tariff_id() only caught a missing id. A non-dict entry, a duplicate id or a nameless entry still misbehaved in run_all(), and an unquoted numeric id (YAML int) still crashed publish_data() building the sensor name - every cycle, via publish_only(). Replace it with module-level compare_entry_problem()/valid_compare_list(), which run_all(), publish_only() and load_yaml() use to drop bad entries (warning with the entry's position) and normalise ids to strings. load_yaml() also str()s stored keys so a numeric key saved earlier can't crash startup. T29 now restores compare_active on the shared fixture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pare page (#5472) _validate_compare_list() now applies compare_entry_problem(), so the apps.yaml editor refuses exactly what a comparison would skip - now including a blank id and a numeric id clashing with the same id quoted. html_compare() reads valid_compare_list(), so a malformed entry no longer crashes the page and a numeric id finds its string-keyed result. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…5472) Review follow-up. check_compare_list() returns the usable entries plus a message per skipped one, so web.py's validator no longer re-implements the loop. It also covers a compare_list that isn't a list (was a TypeError every cycle via publish_only), strips padded ids so ' agile' can't sidestep the duplicate check, and str()s a numeric name, which otherwise crashed publish_data() - and, once saved, every startup. The Compare page now says why an entry was skipped instead of dropping it silently or claiming nothing is configured. Tests now assert the run_all() warnings and the editor's refusal reasons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…roblem (#5472) Second review round. check_compare_list() now skips an entry whose config: isn't a mapping (it crashed run_all() outside the per-tariff try) and treats an empty config: as no overrides; strips the name so a blank one is refused like a blank id. publish_data() str()s a stored name so one saved as a number can't stop startup. The editor lists every compare_list problem at once rather than one per save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rrides (#5472) Third review round. The previous commit skipped any entry whose config: wasn't a mapping, which wrongly dropped working tariffs with config: [] or "" (both always meant no overrides). Only a non-empty non-mapping config - which crashed run_all()'s override snapshot - is skipped now, and the comment names that real crash site. T28 now drives a list config through run_all() itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up. Only None, "", [] and {} count as an empty config:
and are dropped; any other non-mapping (false, 0, a string, a list) is
skipped with a warning instead of silently losing the overrides.
check_compare_list()'s docstring now states the shape it guarantees,
and the comment names where a list config really crashed (overrides
looked up by key in run_all()/fetch_config()).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…5472) Review follow-up. One EMPTY_COMPARE_CONFIG tuple now decides both the skip and the drop, rather than a tuple in one place and truthiness in the other. T27 checks the reason each config entry was skipped, and adds a mapping config, which must survive as the tariff's overrides - nothing tested that, so a check skipping or dropping every config would have passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
springfall2008
pushed a commit
that referenced
this pull request
Oct 12, 2026
Conflicts with #5488 (compare_list validation), resolved keeping both sides: - compare.py: main's check_compare_list()/valid_compare_list() and this branch's COMPARE_*_STATE sets both kept at module level; in run_all() the per-tariff log uses main's tariff_id and this branch's plan_valid = False follows it. - tests/test_compare.py: one import line carrying both sets of names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Fixes #5472
Summary
Compare.run_all()indexedtariff["id"]directly, so acompare_listentry inapps.yamlwith noid:line raisedKeyError: 'id'. That crash also skipped thecompare_activereset inupdate_pred(), so the comparison, and the crash, re-triggered every 5-minute cycle until the config was fixed.Review showed the missing id was one of several malformed entries that crash or misbehave in the same way. The editor's own validator in
web.pyalready knew most of the rules, but nothing else used it. This PR puts the rules in one place:compare.py: the new module-levelcheck_compare_list()returns the entries that can be compared, plus a message for each entry it skips. Each kept entry is a copy whoseidandnameare stripped strings and whoseconfigis either a mapping or absent. An entry is skipped when:7and"7"count as the same, and so doagileand" agile ");config:is neither a mapping nor empty (an emptyconfig:(None,"",[]or{}) still means no overrides).A
compare_listthat isn't a list at all gives one problem and no entries.run_all(),publish_only()andload_yaml()all read the list throughvalid_compare_list(), which logs each skipped entry asWarn: Compare, compare_list entry N ..., skipping it.Numeric ids and names: an unquoted
id: 2024orname: 2024is read by YAML as a number. That number then crashedpublish_data()when it built the sensor name, every cycle viapublish_only(). Becausesave_yaml()runs first, the number was also saved tocomparisons.yamland crashedCompare.__init__on every restart after that. Ids and names now become strings on the way in, andload_yaml()/publish_data()convert anything already saved.predbat.py: setting and clearingcompare_activearoundrun_all()now lives in a smallrun_comparison()helper. The flag is cleared in afinally, so a crash in the comparison from any cause can't leave the switch on and re-trigger itself every cycle.web.py:_validate_compare_list()now callscheck_compare_list(), so it refuses exactly what a comparison would skip, and it lists every problem at once.html_compare) reads the same checked list, so a numeric id finds its result and a malformed entry no longer crashes the page.Behaviour changes for hand-edited configs
These apply only to an
apps.yamlthat already has one of these mistakes:config:that isn't a mapping (e.g.config: "best_soc_min: 2.0") now skips that tariff with a warning. Before, it either crashed the comparison (a list,false,0) or ran with no overrides, logging one "not found" warning per character (a string).compare_liston every save, as it did before. Because the rules are now stricter, an existing entry with one of these mistakes, a blank id or name, or a7/"7"clash will block unrelated saves until it is fixed. The error names each entry by its position.Testing
In
apps/predbat/tests/test_compare.py:valid_compare_list()andcheck_compare_list()over every malformed shape listed above. It checks the warning for each skipped entry, by position and by reason. It also checks:id: 0is accepted;config:values are dropped, which are kept as overrides, and which are skipped.run_all()against the real fixture, withrun_single(),save_yaml()and the other expensive steps stubbed. Thecompare_listmixes valid and malformed entries, including a listconfig:that reachesrun_all()'s override snapshot. The test checks that only the valid tariffs are compared and saved, keyed by string id, and that each skipped entry is logged.run_all()raise insiderun_comparison(). It checks that the exception propagates, and thatcompare_activeis set and then cleared, and afterwards it puts the shared fixture back as it was.load_yaml()turns a numeric key saved incomparisons.yamlinto a string, and thatpublish_data()publishes a numeric name saved there.In
test_web_functions.py, the Compare page:In
test_web_apps_edit.py, the editor refuses a blank id, a numeric/quoted id clash and a duplicate id, and names every problem at once.Each new test was checked against the code with its fix reverted, or a plausible wrong version substituted, and fails as expected. Examples:
tariff["id"]loop fails T28 withKeyError('id');finallyleavescompare_activeon in T29;TypeError;config:fails T27../run_pre_commit(all hooks pass) and./run_all --quick(all tests pass) were run on every commit. Before pushing, each round was reviewed locally with/code-review highplus an independent reviewer, until neither found a significant issue.Notes / not in this PR
impact():update_predcomes back HIGH, because it is the main loop. The only change to it moves thecompare_activeblock intorun_comparison()._validate_compare_listandhtml_compareare LOW, with one caller each.An exception from
run_all()still propagates out ofupdate_pred(). That is unchanged from before, so the rest of that cycle's housekeeping is skipped and the run is recorded as an error. Catching it insiderun_comparison()or per tariff would contain it, but that changes how failures are reported, so it is left for a separate decision.Not changed here:
1.5);html_comparestill puts the id and name into the page unescaped;compare_listare still republished;tariff_catalogue.py(the annual page's dropdowns) keeps its owncompare_listrules.All of these were here before this PR.
Related, already tracked separately: Log warnings of invalid compare items #5133/PR fix(compare): publish a valid entity id per tariff and stop republishing removed tariffs (#5133) #5143 (stale or invalid entity-id publishing) and PR fix(compare): stop the midnight tariff comparison changing the live plan's next decision #5465 (the midnight comparison overwriting the live plan's
plan_preclip).🤖 Generated with Claude Code