test(config): cover the non-string-key guard in the unknown-key walk (#627) - #658
Conversation
…akazhanAlpamys#627) A branch-coverage run over the full suite (19935 passed, 108 skipped, 81% with --cov-branch) left unknown_keys.py at 97% with exactly one uncovered statement: the `continue` at line 118, inside for key, value in raw.items(): if not isinstance(key, str): continue That guard is mine from MakazhanAlpamys#628 and nothing had ever executed it. It is load-bearing. YAML permits non-string mapping keys, and without the guard the key reaches difflib.get_close_matches, which iterates it: get_close_matches(1, [...]) -> TypeError: 'int' object is not iterable get_close_matches(True, [...]) -> TypeError: 'bool' object is not iterable So a config carrying `1:` or `true:` under any section would kill `soup train --config` with a bare TypeError raised from inside the unknown-key reporter -- the code whose entire job is to turn a confusing failure into an actionable one. That is the MakazhanAlpamys#628 defect in different clothes. Mutation evidence, each run against the pre-existing classes in the file and against the new one: mutation PRE-EXISTING NEW guard removed entirely SURVIVED KILLED continue -> return (aborts the walk) SURVIVED KILLED isinstance(key, str) -> (key, object) SURVIVED KILLED The third is reported, not counted: `not isinstance(x, object)` is always False, so it is behaviourally identical to removing the guard and produces the same four failures. One kill spelled two ways. The `continue -> return` row is why the mask test exists: the typo sits after the non-string key in insertion order, so it is only reported if the walk kept going rather than bailing out. Also pinned: `{1: ..., true: ...}` collapses to ONE dict entry because True == 1 in Python, so a fixture written without noticing would claim two non-string key types while covering one. No source change; the guard is correct, only the coverage was missing. Zero deleted test lines. Claude-Session: https://claude.ai/code/session_014vWWFgXhj9y46pYCyEjfcy
|
Merged as Four of the six tests would have been satisfied by "does not raise". The two that actually decide the merge assert the walk keeps going: a typo'd key placed after a non-string key is still reported, with its suggestion, at the top level and one section down. That is exactly what separates this from a coverage-chasing PR — an early- What I verified independently before merging, at
Both anchored on the full executable line with the diff printed, reverted between rounds. The second row is the discriminating one: only the two "keeps going" tests fail under Also checked and confirmed: Two smaller things I want on the record because they are habits, not accidents:
#627 stays open, and I want that visible so a green coverage PR is not read as the issue being done. #628 shipped phase 1 (warn); No changelog fragment, matching #647 and #653. CONTRIBUTORS.md updated — this is the second time you have come back to cover a line in code you shipped yourself, which is a rarer habit than writing the code was. |
Covers the one uncovered statement in
config/unknown_keys.py— the non-string-key guard I shipped in #628. No source change; the guard is correct, only its coverage was missing.Claimed on #627 before writing anything.
Refs #627.How it was found
Not by eye. A branch-coverage run over the full suite:
Line 118 is the
continuein:Nothing in
tests/test_issue627_unknown_config_keys.pyhad ever fed the walk a non-string key.Why it matters
YAML permits non-string mapping keys. Without the guard the key reaches
difflib.get_close_matches, which iterates it:So a config carrying
1:ortrue:under any section would killsoup train --configwith a bareTypeErrorraised from inside the unknown-key reporter — the code whose entire purpose is to turn a confusing failure into an actionable one. That is the #628 defect wearing different clothes.Mutation table
Each mutation run against the pre-existing classes in the file and against the new one, so "this was uncovered" is measured rather than asserted:
continue->return(aborts the walk)isinstance(key, str)->isinstance(key, object)Named failures:
The third row is reported, not counted.
not isinstance(x, object)is alwaysFalse, so that mutation is behaviourally identical to removing the guard and produces the same four failures. One kill spelled two ways — I would rather say so than present three independent kills.unknown_keys.pymd5-verified byte-identical after every round;__pycache__cleared each time.Two details the tests pin deliberately
continue, notreturn. The mask test puts the typo after the non-string key in insertion order, sotraining.epocsis only reported if the walk kept going. A guard rewritten as an earlyreturnwould silently stop reporting every key after the first non-string one — and would have passed a test that only checked "does not crash".True == 1.{1: ..., true: ...}collapses to a single dict entry, soyaml.safe_load("1: numeric\ntrue: boolean\n")yields[1], not two keys. A fixture written without noticing would claim to cover two non-string key types while covering one.test_python_collapses_true_and_one_into_one_keypins that premise before the other tests rely on it; the boolean case is therefore constructed directly rather than through YAML.Verification
Measured but deliberately not taken
Recorded so they are findable rather than lost in a terminal. Both are the #273 shape — full statement coverage, one branch each:
monitoring/plugin_callback.py34->30— an enabled plugin that exposes no hooks is never exercised.utils/moe.py60->62— a config object with no__dict__is never exercised.Happy for anyone to take either; say the word if you would rather I fold them into this PR.
Limits
difflibcall rather than by shipping a broken build: the TypeError above is real output, but the end-to-endsoup trainpath is only reached throughfind_unknown_config_keys, which is what the tests drive.https://claude.ai/code/session_014vWWFgXhj9y46pYCyEjfcy