From 23630833366358f82b37c417991843ec97290e1e Mon Sep 17 00:00:00 2001 From: Andreacnt Date: Thu, 22 Oct 2026 22:51:49 +0200 Subject: [PATCH] fix(config): errore su chiavi sconosciute in raw.sources[] (#514) --- tests/test_config_loading.py | 28 ++++++++++++++++++++++++++++ toolkit/core/config.py | 26 ++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/tests/test_config_loading.py b/tests/test_config_loading.py index bd412a3..57bfb31 100644 --- a/tests/test_config_loading.py +++ b/tests/test_config_loading.py @@ -813,3 +813,31 @@ def test_di_candidate_real_config_loads_with_latest_default(): logger=None, ) assert params[0] == "latest" + + +@pytest.mark.contract +def test_raw_source_unknown_key_rejected(tmp_path: Path): + """Chiavi sconosciute in raw.sources[] falliscono invece di essere ignorate (#514).""" + yml = tmp_path / "dataset.yml" + _yml( + yml, + years=[2024], + raw={"sources": [{"name": "src", "type": "http_file", "args": {"url": "x"}, "extractpr": {"type": "unzip_first_csv"}}]}, + ) + + with pytest.raises(ValueError, match=r"raw\.sources\[0\]: unknown key\(s\) \['extractpr'\]"): + load_config(yml) + + +@pytest.mark.contract +def test_raw_source_client_unknown_key_rejected(tmp_path: Path): + """Anche le chiavi sconosciute dentro client: falliscono con il path completo.""" + yml = tmp_path / "dataset.yml" + _yml( + yml, + years=[2024], + raw={"sources": [{"name": "src", "type": "http_file", "args": {"url": "x"}, "client": {"retry": 3}}]}, + ) + + with pytest.raises(ValueError, match=r"raw\.sources\[0\]\.client: unknown key\(s\) \['retry'\]"): + load_config(yml) diff --git a/toolkit/core/config.py b/toolkit/core/config.py index aedb6e7..c7bcb92 100644 --- a/toolkit/core/config.py +++ b/toolkit/core/config.py @@ -81,6 +81,15 @@ def _ensure_int_list(value: Any, field_name: str) -> list[int]: raise ValueError(f"{field_name} must be an int or a list of ints") +def reject_unknown_keys(value: dict, known: set[str], field_name: str) -> None: + """Fallisce su chiavi non riconosciute invece di scartarle in silenzio.""" + unknown = sorted(set(value) - known) + if unknown: + raise ValueError( + f"{field_name}: unknown key(s) {unknown}; allowed keys: {sorted(known)}" + ) + + # --------------------------------------------------------------------------- # Config dataclasses # --------------------------------------------------------------------------- @@ -474,8 +483,19 @@ class RawSourceConfig: extractor: dict | None = None @staticmethod - def from_dict(d: dict) -> RawSourceConfig: + def from_dict(d: dict, field_name: str = "raw.sources[]") -> RawSourceConfig: + reject_unknown_keys( + d, + {"name", "type", "year", "args", "primary", "inject_column", "client", "extractor"}, + field_name, + ) client = d.get("client") or {} + if isinstance(client, dict): + reject_unknown_keys( + client, {"timeout", "retries", "user_agent", "headers"}, f"{field_name}.client" + ) + else: + raise ValueError(f"{field_name}.client must be a mapping") return RawSourceConfig( name=d.get("name"), type=d.get("type", "http_file"), @@ -552,7 +572,9 @@ def from_dict(d: dict | None) -> RawConfig: if not d: return RawConfig() sources = [ - RawSourceConfig.from_dict(s) for s in (d.get("sources") or []) if isinstance(s, dict) + RawSourceConfig.from_dict(s, f"raw.sources[{i}]") + for i, s in enumerate(d.get("sources") or []) + if isinstance(s, dict) ] known = {"sources", "output_policy", "extractor"} extra = {k: v for k, v in d.items() if k not in known}