From 6ca188dcd5d446ce2ec601b5b8f2b45788bfb43a Mon Sep 17 00:00:00 2001 From: bfs2021 <101915083+bfs2021@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:03:07 +0800 Subject: [PATCH] fix(manifest): reject a manifest that is not a mapping (#1177) An empty file parses to None and a CSV passed by mistake parses to a string; both reached data.get() and died with AttributeError and a traceback. load_manifest now raises the same ValueError the other schema errors use, so the CLI prints one error line and exits 2. --- faircode/manifest.py | 7 +++++++ tests/test_cli.py | 32 ++++++++++++++++++++++++++++++++ tests/test_manifest.py | 13 +++++++++++++ 3 files changed, 52 insertions(+) diff --git a/faircode/manifest.py b/faircode/manifest.py index 405a64bb..dc715841 100644 --- a/faircode/manifest.py +++ b/faircode/manifest.py @@ -224,6 +224,13 @@ def load_manifest(path) -> Manifest: path = Path(path) with open(path) as f: data = yaml.safe_load(f) + if not isinstance(data, dict): + # An empty file parses to None, and a file passed by mistake (a CSV, + # say) to a string; both used to die inside data.get() (#1177). + raise ValueError( + f"{path}: the manifest must be a mapping of keys to values, " + f"got {type(data).__name__}" + ) return Manifest.from_dict(data, path) diff --git a/tests/test_cli.py b/tests/test_cli.py index 1511f00c..c9f8654f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1132,6 +1132,38 @@ def raise_value_error(**kwargs): captured = capsys.readouterr() assert "error: bad_audit/audit.yaml: 'target'" in captured.err +def test_cli_benchmark_empty_manifest_returns_2_with_clean_error(tmp_path, capsys): + """#1177: an empty manifest used to raise AttributeError.""" + pytest.importorskip("sklearn", reason="benchmark extra required") + pytest.importorskip("fairlearn", reason="benchmark extra required") + pytest.importorskip("yaml", reason="benchmark extra required") + + empty = tmp_path / "empty.yaml" + empty.write_text("") + exit_code = main(["benchmark", str(empty), "--no-plots"]) + + assert exit_code == 2 + captured = capsys.readouterr() + assert f"error: {empty}:" in captured.err + assert "must be a mapping" in captured.err + assert "Traceback" not in captured.err + + +def test_cli_benchmark_non_mapping_manifest_returns_2_with_clean_error(tmp_path, capsys): + """#1177: a CSV passed by mistake used to raise AttributeError.""" + pytest.importorskip("sklearn", reason="benchmark extra required") + pytest.importorskip("fairlearn", reason="benchmark extra required") + pytest.importorskip("yaml", reason="benchmark extra required") + + path = tmp_path / "tiny.csv" + path.write_text("name,title\ntoy,Toy\n") + exit_code = main(["benchmark", str(path), "--no-plots"]) + + assert exit_code == 2 + captured = capsys.readouterr() + assert "must be a mapping" in captured.err + assert "Traceback" not in captured.err + @pytest.mark.skipif(not SMALL_AUDIT.is_file(), reason="German Credit Lending fixture not found") @pytest.mark.slow # trains models end to end (about 15 s); `make test-fast` skips it (#910) diff --git a/tests/test_manifest.py b/tests/test_manifest.py index ca5556d4..a68cbec8 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -96,6 +96,19 @@ def test_empty_protected_attributes_raises(tmp_path): load_manifest(path) +def test_an_empty_manifest_raises_a_mapping_error(tmp_path): + path = tmp_path / "audit.yaml" + path.write_text("") + with pytest.raises(ValueError, match="must be a mapping"): + load_manifest(path) + + +def test_a_manifest_that_is_not_a_mapping_raises(tmp_path): + path = tmp_path / "audit.yaml" + path.write_text("a,b\n1,2\n") + with pytest.raises(ValueError, match="must be a mapping"): + load_manifest(path) + def test_unknown_target_method_fails_loudly(): spec = TargetSpec(column="x", method="not_a_real_method") df = pd.DataFrame({"x": [1, 2, 3]})