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
41 changes: 37 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }})
Expand All @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion src/fluid_provider_sdk/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions src/fluid_provider_sdk/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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))
114 changes: 114 additions & 0 deletions tests/test_contract_helper.py
Original file line number Diff line number Diff line change
@@ -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"
103 changes: 103 additions & 0 deletions tests/test_core_types.py
Original file line number Diff line number Diff line change
@@ -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
61 changes: 61 additions & 0 deletions tests/test_hooks_and_harness.py
Original file line number Diff line number Diff line change
@@ -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
Loading