diff --git a/tests/test_env_declaration.py b/tests/test_env_declaration.py new file mode 100644 index 0000000..8eee47b --- /dev/null +++ b/tests/test_env_declaration.py @@ -0,0 +1,103 @@ +"""Guards for þing-01 P1 (#134): the implicit ensemble-dotenv borrow is dead, +and the manager's entry validation is fail-loud, naming every missing variable. + +The borrow (`load_dotenv(ensemble_path_manager.dotenv)` at the old unfao.py:180) +was the runtime edge of the platform's copy-chain (þing-01 sáttmál S6): this +repo's manager reached inside views-models' checkout for its environment. The +verdict retired it — the launcher declares its env sourcing (views-models M3, +merged), and this package only validates (verdict D6). + +Imports only `unfao.appwrite_env` (dependency-light by design); the manager +module itself needs views-pipeline-core, absent in test environments, so the +manager-side facts are pinned by source scan — the repo's standing pattern. +""" + +import re +from pathlib import Path + +import pytest + +from views_postprocessing.unfao import appwrite_env + + +_MANAGER_SOURCE = ( + Path(__file__).resolve().parent.parent + / "views_postprocessing" + / "unfao" + / "managers" + / "unfao.py" +) + + +def test_the_dotenv_borrow_is_dead(): + # Prose may mention the dead borrow; importing or calling it may not. + text = _MANAGER_SOURCE.read_text() + for token in ("load_dotenv", "from dotenv", "import dotenv", "find_dotenv"): + assert token not in text, ( + f"{token!r} is back in the manager (þing-01 #134 killed the borrow): " + "the launcher declares the environment; this package must never load one." + ) + + +def test_missing_env_raises_naming_every_missing_variable(monkeypatch): + names = appwrite_env.CONNECTION_ENV + appwrite_env.PROD_FORECASTS_ENV + for name in names: + monkeypatch.delenv(name, raising=False) + with pytest.raises(EnvironmentError) as excinfo: + appwrite_env.assert_env_declared(names, store="production_forecasts datastore") + message = str(excinfo.value) + for name in names: + assert name in message # ALL missing names, not just the first + + +def test_partially_missing_env_names_only_the_missing(monkeypatch): + names = appwrite_env.CONNECTION_ENV + appwrite_env.UNFAO_ENV + for name in names: + monkeypatch.setenv(name, "set") + monkeypatch.delenv("APPWRITE_UNFAO_BUCKET_ID") + with pytest.raises(EnvironmentError) as excinfo: + appwrite_env.assert_env_declared(names, store="unfao_bucket datastore") + message = str(excinfo.value) + assert "APPWRITE_UNFAO_BUCKET_ID" in message + assert "APPWRITE_ENDPOINT" not in message + + +def test_complete_env_passes(monkeypatch): + names = appwrite_env.CONNECTION_ENV + appwrite_env.UNFAO_ENV + for name in names: + monkeypatch.setenv(name, "set") + appwrite_env.assert_env_declared(names, store="unfao_bucket datastore") + + +def test_empty_string_counts_as_missing(monkeypatch): + # A name exported but empty is not an assembled environment — fail loud, + # never hand AppwriteConfig an empty coordinate that half-works. + for name in appwrite_env.CONNECTION_ENV: + monkeypatch.setenv(name, "set") + monkeypatch.setenv("APPWRITE_DATASTORE_API_KEY", "") + with pytest.raises(EnvironmentError, match="APPWRITE_DATASTORE_API_KEY"): + appwrite_env.assert_env_declared( + appwrite_env.CONNECTION_ENV, store="production_forecasts datastore" + ) + + +def test_declared_names_match_the_manager_reads(): + # Declaration and use must not drift: every APPWRITE_* name the manager + # actually reads is declared, and both store paths validate before building. + text = _MANAGER_SOURCE.read_text() + read_names = set(re.findall(r'os\.getenv\("(APPWRITE_[A-Z_]+)"\)', text)) + declared = set( + appwrite_env.CONNECTION_ENV + appwrite_env.PROD_FORECASTS_ENV + appwrite_env.UNFAO_ENV + ) + assert read_names == declared + assert text.count("appwrite_env.assert_env_declared(") == 3 # both stores + legacy save + + +def test_secret_env_names_follow_the_platform_naming_rule(): + # PLATFORM-001 D3: suffix _API_KEY/_PASSWORD/_TOKEN ⇒ secret. Exactly one + # declared name is a secret; every other declared name is a coordinate. + declared = set( + appwrite_env.CONNECTION_ENV + appwrite_env.PROD_FORECASTS_ENV + appwrite_env.UNFAO_ENV + ) + secrets = {n for n in declared if n.endswith(("_API_KEY", "_PASSWORD", "_TOKEN"))} + assert secrets == {"APPWRITE_DATASTORE_API_KEY"} diff --git a/views_postprocessing/unfao/appwrite_env.py b/views_postprocessing/unfao/appwrite_env.py new file mode 100644 index 0000000..6e2724e --- /dev/null +++ b/views_postprocessing/unfao/appwrite_env.py @@ -0,0 +1,51 @@ +"""The Appwrite-seam environment this package requires, DECLARED (þing-01 P1, #134). + +Names follow the PLATFORM-001 coordinate registry (connection + target classes) +plus the one operator-issued secret slot. The LAUNCHER assembles the environment +(views-models M3: run.sh reads the owned registry; the secret stays the operator +slot) — this package loads no dotenv and validates fail-loud instead (verdict D6). +The old implicit borrow (`load_dotenv(ensemble_path_manager.dotenv)`) was the +runtime edge of the platform's copy-chain (þing-01 sáttmál S6) and is dead. + +Deliberately dependency-light: no pipeline-core imports, so the declaration is +importable (and testable) everywhere. +""" + +from __future__ import annotations + +import os + +CONNECTION_ENV = ( + "APPWRITE_ENDPOINT", + "APPWRITE_DATASTORE_PROJECT_ID", + "APPWRITE_DATASTORE_API_KEY", # secret slot — the value must never be logged +) +PROD_FORECASTS_ENV = ( + "APPWRITE_PROD_FORECASTS_BUCKET_ID", + "APPWRITE_PROD_FORECASTS_BUCKET_NAME", + "APPWRITE_PROD_FORECASTS_COLLECTION_ID", + "APPWRITE_PROD_FORECASTS_COLLECTION_NAME", + "APPWRITE_METADATA_DATABASE_ID", + "APPWRITE_METADATA_DATABASE_NAME", +) +UNFAO_ENV = ( + "APPWRITE_UNFAO_BUCKET_ID", + "APPWRITE_UNFAO_BUCKET_NAME", + "APPWRITE_UNFAO_COLLECTION_ID", + "APPWRITE_UNFAO_COLLECTION_NAME", + "APPWRITE_METADATA_DATABASE_ID", + "APPWRITE_METADATA_DATABASE_NAME", +) + + +def assert_env_declared(names: tuple, *, store: str) -> None: + """Entry validation (þing-01 D6): every required name resolved and non-empty, + or raise naming ALL missing variables — never a partial config that half-works.""" + missing = [name for name in names if not os.getenv(name)] + if missing: + raise EnvironmentError( + f"{store}: the launcher did not assemble the required environment — " + f"missing {missing}. Coordinates come from the PLATFORM-001 registry " + "(views-models run.sh declares its sourcing); the secret is the " + "operator slot. This package no longer loads any dotenv (#134)." + ) diff --git a/views_postprocessing/unfao/managers/unfao.py b/views_postprocessing/unfao/managers/unfao.py index 8d3ef80..ef3f22c 100644 --- a/views_postprocessing/unfao/managers/unfao.py +++ b/views_postprocessing/unfao/managers/unfao.py @@ -15,11 +15,10 @@ import io from datetime import datetime import os -from dotenv import load_dotenv from views_postprocessing.unfao.enrichment import _DEFAULT_LOOKUP, GaulLookupEnricher from views_postprocessing.unfao.gaul_schema import METADATA_COLS from views_pipeline_core.modules.dataloaders.datafactory_contract import declared_data_format -from views_postprocessing.unfao import extraction, frame_extraction, historical, product, source_metadata +from views_postprocessing.unfao import appwrite_env, extraction, frame_extraction, historical, product, source_metadata from views_postprocessing.unfao.wire import sink as wire_sink from views_postprocessing.unfao.wire import source_selection from views_postprocessing.delivery import coverage, identity, observed_range, provenance @@ -148,7 +147,8 @@ def _read_historical_data(self): def _prod_forecasts_datastore(self, *, name_scoped: bool = True) -> DatastoreModule: """The shared internal store (ADR-013's 'shared shelf'), configured from the - declared ensemble's environment. Used by both the legacy and contract reads. + launcher-assembled environment (validated fail-loud; þing-01 #134 — no dotenv + is loaded here). Used by both the legacy and contract reads. ``name_scoped`` controls pipeline-core's automatic ``name == model_name`` query filter (``DatastoreModule.get_predictions_by_metadata`` injects it on every @@ -175,10 +175,11 @@ def _prod_forecasts_datastore(self, *, name_scoped: bool = True) -> DatastoreMod err_msg = "level must be defined in the ensemble configurations (e.g, pgm, cm). Cannot proceed." logger.error(err_msg) raise ValueError(err_msg) - - # Force it to the correct .env just to be safe - load_dotenv(dotenv_path=str(self.ensemble_path_manager.dotenv)) + appwrite_env.assert_env_declared( + appwrite_env.CONNECTION_ENV + appwrite_env.PROD_FORECASTS_ENV, + store="production_forecasts datastore", + ) appwrite_config = AppwriteConfig( path_manager=self.ensemble_path_manager, endpoint=os.getenv("APPWRITE_ENDPOINT"), @@ -496,6 +497,9 @@ def _unfao_datastore(self) -> DatastoreModule: return DatastoreModule(appwrite_file_manager_config=self._unfao_appwrite_config()) def _unfao_appwrite_config(self) -> AppwriteConfig: + appwrite_env.assert_env_declared( + appwrite_env.CONNECTION_ENV + appwrite_env.UNFAO_ENV, store="unfao_bucket datastore" + ) return AppwriteConfig( path_manager=self._model_path, endpoint=os.getenv("APPWRITE_ENDPOINT"), @@ -521,6 +525,9 @@ def _save(self) -> list: logger.error(err_msg) raise ValueError(err_msg) + appwrite_env.assert_env_declared( + appwrite_env.CONNECTION_ENV + appwrite_env.UNFAO_ENV, store="unfao_bucket datastore" + ) unfao_appwrite_config = AppwriteConfig( path_manager=self._model_path, endpoint=os.getenv("APPWRITE_ENDPOINT"),