diff --git a/src/sources/_base.py b/src/sources/_base.py index a4d411e0..dad940da 100644 --- a/src/sources/_base.py +++ b/src/sources/_base.py @@ -33,9 +33,18 @@ class BaseSource(ABC): source_type: ClassVar[SourceType] source_intro: ClassVar[str] resolution_criteria: ClassVar[str] - nullified_questions: ClassVar[list[NullifiedQuestion]] = [] + nullified_questions: ClassVar[list[NullifiedQuestion]] resolution_schema: ClassVar[type] = ResolutionFrame + # Source-specific metadata keys this source needs to even run, beyond the globally required set. + # Declared on the concrete subclass (e.g. yfinance -> {"ticker_renames"}) so a missing/typo'd key + # in SOURCE_METADATA fails loudly at class-definition time rather than late at runtime. + additional_required_metadata_keys: ClassVar[set[str]] = set() + + # Test-only escape hatch. Every production source must have a SOURCE_METADATA entry; test stubs + # that exercise BaseSource directly set this True to opt out of that requirement. + _allow_missing_metadata: ClassVar[bool] = False + def __init__(self) -> None: """Initialize with empty hash mapping.""" self.hash_mapping: dict[str, dict] = {} @@ -67,17 +76,26 @@ def __init_subclass__(cls, **kwargs): raise TypeError(f"Concrete source {cls.__name__} must define ClassVar '{attr}'") # Auto-populate from metadata - _REQUIRED_METADATA_KEYS = {"source_type", "source_intro", "resolution_criteria"} + _REQUIRED_METADATA_KEYS = { + "source_type", + "source_intro", + "resolution_criteria", + "nullified_questions", + } name = getattr(cls, "name", None) - if name and name in SOURCE_METADATA: - meta = SOURCE_METADATA[name] - missing = _REQUIRED_METADATA_KEYS - meta.keys() - if missing: - raise TypeError( - f"SOURCE_METADATA['{name}'] missing required keys: {sorted(missing)}" - ) - for key, value in meta.items(): - setattr(cls, key, value) + if name not in SOURCE_METADATA: + if cls._allow_missing_metadata: + return + raise TypeError( + f"Concrete source {cls.__name__} (name={name!r}) has no entry in SOURCE_METADATA. " + "Add one in sources/_metadata.py." + ) + meta = SOURCE_METADATA[name] + missing = (_REQUIRED_METADATA_KEYS | cls.additional_required_metadata_keys) - meta.keys() + if missing: + raise TypeError(f"SOURCE_METADATA['{name}'] missing required keys: {sorted(missing)}") + for key, value in meta.items(): + setattr(cls, key, value) # ------------------------------------------------------------------ # Public resolve interface diff --git a/src/sources/_metadata.py b/src/sources/_metadata.py index 2bddddfe..4a25d151 100644 --- a/src/sources/_metadata.py +++ b/src/sources/_metadata.py @@ -21,6 +21,7 @@ "resolution_criteria": ( "Resolves to the value calculated from the ACLED dataset once the data is published." ), + "nullified_questions": [], }, "dbnomics": { "source_type": SourceType.DATASET, @@ -32,6 +33,7 @@ "this data will resolve." ), "resolution_criteria": "Resolves to the value found at {url} once the data is published.", + "nullified_questions": [], }, "fred": { "source_type": SourceType.DATASET, @@ -61,6 +63,7 @@ "resolve as 'Yes'." ), "resolution_criteria": "Resolves to the outcome of the question found at {url}.", + "nullified_questions": [], }, "manifold": { "source_type": SourceType.MARKET, @@ -71,6 +74,7 @@ "resolve as 'Yes'." ), "resolution_criteria": "Resolves to the outcome of the question found at {url}.", + "nullified_questions": [], }, "metaculus": { "source_type": SourceType.MARKET, @@ -81,6 +85,7 @@ "resolve as 'Yes'." ), "resolution_criteria": "Resolves to the outcome of the question found at {url}.", + "nullified_questions": [], }, "polymarket": { "source_type": SourceType.MARKET, diff --git a/src/sources/fred.py b/src/sources/fred.py index 7baac88c..b2f788d1 100644 --- a/src/sources/fred.py +++ b/src/sources/fred.py @@ -4,8 +4,6 @@ from typing import ClassVar -from _fb_types import NullifiedQuestion - from ._dataset import DatasetSource from ._metadata import SOURCE_METADATA @@ -20,9 +18,6 @@ class FredSource(DatasetSource): """Federal Reserve Economic Data source.""" name: ClassVar[str] = "fred" - nullified_questions: ClassVar[list[NullifiedQuestion]] = SOURCE_METADATA["fred"][ - "nullified_questions" - ] def fetch(self, **kwargs): """Fetch FRED data from external API.""" diff --git a/src/sources/yfinance.py b/src/sources/yfinance.py index f430eed7..a0d56958 100644 --- a/src/sources/yfinance.py +++ b/src/sources/yfinance.py @@ -27,6 +27,7 @@ class YfinanceSource(DatasetSource): """Yahoo Finance financial data source.""" name: ClassVar[str] = "yfinance" + additional_required_metadata_keys: ClassVar[set[str]] = {"ticker_renames"} # Pinned at the start of fetch()/update() so every downstream helper (via self.get_date_today()) # observes one consistent date for the whole run, even if it straddles midnight. diff --git a/src/tests/test_base_source.py b/src/tests/test_base_source.py index e58dd316..2b795961 100644 --- a/src/tests/test_base_source.py +++ b/src/tests/test_base_source.py @@ -8,6 +8,7 @@ from _fb_types import NullifiedQuestion, SourceType from sources._base import BaseSource +from sources._metadata import SOURCE_METADATA from tests.conftest import make_forecast_df, make_question_df # --------------------------------------------------------------------------- @@ -20,6 +21,8 @@ class _StubSource(BaseSource): name = "stub" source_type = SourceType.DATASET + nullified_questions = [] + _allow_missing_metadata = True def _resolve(self, df, dfq, dfr): df["resolved_to"] = 1.0 @@ -38,6 +41,7 @@ class _StubSourceWithNullified(BaseSource): name = "stub_null" source_type = SourceType.DATASET + _allow_missing_metadata = True nullified_questions = [ NullifiedQuestion(id="null_q1", nullification_start_date=date(2024, 6, 1)), NullifiedQuestion(id="null_q2", nullification_start_date=date(2025, 1, 1)), @@ -72,7 +76,18 @@ class _BadSource(BaseSource): def _resolve(self, df, dfq, dfr): return df - def test_valid_concrete_source_ok(self): + def test_valid_concrete_source_ok(self, monkeypatch): + monkeypatch.setitem( + SOURCE_METADATA, + "good", + { + "source_type": SourceType.MARKET, + "source_intro": "intro", + "resolution_criteria": "criteria", + "nullified_questions": [], + }, + ) + # Should not raise class _GoodSource(BaseSource): name = "good" @@ -81,6 +96,88 @@ class _GoodSource(BaseSource): def _resolve(self, df, dfq, dfr): return df + def test_missing_metadata_entry_raises(self): + # A concrete source whose name has no SOURCE_METADATA entry (e.g. a typo) must fail at + # class-definition time rather than later at runtime. + with pytest.raises(TypeError, match="no entry in SOURCE_METADATA"): + + class _NoMetadataSource(BaseSource): + name = "definitely_not_a_real_source" + source_type = SourceType.DATASET + + def _resolve(self, df, dfq, dfr): + return df + + def test_missing_required_nullified_questions_raises(self, monkeypatch): + # nullified_questions is globally required; a source whose metadata omits it must fail at + # class-definition time, not silently default to "nothing nullified". + monkeypatch.setitem( + SOURCE_METADATA, + "fake_no_nullified", + { + "source_type": SourceType.DATASET, + "source_intro": "intro", + "resolution_criteria": "criteria", + }, + ) + with pytest.raises(TypeError, match="nullified_questions"): + + class _BadNullified(BaseSource): + name = "fake_no_nullified" + source_type = SourceType.DATASET + + def _resolve(self, df, dfq, dfr): + return df + + def test_missing_source_specific_required_key_raises(self, monkeypatch): + # A source-specific key declared via additional_required_metadata_keys but absent from SOURCE_METADATA + # must fail loudly at class-definition time. + monkeypatch.setitem( + SOURCE_METADATA, + "fake_missing_specific", + { + "source_type": SourceType.DATASET, + "source_intro": "intro", + "resolution_criteria": "criteria", + "nullified_questions": [], + }, + ) + with pytest.raises(TypeError, match="ticker_renames"): + + class _BadSpecific(BaseSource): + name = "fake_missing_specific" + source_type = SourceType.DATASET + additional_required_metadata_keys = {"ticker_renames"} + + def _resolve(self, df, dfq, dfr): + return df + + def test_additional_required_metadata_keys_satisfied_populates(self, monkeypatch): + # When a declared source-specific key is present, no raise and it's set on the class. + renames = [{"original_ticker": "FI", "replacement_ticker": "FISV"}] + monkeypatch.setitem( + SOURCE_METADATA, + "fake_with_specific", + { + "source_type": SourceType.DATASET, + "source_intro": "intro", + "resolution_criteria": "criteria", + "nullified_questions": [], + "ticker_renames": renames, + }, + ) + + class _GoodSpecific(BaseSource): + name = "fake_with_specific" + source_type = SourceType.DATASET + additional_required_metadata_keys = {"ticker_renames"} + + def _resolve(self, df, dfq, dfr): + return df + + assert _GoodSpecific.ticker_renames == renames + assert _GoodSpecific.nullified_questions == [] + # --------------------------------------------------------------------------- # _is_combo