fix(salary): treat null metadata/categories as absent instead of crashing (#413)

--validate treats an explicit "metadata": null / "categories": null the same
as an omitted key ("...must be an object when provided", None is skipped), but
format_entry read both through dict.get(key, {}), which only substitutes the
default for an *absent* key - a present-but-null value passed through. The
renderer then hit None.get("index_label", ...) (AttributeError) or, via the
numeric-field fallback, None[key] = value (TypeError), so a hand-maintained
salary_data.json using null for "no value" died with an uncaught traceback
right after printing "Found 1 match(es)".

format_entry now coerces both to {} up front, honouring the validator's
existing "when provided" contract at the single consumer that broke it.

Tests (all verified to fail on the unfixed renderer):
- two unit cases calling format_entry with null metadata / null categories
- two end-to-end cases running main() --validate (blesses the file) then the
  lookup path (renders it), one per null shape

Plus an [Unreleased] CHANGELOG entry.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
soumyadip sarkar
2026-09-01 21:35:59 +02:00
committed by GitHub
co-authored by Claude Sonnet 5
parent 6ef295bf7b
commit 9833a5dcb7
3 changed files with 112 additions and 2 deletions
+92
View File
@@ -87,6 +87,34 @@ class FormatEntryTests(unittest.TestCase):
self.assertIn("45000.0", rendered)
self.assertIn("+12.5%", rendered)
def test_null_categories_with_sibling_dict_does_not_crash(self):
# --validate accepts "categories": null, so format_entry must not crash
# on it. entry.get("categories", {}) returns None (not {}) for an
# explicit null, and the numeric-field fallback then did None[key] = ....
entry = {
"company": "Example Corp",
"city": "",
"categories": None,
"engineering": {"count": 10, "index": 105.0},
}
rendered = format_entry(entry, {"index_baseline": 100, "index_label": "Index"})
self.assertRegex(rendered, r"Engineering\s+10\s+105\.0")
def test_null_metadata_does_not_crash(self):
# --validate accepts "metadata": null the same way; format_entry then did
# None.get("index_label", ...) -> AttributeError.
entry = {
"company": "Example Corp",
"city": "",
"categories": {"eng": {"count": 5, "index": 108.0}},
}
rendered = format_entry(entry, None)
self.assertRegex(rendered, r"Eng\s+5\s+108\.0")
# ---------------------------------------------------------------------------
# match_score tests (from #106)
@@ -331,6 +359,70 @@ class ValidateFlagTests(unittest.TestCase):
self.assertIn("Duplicate company name", out)
class NullShapeEndToEndTests(unittest.TestCase):
"""The disagreement in full: --validate blesses a file with a null
metadata/categories, then the lookup path must render it, not crash.
Both payloads pass --validate on master; the second command then dies
(TypeError in the categories fallback, AttributeError on metadata.get).
"""
def _run_main(self, payload, *argv_tail):
"""Run main() against `payload` with the given argv. Returns
(exit_code_or_None, stdout). main() returns normally on a successful
render, so a missing SystemExit is success, not an error."""
with tempfile.TemporaryDirectory() as tmpdir:
data_file = Path(tmpdir) / "salary_data.json"
data_file.write_text(payload, encoding="utf-8")
original_data_file = salary_lookup.DATA_FILE
salary_lookup.DATA_FILE = data_file
argv_patch = mock.patch("sys.argv", ["salary_lookup.py", *argv_tail])
argv_patch.start()
try:
stdout = io.StringIO()
try:
with redirect_stdout(stdout):
salary_lookup.main()
return None, stdout.getvalue()
except SystemExit as exc:
return exc.code, stdout.getvalue()
finally:
argv_patch.stop()
salary_lookup.DATA_FILE = original_data_file
def test_null_categories_passes_validate_then_renders(self):
payload = (
'{"metadata": {"index_label": "Index", "index_baseline": 100},'
' "companies": [{"company": "Foo A/S", "city": "Aarhus",'
' "categories": null,'
' "engineering": {"count": 10, "index": 105}}]}'
)
code, out = self._run_main(payload, "--validate")
self.assertEqual(code, 0)
self.assertIn("OK", out)
code, out = self._run_main(payload, "Foo")
self.assertIsNone(code)
self.assertIn("Foo A/S", out)
self.assertRegex(out, r"Engineering\s+10\s+105")
def test_null_metadata_passes_validate_then_renders(self):
payload = (
'{"metadata": null,'
' "companies": [{"company": "Foo A/S", "city": "Aarhus",'
' "categories": {"engineering": {"count": 10, "index": 105}}}]}'
)
code, out = self._run_main(payload, "--validate")
self.assertEqual(code, 0)
self.assertIn("OK", out)
code, out = self._run_main(payload, "Foo")
self.assertIsNone(code)
self.assertRegex(out, r"Engineering\s+10\s+105")
class UtilityTests(unittest.TestCase):
def test_normalize_strips_suffix_and_noise(self):
self.assertEqual(normalize("Novo Nordisk A/S"), "novonordisk")