From 1bfb7016173e0c87c16fd1f1cd9522ae12c73a3e Mon Sep 17 00:00:00 2001 From: Speculator55005 <50082482+fas89@users.noreply.github.com> Date: Sat, 4 Apr 2026 23:10:15 +0200 Subject: [PATCH] fix: harden sdk public contract verification --- .github/workflows/ci.yml | 41 ++++++++- src/fluid_provider_sdk/base.py | 2 +- src/fluid_provider_sdk/capabilities.py | 5 +- tests/conftest.py | 10 +++ tests/test_contract_helper.py | 114 +++++++++++++++++++++++++ tests/test_core_types.py | 103 ++++++++++++++++++++++ tests/test_hooks_and_harness.py | 61 +++++++++++++ tests/test_public_api.py | 60 +++++++++++++ 8 files changed, 389 insertions(+), 7 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_contract_helper.py create mode 100644 tests/test_core_types.py create mode 100644 tests/test_hooks_and_harness.py create mode 100644 tests/test_public_api.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3651841..b9470a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,9 @@ jobs: - name: Install tools run: pip install ruff black - name: Ruff - run: ruff check src/ + run: ruff check src/ tests/ - name: Black - run: black --check src/ + run: black --check src/ tests/ test: name: Test (Python ${{ matrix.python-version }}) @@ -41,7 +41,9 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install package - run: pip install -e . + run: pip install -e . pytest + - name: Test suite + run: pytest -q - name: Import check run: python -c "from fluid_provider_sdk import BaseProvider, ApplyResult, ProviderError; print('OK')" - name: Type stub check @@ -60,8 +62,39 @@ jobs: pip install build twine python -m build twine check dist/* + - name: Verify packaged typed files and testing helpers + run: | + python - <<'PY' + import tarfile + import zipfile + from pathlib import Path + + dist = Path("dist") + wheels = sorted(dist.glob("*.whl")) + sdists = sorted(dist.glob("*.tar.gz")) + assert wheels, "No wheel built" + assert sdists, "No sdist built" + + with zipfile.ZipFile(wheels[-1]) as zf: + names = set(zf.namelist()) + assert any(name.endswith("fluid_provider_sdk/py.typed") for name in names), "py.typed missing from wheel" + assert any(name.endswith("fluid_provider_sdk/testing/__init__.py") for name in names), "testing package missing from wheel" + assert any(name.endswith("fluid_provider_sdk/testing/fixtures.py") for name in names), "fixtures missing from wheel" + + with tarfile.open(sdists[-1], "r:gz") as tf: + names = set(tf.getnames()) + assert any(name.endswith("src/fluid_provider_sdk/py.typed") for name in names), "py.typed missing from sdist" + assert any(name.endswith("src/fluid_provider_sdk/testing/__init__.py") for name in names), "testing package missing from sdist" + assert any(name.endswith("src/fluid_provider_sdk/testing/fixtures.py") for name in names), "fixtures missing from sdist" + PY - name: Smoke test run: | python -m venv /tmp/test-install /tmp/test-install/bin/pip install dist/*.whl - /tmp/test-install/bin/python -c "from fluid_provider_sdk import BaseProvider; print('OK')" + /tmp/test-install/bin/python - <<'PY' + import fluid_provider_sdk + import fluid_provider_sdk.testing + + print(fluid_provider_sdk.BaseProvider.__name__) + print(fluid_provider_sdk.testing.ProviderTestHarness.__name__) + PY diff --git a/src/fluid_provider_sdk/base.py b/src/fluid_provider_sdk/base.py index 39a7994..1eafa5f 100644 --- a/src/fluid_provider_sdk/base.py +++ b/src/fluid_provider_sdk/base.py @@ -103,7 +103,7 @@ def get_provider_info(cls): return ProviderMetadata( name=cls.name, display_name=cls.name.replace("_", " ").title(), - description="", + description=f"FLUID provider for {cls.name.replace('_', ' ').title()}", version="0.0.0", sdk_version=SDK_VERSION, author="Unknown", diff --git a/src/fluid_provider_sdk/capabilities.py b/src/fluid_provider_sdk/capabilities.py index 211e31c..055f9db 100644 --- a/src/fluid_provider_sdk/capabilities.py +++ b/src/fluid_provider_sdk/capabilities.py @@ -3,12 +3,13 @@ from __future__ import annotations +from collections.abc import Iterator, Mapping from dataclasses import dataclass, field from typing import Dict @dataclass -class ProviderCapabilities: +class ProviderCapabilities(Mapping[str, bool]): """Structured capability flags advertised by a provider. Providers return this from ``capabilities()`` instead of a raw dict. @@ -60,7 +61,7 @@ def __getitem__(self, key: str) -> bool: def __contains__(self, key: object) -> bool: return key in self._as_dict() - def __iter__(self): + def __iter__(self) -> Iterator[str]: return iter(self._as_dict()) def __len__(self) -> int: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c0d7d12 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" + +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) diff --git a/tests/test_contract_helper.py b/tests/test_contract_helper.py new file mode 100644 index 0000000..6a4e1b1 --- /dev/null +++ b/tests/test_contract_helper.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import pytest + +from fluid_provider_sdk import ContractHelper +from fluid_provider_sdk.testing import ( + AWS_CONTRACT, + GCP_CONTRACT, + LOCAL_CONTRACT, + SNOWFLAKE_CONTRACT, +) + + +@pytest.mark.parametrize( + ("contract", "expected"), + [ + ( + LOCAL_CONTRACT, + { + "id": "test.local_customer_360", + "name": "Customer 360 (Local Test)", + "expose_id": "customer_profiles", + "build_id": "customer_360_analysis", + "platform": "local", + "format": "csv", + "path": "runtime/out/customer_profiles.csv", + "consumes": 2, + }, + ), + ( + GCP_CONTRACT, + { + "id": "finance.bitcoin_prices_gcp", + "name": "Bitcoin Prices (GCP)", + "expose_id": "bitcoin_prices_table", + "build_id": "ingest_btc", + "platform": "gcp", + "format": "bigquery_table", + "path": None, + "consumes": 0, + }, + ), + ( + AWS_CONTRACT, + { + "id": "finance.bitcoin_prices_aws", + "name": "Bitcoin Prices (AWS)", + "expose_id": "bitcoin_prices_table", + "build_id": "ingest_btc", + "platform": "aws", + "format": "parquet", + "path": "crypto/bitcoin_prices/", + "consumes": 0, + }, + ), + ( + SNOWFLAKE_CONTRACT, + { + "id": "finance.bitcoin_prices_sf", + "name": "Bitcoin Prices (Snowflake)", + "expose_id": "bitcoin_prices", + "build_id": "ingest_btc", + "platform": None, + "format": "snowflake_table", + "path": None, + "consumes": 0, + }, + ), + ], +) +def test_contract_helper_parses_bundled_sample_contracts(contract, expected) -> None: + helper = ContractHelper(contract) + expose = helper.exposes()[0] + build = helper.primary_build() + + assert helper.id == expected["id"] + assert helper.name == expected["name"] + assert len(helper.exposes()) == 1 + assert len(helper.builds()) == 1 + assert len(helper.consumes()) == expected["consumes"] + assert expose.id == expected["expose_id"] + assert expose.platform == expected["platform"] + assert expose.format == expected["format"] + assert expose.path == expected["path"] + assert build is not None + assert build.id == expected["build_id"] + + +def test_contract_helper_supports_single_build_fallback() -> None: + helper = ContractHelper( + { + "id": "single.build", + "build": { + "engine": "sql", + "pattern": "embedded-logic", + "properties": {"sql": "select 1"}, + }, + } + ) + + builds = helper.builds() + + assert len(builds) == 1 + assert builds[0].id == "build_0" + assert builds[0].engine == "sql" + assert builds[0].sql == "select 1" + assert helper.primary_build() == builds[0] + + +def test_contract_helper_exposes_top_level_snowflake_binding_location() -> None: + helper = ContractHelper(SNOWFLAKE_CONTRACT) + + assert helper.binding_location == {"database": "PROD_DB", "schema": "FINANCE"} + assert helper.security["access_control"]["grants"][0]["role"] == "ANALYST" diff --git a/tests/test_core_types.py b/tests/test_core_types.py new file mode 100644 index 0000000..79ef12c --- /dev/null +++ b/tests/test_core_types.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import json +from collections.abc import Mapping + +from fluid_provider_sdk import ( + ApplyResult, + CostEstimate, + ProviderAction, + ProviderCapabilities, + ProviderMetadata, + validate_actions, +) + + +def test_provider_action_round_trips_through_dict() -> None: + action = ProviderAction( + op="create_table", + resource_type="table", + resource_id="customers", + params={"dataset": "analytics"}, + depends_on=["dataset.analytics"], + phase="expose", + idempotent=False, + description="Create the customer table", + tags={"team": "platform"}, + ) + + restored = ProviderAction.from_dict(action.to_dict()) + + assert restored == action + assert restored["op"] == "create_table" + assert "resource_id" in restored + + +def test_provider_action_from_dict_keeps_legacy_fields_in_params() -> None: + restored = ProviderAction.from_dict( + { + "op": "grant_access", + "resource_id": "grant.analytics", + "principal": "analyst", + } + ) + + assert restored.params["principal"] == "analyst" + + +def test_validate_actions_reports_missing_and_invalid_dependencies() -> None: + errors = validate_actions( + [ + ProviderAction(op="", resource_id=""), + ProviderAction(op="create", resource_id="dup"), + ProviderAction(op="update", resource_id="dup", depends_on=["missing"]), + ] + ) + + assert "Action [0] missing 'op'" in errors + assert "Action [0] (op='') missing 'resource_id'" in errors + assert "Duplicate resource_id: 'dup'" in errors + assert "Action 'dup' depends on unknown 'missing'" in errors + + +def test_apply_result_json_and_dict_compatibility_are_stable() -> None: + result = ApplyResult( + provider="demo", + applied=2, + failed=1, + duration_sec=1.25, + timestamp="2026-04-04T00:00:00Z", + results=[{"op": "create"}], + ) + + payload = json.loads(result.to_json()) + + assert payload["provider"] == "demo" + assert result["applied"] == 2 + assert result.get("missing", "fallback") == "fallback" + assert "failed" in result + + +def test_provider_capabilities_is_a_real_mapping() -> None: + capabilities = ProviderCapabilities(render=True, extra={"custom": True}) + + assert isinstance(capabilities, Mapping) + assert capabilities["planning"] is True + assert capabilities["render"] is True + assert capabilities["custom"] is True + assert list(capabilities.keys())[-1] == "custom" + + +def test_provider_metadata_defaults_and_to_dict_are_stable() -> None: + metadata = ProviderMetadata(name="my_provider") + + assert metadata.display_name == "My Provider" + assert metadata.to_dict()["name"] == "my_provider" + assert metadata.to_dict()["display_name"] == "My Provider" + + +def test_cost_estimate_total_is_reflected_in_serialized_output() -> None: + estimate = CostEstimate(monthly=1.5, one_time=2.5, notes="demo") + + assert estimate.total() == 4.0 + assert estimate.to_dict()["total"] == 4.0 diff --git a/tests/test_hooks_and_harness.py b/tests/test_hooks_and_harness.py new file mode 100644 index 0000000..69a0ab9 --- /dev/null +++ b/tests/test_hooks_and_harness.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from fluid_provider_sdk import ( + ApplyResult, + BaseProvider, + ProviderHookSpec, + has_hook, + invoke_hook, +) +from fluid_provider_sdk.testing import LOCAL_CONTRACT, ProviderTestHarness + + +class DemoProvider(BaseProvider): + name = "demo_provider" + + def plan(self, contract): + return [{"op": "demo.create", "resource_id": contract.get("id", "demo")}] + + def apply(self, actions): + action_list = list(actions) + return ApplyResult( + provider=self.name, + applied=len(action_list), + failed=0, + duration_sec=0.01, + timestamp="2026-04-04T00:00:00Z", + results=[{"op": action["op"], "status": "ok"} for action in action_list], + ) + + +class TestDemoProviderHarness(ProviderTestHarness): + provider_class = DemoProvider + sample_contracts = [LOCAL_CONTRACT] + skip_apply = False + + +class OverriddenHook(ProviderHookSpec): + def pre_plan(self, contract): + return {"changed": True, **contract} + + +class FailingHook(ProviderHookSpec): + def pre_plan(self, contract): + raise RuntimeError("boom") + + +def test_invoke_hook_passes_through_when_missing() -> None: + contract = {"id": "demo"} + + assert invoke_hook(object(), "pre_plan", contract) == contract + + +def test_invoke_hook_returns_pass_through_on_hook_error() -> None: + contract = {"id": "demo"} + + assert invoke_hook(FailingHook(), "pre_plan", contract) == contract + + +def test_has_hook_detects_real_overrides() -> None: + assert has_hook(ProviderHookSpec(), "pre_plan") is False + assert has_hook(OverriddenHook(), "pre_plan") is True diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..10fa7fc --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from pathlib import Path + +import fluid_provider_sdk as sdk +from fluid_provider_sdk.testing import ( + AWS_CONTRACT, + GCP_CONTRACT, + LOCAL_CONTRACT, + SAMPLE_CONTRACTS, + SNOWFLAKE_CONTRACT, + ProviderTestHarness, +) + +EXPECTED_PUBLIC_EXPORTS = { + "ApplyResult", + "BaseProvider", + "BuildSpec", + "ColumnSpec", + "ConsumeSpec", + "ContractHelper", + "CostEstimate", + "ExposeSpec", + "PlanAction", + "ProviderAction", + "ProviderCapabilities", + "ProviderError", + "ProviderHookSpec", + "ProviderInternalError", + "ProviderMetadata", + "SDK_VERSION", + "has_hook", + "invoke_hook", + "validate_actions", +} + + +def test_top_level_public_exports_match_sdk_contract() -> None: + assert set(sdk.__all__) == EXPECTED_PUBLIC_EXPORTS + for name in EXPECTED_PUBLIC_EXPORTS: + assert getattr(sdk, name) is not None + + +def test_sdk_version_aliases_stay_aligned() -> None: + assert sdk.__version__ == sdk.SDK_VERSION + + +def test_py_typed_exists_next_to_package_init() -> None: + package_dir = Path(sdk.__file__).resolve().parent + assert (package_dir / "py.typed").exists() + + +def test_testing_exports_expose_sample_contracts() -> None: + assert ProviderTestHarness.__name__ == "ProviderTestHarness" + assert [contract["id"] for contract in SAMPLE_CONTRACTS] == [ + LOCAL_CONTRACT["id"], + GCP_CONTRACT["id"], + AWS_CONTRACT["id"], + SNOWFLAKE_CONTRACT["id"], + ]