Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions tests/test_env_declaration.py
Original file line number Diff line number Diff line change
@@ -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"}
51 changes: 51 additions & 0 deletions views_postprocessing/unfao/appwrite_env.py
Original file line number Diff line number Diff line change
@@ -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)."
)
19 changes: 13 additions & 6 deletions views_postprocessing/unfao/managers/unfao.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"),
Expand Down Expand Up @@ -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"),
Expand All @@ -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"),
Expand Down
Loading