Skip to content

fix(compare): skip malformed compare_list entries instead of crashing, with one shared check - #5488

Merged
springfall2008 merged 9 commits into
mainfrom
fix/compare-missing-tariff-id-5472
Oct 12, 2026
Merged

springfall2008 merged 9 commits into
mainfrom
fix/compare-missing-tariff-id-5472

Conversation

@chalfontchubby

@chalfontchubby chalfontchubby commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #5472

Summary

Compare.run_all() indexed tariff["id"] directly, so a compare_list entry in apps.yaml with no id: line raised KeyError: 'id'. That crash also skipped the compare_active reset in update_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.py already knew most of the rules, but nothing else used it. This PR puts the rules in one place:

  • compare.py: the new module-level check_compare_list() returns the entries that can be compared, plus a message for each entry it skips. Each kept entry is a copy whose id and name are stripped strings and whose config is either a mapping or absent. An entry is skipped when:

    • it isn't a dictionary;
    • it has no id, or only whitespace for one;
    • its id duplicates an earlier entry's (7 and "7" count as the same, and so do agile and " agile ");
    • it has no name;
    • its config: is neither a mapping nor empty (an empty config: (None, "", [] or {}) still means no overrides).

    A compare_list that isn't a list at all gives one problem and no entries. run_all(), publish_only() and load_yaml() all read the list through valid_compare_list(), which logs each skipped entry as Warn: Compare, compare_list entry N ..., skipping it.

  • Numeric ids and names: an unquoted id: 2024 or name: 2024 is read by YAML as a number. That number then crashed publish_data() when it built the sensor name, every cycle via publish_only(). Because save_yaml() runs first, the number was also saved to comparisons.yaml and crashed Compare.__init__ on every restart after that. Ids and names now become strings on the way in, and load_yaml()/publish_data() convert anything already saved.

  • predbat.py: setting and clearing compare_active around run_all() now lives in a small run_comparison() helper. The flag is cleared in a finally, so a crash in the comparison from any cause can't leave the switch on and re-trigger itself every cycle.

  • web.py:

    • The apps.yaml editor's _validate_compare_list() now calls check_compare_list(), so it refuses exactly what a comparison would skip, and it lists every problem at once.
    • The Compare page (html_compare) reads the same checked list, so a numeric id finds its result and a malformed entry no longer crashes the page.
    • The page now shows a "Skipped:" line for each entry it can't use, rather than dropping the entry silently or saying no tariffs are configured.

Behaviour changes for hand-edited configs

These apply only to an apps.yaml that already has one of these mistakes:

  • Duplicate ids: the first entry now wins and later ones are skipped with a warning. Before, every entry ran and the last one overwrote the result. The editor already refused duplicates.
  • A non-empty 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).
  • The editor validates the whole compare_list on 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 a 7/"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:

  • T27 runs valid_compare_list() and check_compare_list() over every malformed shape listed above. It checks the warning for each skipped entry, by position and by reason. It also checks:
    • that ids and names come back as stripped strings;
    • that the user's own entries are copied, not modified;
    • that id: 0 is accepted;
    • which config: values are dropped, which are kept as overrides, and which are skipped.
  • T28 runs run_all() against the real fixture, with run_single(), save_yaml() and the other expensive steps stubbed. The compare_list mixes valid and malformed entries, including a list config: that reaches run_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.
  • T29 makes run_all() raise inside run_comparison(). It checks that the exception propagates, and that compare_active is set and then cleared, and afterwards it puts the shared fixture back as it was.
  • T30 / T31 check that load_yaml() turns a numeric key saved in comparisons.yaml into a string, and that publish_data() publishes a numeric name saved there.

In test_web_functions.py, the Compare page:

  • shows a numeric id's result;
  • survives malformed entries and explains why each was skipped;
  • doesn't show "not configured" or "Loading chart" when every entry is malformed.

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:

  • the old tariff["id"] loop fails T28 with KeyError('id');
  • removing the finally leaves compare_active on in T29;
  • skipping the string conversion makes T30 and T31 raise TypeError;
  • a check that skips or drops every 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 high plus an independent reviewer, until neither found a significant issue.

Notes / not in this PR

  • impact():

    • update_pred comes back HIGH, because it is the main loop. The only change to it moves the compare_active block into run_comparison().
    • _validate_compare_list and html_compare are LOW, with one caller each.
  • An exception from run_all() still propagates out of update_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 inside run_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:

    • ids are not checked to be valid Home Assistant entity names (spaces, capitals, 1.5);
    • html_compare still puts the id and name into the page unescaped;
    • results for ids no longer in compare_list are still republished;
    • tariff_catalogue.py (the annual page's dropdowns) keeps its own compare_list rules.

    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

#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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_active using finally.
  • 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.

Comment thread apps/predbat/predbat.py Outdated
Comment thread apps/predbat/tests/test_compare.py Outdated
chalfontchubby and others added 8 commits October 11, 2026 22:06
…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>
@chalfontchubby chalfontchubby changed the title fix(compare): skip a compare_list entry with no id instead of crashing fix(compare): skip malformed compare_list entries instead of crashing, with one shared check Oct 11, 2026
@springfall2008
springfall2008 merged commit a73bfef into main Oct 12, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/compare-missing-tariff-id-5472 branch October 12, 2026 07:03
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Error: Exception raised 'id when missing from compare configuration

3 participants