From 94af4764ad5ed5822deca6251c0c136891a82291 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Thu, 22 Oct 2026 13:26:03 +0530 Subject: [PATCH 1/2] feat(cbc): add get_feature_statuses and get_feature_status to CBCClient Add FeatureStatus and FeatureStatuses models, two new client methods (get_feature_statuses, get_feature_status), matching unit tests, BDD integration scenarios, and user-guide coverage. --- src/sap_cloud_sdk/cbc/__init__.py | 5 + src/sap_cloud_sdk/cbc/_models.py | 48 +++++++ src/sap_cloud_sdk/cbc/client.py | 53 ++++++++ src/sap_cloud_sdk/cbc/user-guide.md | 40 +++++- src/sap_cloud_sdk/core/telemetry/operation.py | 2 + tests/cbc/integration/cbc.feature | 12 ++ tests/cbc/integration/test_e2e_bdd.py | 51 +++++++- tests/cbc/unit/test_client.py | 118 ++++++++++++++++++ tests/cbc/unit/test_models.py | 68 ++++++++++ tests/core/unit/telemetry/test_operation.py | 4 +- 10 files changed, 397 insertions(+), 4 deletions(-) diff --git a/src/sap_cloud_sdk/cbc/__init__.py b/src/sap_cloud_sdk/cbc/__init__.py index 58ac0177..156abb21 100644 --- a/src/sap_cloud_sdk/cbc/__init__.py +++ b/src/sap_cloud_sdk/cbc/__init__.py @@ -53,6 +53,8 @@ ConsumptionVersion, ConsumptionVersions, EntityData, + FeatureStatus, + FeatureStatuses, ) @@ -74,6 +76,9 @@ # models — consumption versions "ConsumptionVersion", "ConsumptionVersions", + # models — feature status + "FeatureStatus", + "FeatureStatuses", # models — entities "EntityData", "ConfigurationEntity", diff --git a/src/sap_cloud_sdk/cbc/_models.py b/src/sap_cloud_sdk/cbc/_models.py index 672c8df5..5f09385e 100644 --- a/src/sap_cloud_sdk/cbc/_models.py +++ b/src/sap_cloud_sdk/cbc/_models.py @@ -67,6 +67,54 @@ def latest(self) -> ConsumptionVersion | None: return self.items[-1] +# --------------------------------------------------------------------------- +# Feature status models +# --------------------------------------------------------------------------- + + +class FeatureStatus(_FrozenModel): + """Activation status of one AI feature for an app tenant. + + Attributes: + feature_id: Feature identifier (e.g. ``"ai_assisted_supplier_bid_analysis"``). + enabled: Whether the feature is enabled for the tenant. + """ + + feature_id: str = Field(alias="featureId") + enabled: bool = False + + def is_enabled(self) -> bool: + """Return whether this feature is enabled.""" + return self.enabled + + +class FeatureStatuses(_FrozenModel): + """Activation status of all AI features for an app tenant. + + Attributes: + features: List of :class:`FeatureStatus` objects. + """ + + features: list[FeatureStatus] + + def is_enabled(self, feature_id: str) -> bool: + """Return ``True`` if the feature is enabled, ``False`` if disabled or absent. + + Args: + feature_id: Feature identifier to look up. + """ + feature = next((f for f in self.features if f.feature_id == feature_id), None) + return feature.is_enabled() if feature is not None else False + + def get(self, feature_id: str) -> FeatureStatus | None: + """Return the :class:`FeatureStatus` for ``feature_id``, or ``None`` if absent. + + Args: + feature_id: Feature identifier to look up. + """ + return next((f for f in self.features if f.feature_id == feature_id), None) + + # --------------------------------------------------------------------------- # Entity models # --------------------------------------------------------------------------- diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index fa7ceea5..bdb62516 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -44,6 +44,8 @@ ConfigurationObjectList, ConsumptionVersions, EntityData, + FeatureStatus, + FeatureStatuses, ) from sap_cloud_sdk.cbc.config import CBCConfig from sap_cloud_sdk.cbc.exceptions import ( @@ -126,6 +128,7 @@ class _ApiPaths: """API path configuration for a :class:`CBCClient` instance.""" configurations_path: str + features_path: str # --------------------------------------------------------------------------- @@ -181,6 +184,7 @@ def __init__( self._app_tenant_id = app_tenant_id self._config = _ApiPaths( configurations_path="/configuration/v1", + features_path="/feature/v1", ) self._ssl_factory = ssl_context self._lock = threading.Lock() @@ -394,6 +398,55 @@ def get_entity_data( entity_id, ).data + @record_metrics(Module.CBC, Operation.CBC_GET_FEATURE_STATUSES) + def get_feature_statuses(self) -> FeatureStatuses: + """Return the activation status of every AI feature for the configured tenant. + + Returns: + :class:`FeatureStatuses` with one :class:`FeatureStatus` per feature. + + Raises: + CBCClientError: On 4xx responses. + CBCServerError: On 5xx responses. + CBCNetworkError: On connection failures. + """ + base_url = self._base_url() + app_tenant_id = self._resolve_app_tenant_id() + url = self._features_url(base_url, f"/features?appTenantId={app_tenant_id}") + return FeatureStatuses.model_validate(self._request("GET", url).json()) + + @record_metrics(Module.CBC, Operation.CBC_GET_FEATURE_STATUS) + def get_feature_status(self, feature_id: str) -> FeatureStatus | None: + """Return the activation status of a single AI feature for the configured tenant. + + Queries the feature endpoint filtered to both the tenant and the feature, + so the server returns only the matching entry. + + Args: + feature_id: Feature identifier (e.g. ``"ai_assisted_supplier_bid_analysis"``). + + Returns: + The matching :class:`FeatureStatus`, or ``None`` if the feature is not + found for the tenant. + + Raises: + CBCClientError: On 4xx responses. + CBCServerError: On 5xx responses. + CBCNetworkError: On connection failures. + """ + base_url = self._base_url() + app_tenant_id = self._resolve_app_tenant_id() + url = self._features_url( + base_url, + f"/features?appTenantId={app_tenant_id}&featureId={feature_id}", + ) + statuses = FeatureStatuses.model_validate(self._request("GET", url).json()) + return statuses.get(feature_id) + + def _features_url(self, base_url: str, path: str = "") -> str: + base = base_url.rstrip("/") + return f"{base}{self._config.features_path}{path}" + def _fetch_entity_data( self, base_url: str, diff --git a/src/sap_cloud_sdk/cbc/user-guide.md b/src/sap_cloud_sdk/cbc/user-guide.md index ca5fdbd4..88650dac 100644 --- a/src/sap_cloud_sdk/cbc/user-guide.md +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -140,6 +140,38 @@ modes = cbc_client.get_entity_data( ``` +## Feature status + +Alongside configuration, CBC tracks which AI features are enabled for an app +tenant. Query the current activation state with `get_feature_statuses`: + +```python +statuses = cbc_client.get_feature_statuses() + +if statuses.is_enabled("ai_assisted_supplier_bid_analysis"): + ... +``` + +`is_enabled(feature_id)` returns `False` for a feature that is disabled or not +present for the tenant. To inspect a feature directly, use `get`: + +```python +feature = statuses.get("ai_assisted_tco_analysis") # FeatureStatus | None +if feature is not None: + print(feature.feature_id, feature.enabled) +``` + +To fetch a single feature without pulling the whole list, use +`get_feature_status`, which filters server-side and returns one `FeatureStatus` +(or `None` if the tenant has no entry for it): + +```python +feature = cbc_client.get_feature_status("ai_assisted_lead_routing") +if feature is not None and feature.is_enabled(): + ... +``` + + ## Error handling | Exception | When | @@ -283,7 +315,7 @@ client = cbc.create_client( `create_client` returns a concrete `CBCClient`. For tests, inject a mock `httpx.Client` when constructing one directly, or substitute your own stub object -that implements the three public methods: +that implements the public methods: ```python from sap_cloud_sdk import cbc @@ -302,6 +334,12 @@ class StubCBCClient: self, configuration_object_id, entity_id, consumption_version=None ): ... + def get_feature_statuses(self): + return cbc.FeatureStatuses(features=[]) + + def get_feature_status(self, feature_id): + return None + def test_my_service(): service = MyService(cbc_client=StubCBCClient()) diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index 4ae1893d..78a692dd 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -143,6 +143,8 @@ class Operation(str, Enum): CBC_GET_CONSUMPTION_VERSIONS = "get_consumption_versions" CBC_GET_CONFIGURATION = "get_configuration" CBC_GET_ENTITY_DATA = "get_entity_data" + CBC_GET_FEATURE_STATUSES = "get_feature_statuses" + CBC_GET_FEATURE_STATUS = "get_feature_status" # Bootstrap Operations BOOTSTRAP = "bootstrap" diff --git a/tests/cbc/integration/cbc.feature b/tests/cbc/integration/cbc.feature index fbbae6b4..54253d9e 100644 --- a/tests/cbc/integration/cbc.feature +++ b/tests/cbc/integration/cbc.feature @@ -34,3 +34,15 @@ Feature: CBC (Central Business Configuration) Integration Scenario: Fetch a single entity directly returns EntityData When I call get_entity_data for the first entity in the first config object Then the result should be EntityData accessible as a list or object + + # ── Feature status ─────────────────────────────────────────────────────────── + + Scenario: Fetch feature statuses returns FeatureStatuses + When I call get_feature_statuses + Then the result should be FeatureStatuses + And every feature should have a non-empty feature_id + + Scenario: Look up a single feature status by id + When I call get_feature_statuses + And I call get_feature_status for the first feature id + Then the result should be a FeatureStatus for the requested feature id diff --git a/tests/cbc/integration/test_e2e_bdd.py b/tests/cbc/integration/test_e2e_bdd.py index 99d40c7e..c66e2828 100644 --- a/tests/cbc/integration/test_e2e_bdd.py +++ b/tests/cbc/integration/test_e2e_bdd.py @@ -20,7 +20,13 @@ import pytest from pytest_bdd import given, scenario, then, when -from sap_cloud_sdk.cbc import ConfigurationData, ConsumptionVersions, EntityData +from sap_cloud_sdk.cbc import ( + ConfigurationData, + ConsumptionVersions, + EntityData, + FeatureStatus, + FeatureStatuses, +) from sap_cloud_sdk.cbc.client import CBCClient pytestmark = pytest.mark.integration @@ -72,6 +78,16 @@ def test_get_entity_data_returns_entity_data(): pass +@scenario("cbc.feature", "Fetch feature statuses returns FeatureStatuses") +def test_get_feature_statuses_returns_feature_statuses(): + pass + + +@scenario("cbc.feature", "Look up a single feature status by id") +def test_get_feature_status_matches_full_list(): + pass + + # -- Steps --------------------------------------------------------------------- @@ -171,3 +187,36 @@ def assert_single_entity_data(ctx: dict): except ValueError: result = data.as_object() assert result is not None + + +@when("I call get_feature_statuses") +def call_get_feature_statuses(ctx: dict, cbc_client: CBCClient): + ctx["feature_statuses"] = cbc_client.get_feature_statuses() + + +@then("the result should be FeatureStatuses") +def assert_feature_statuses_type(ctx: dict): + assert isinstance(ctx["feature_statuses"], FeatureStatuses) + + +@then("every feature should have a non-empty feature_id") +def assert_feature_ids_non_empty(ctx: dict): + statuses: FeatureStatuses = ctx["feature_statuses"] + for f in statuses.features: + assert f.feature_id, "feature_id missing on a FeatureStatus" + + +@when("I call get_feature_status for the first feature id") +def call_get_feature_status(ctx: dict, cbc_client: CBCClient): + statuses: FeatureStatuses = ctx["feature_statuses"] + if not statuses.features: + pytest.skip("tenant has no features — cannot test get_feature_status") + ctx["first_feature_id"] = statuses.features[0].feature_id + ctx["single_feature"] = cbc_client.get_feature_status(ctx["first_feature_id"]) + + +@then("the result should be a FeatureStatus for the requested feature id") +def assert_single_feature_matches(ctx: dict): + single: FeatureStatus | None = ctx["single_feature"] + assert single is not None + assert single.feature_id == ctx["first_feature_id"] diff --git a/tests/cbc/unit/test_client.py b/tests/cbc/unit/test_client.py index e5b529c1..e892f1c6 100644 --- a/tests/cbc/unit/test_client.py +++ b/tests/cbc/unit/test_client.py @@ -383,6 +383,124 @@ def test_get_entity_data_when_no_versions_exist_raises_cbc_client_error(self): client.get_entity_data("payment-config", "payment-mode") +# --------------------------------------------------------------------------- +# CBCClient — _features_url +# --------------------------------------------------------------------------- + + +class TestFeaturesUrl: + def test_features_url_joins_base_feature_path_and_suffix(self): + client, _ = _make_client("https://my-tenant.cbc.example.ondemand.com") + url = client._features_url( + "https://my-tenant.cbc.example.ondemand.com", "/features" + ) + assert url == ( + "https://my-tenant.cbc.example.ondemand.com/feature/v1/features" + ) + + +# --------------------------------------------------------------------------- +# CBCClient — get_feature_statuses +# --------------------------------------------------------------------------- + + +class TestGetFeatureStatuses: + def test_get_feature_statuses_returns_parsed_features(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + json_body={ + "features": [ + {"featureId": "ai_assisted_supplier_bid_analysis", "enabled": True}, + {"featureId": "ai_assisted_tco_analysis", "enabled": False}, + ] + } + ) + result = client.get_feature_statuses() + assert result.is_enabled("ai_assisted_supplier_bid_analysis") is True + assert result.is_enabled("ai_assisted_tco_analysis") is False + + def test_get_feature_statuses_sends_only_app_tenant_id(self): + client, mock_http = _make_client(app_tenant_id=_app_tid("tenant-9")) + mock_http.request.return_value = _mock_response(json_body={"features": []}) + client.get_feature_statuses() + called_url = mock_http.request.call_args.kwargs["url"] + assert called_url.endswith("/feature/v1/features?appTenantId=tenant-9") + + def test_get_feature_statuses_on_404_raises_cbc_client_error(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + status_code=404, + content=b'{"error":{"code":"NOT_FOUND","message":"not found"}}', + ) + with pytest.raises(CBCClientError): + client.get_feature_statuses() + + def test_get_feature_statuses_on_500_raises_cbc_server_error(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response(status_code=500, content=b"") + with pytest.raises(CBCServerError): + client.get_feature_statuses() + + def test_get_feature_statuses_on_connection_failure_raises_cbc_network_error(self): + client, mock_http = _make_client() + mock_http.request.side_effect = httpx.ConnectError("refused") + with pytest.raises(CBCNetworkError): + client.get_feature_statuses() + + +# --------------------------------------------------------------------------- +# CBCClient — get_feature_status +# --------------------------------------------------------------------------- + + +class TestGetFeatureStatus: + def test_get_feature_status_returns_matching_feature(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + json_body={ + "features": [ + {"featureId": "ai_assisted_supplier_bid_analysis", "enabled": True} + ] + } + ) + result = client.get_feature_status("ai_assisted_supplier_bid_analysis") + assert result is not None + assert result.feature_id == "ai_assisted_supplier_bid_analysis" + assert result.is_enabled() is True + + def test_get_feature_status_sends_both_query_params(self): + client, mock_http = _make_client(app_tenant_id=_app_tid("tenant-9")) + mock_http.request.return_value = _mock_response(json_body={"features": []}) + client.get_feature_status("ai_assisted_lead_routing") + called_url = mock_http.request.call_args.kwargs["url"] + assert called_url.endswith( + "/feature/v1/features" + "?appTenantId=tenant-9&featureId=ai_assisted_lead_routing" + ) + + def test_get_feature_status_returns_none_when_feature_absent(self): + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response(json_body={"features": []}) + assert client.get_feature_status("ai_assisted_lead_routing") is None + + def test_get_feature_status_filters_to_requested_feature_from_full_list(self): + # If the server ignores featureId and returns all features, the client + # still extracts the requested one. + client, mock_http = _make_client() + mock_http.request.return_value = _mock_response( + json_body={ + "features": [ + {"featureId": "ai_assisted_supplier_bid_analysis", "enabled": True}, + {"featureId": "ai_assisted_tco_analysis", "enabled": False}, + ] + } + ) + result = client.get_feature_status("ai_assisted_tco_analysis") + assert result is not None + assert result.feature_id == "ai_assisted_tco_analysis" + assert result.is_enabled() is False + + # --------------------------------------------------------------------------- # CBCClient — context manager # --------------------------------------------------------------------------- diff --git a/tests/cbc/unit/test_models.py b/tests/cbc/unit/test_models.py index 5bfc9b90..969e0c7a 100644 --- a/tests/cbc/unit/test_models.py +++ b/tests/cbc/unit/test_models.py @@ -14,6 +14,8 @@ ConsumptionVersions, ConfigurationEntity, EntityData, + FeatureStatus, + FeatureStatuses, ) @@ -71,6 +73,72 @@ def test_latest_without_any_dates_returns_last_item(self): assert result.version == "v2" +# --------------------------------------------------------------------------- +# FeatureStatus / FeatureStatuses +# --------------------------------------------------------------------------- + + +class TestFeatureStatus: + def test_parses_camel_case_feature_id_alias(self): + f = FeatureStatus.model_validate( + {"featureId": "ai_assisted_supplier_bid_analysis", "enabled": True} + ) + assert f.feature_id == "ai_assisted_supplier_bid_analysis" + assert f.enabled is True + assert f.is_enabled() is True + + def test_enabled_defaults_to_false_when_absent(self): + f = FeatureStatus.model_validate({"featureId": "ai_assisted_tco_analysis"}) + assert f.enabled is False + assert f.is_enabled() is False + + def test_ignores_extra_api_fields(self): + f = FeatureStatus.model_validate( + { + "featureId": "ai_assisted_supplier_bid_analysis", + "appTenantId": "b012b543-0374-4b3b-8597-c8c3a60bef22", + "status": "SUCCESS", + "decision": True, + "enabled": True, + "defaultValue": False, + "category": "ai", + "packageName": "Joule Premium for Sourcing", + "packageId": "SAP_JOULE_PREMIUM_SOURCING", + } + ) + assert f.feature_id == "ai_assisted_supplier_bid_analysis" + assert f.enabled is True + + +class TestFeatureStatuses: + def _statuses(self) -> FeatureStatuses: + return FeatureStatuses.model_validate( + { + "features": [ + {"featureId": "ai_assisted_supplier_bid_analysis", "enabled": True}, + {"featureId": "ai_assisted_tco_analysis", "enabled": False}, + ] + } + ) + + def test_is_enabled_true_for_enabled_feature(self): + assert self._statuses().is_enabled("ai_assisted_supplier_bid_analysis") is True + + def test_is_enabled_false_for_disabled_feature(self): + assert self._statuses().is_enabled("ai_assisted_tco_analysis") is False + + def test_is_enabled_false_for_absent_feature(self): + assert self._statuses().is_enabled("ai_assisted_lead_routing") is False + + def test_get_returns_matching_feature(self): + f = self._statuses().get("ai_assisted_supplier_bid_analysis") + assert f is not None + assert f.feature_id == "ai_assisted_supplier_bid_analysis" + + def test_get_returns_none_for_absent_feature(self): + assert self._statuses().get("ai_assisted_lead_routing") is None + + # --------------------------------------------------------------------------- # EntityData # --------------------------------------------------------------------------- diff --git a/tests/core/unit/telemetry/test_operation.py b/tests/core/unit/telemetry/test_operation.py index cc42e94f..dd772882 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -318,5 +318,5 @@ def test_operation_count(self): # 3 auditlog + 12 destination + 10 certificate + 10 fragment + 8 objectstore # + 2 extensibility + 7 aicore + 23 dms + 7 agentgateway + 13 agent_memory # + 5 data_anonymization + 52 adms + 6 print + 94 dpi_ng + 1 bootstrap + 3 output_management - # + 3 cbc = 259 - assert len(all_operations) == 259 + # + 5 cbc = 261 + assert len(all_operations) == 261 From bde443b03a77b0264d831ca6093bad2674f6ac77 Mon Sep 17 00:00:00 2001 From: Soumya Dey Date: Thu, 22 Oct 2026 16:19:51 +0530 Subject: [PATCH 2/2] feat(cbc): add CBCEventHandler for parsing and dispatching CBC CloudEvents Introduces CBCEventHandler that parses incoming CBC CloudEvents (using the cloudevents SDK v2.x) and dispatches them to registered callbacks. Adds ConsumptionVersionEvent and FeatureActivationEvent models, CBCEventError exception, and documents event handling in the user guide. --- pyproject.toml | 1 + src/sap_cloud_sdk/cbc/__init__.py | 13 + src/sap_cloud_sdk/cbc/_events.py | 224 +++++++++++++ src/sap_cloud_sdk/cbc/_models.py | 73 +++- src/sap_cloud_sdk/cbc/client.py | 3 +- src/sap_cloud_sdk/cbc/exceptions.py | 6 + src/sap_cloud_sdk/cbc/user-guide.md | 95 +++++- src/sap_cloud_sdk/core/telemetry/operation.py | 1 + tests/cbc/unit/test_client.py | 4 +- tests/cbc/unit/test_events.py | 314 ++++++++++++++++++ tests/cbc/unit/test_models.py | 2 - tests/core/unit/telemetry/test_operation.py | 4 +- uv.lock | 29 +- 13 files changed, 755 insertions(+), 14 deletions(-) create mode 100644 src/sap_cloud_sdk/cbc/_events.py create mode 100644 tests/cbc/unit/test_events.py diff --git a/pyproject.toml b/pyproject.toml index d4835fa4..be1e791d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "opentelemetry-instrumentation-flask~=0.65b0", "mcp>=1.1.0,<3", "python-odata>=0.7.0", + "cloudevents>=2.2.0,<3", "cryptography>=48.0.1,<51", ] diff --git a/src/sap_cloud_sdk/cbc/__init__.py b/src/sap_cloud_sdk/cbc/__init__.py index 156abb21..6521881a 100644 --- a/src/sap_cloud_sdk/cbc/__init__.py +++ b/src/sap_cloud_sdk/cbc/__init__.py @@ -40,11 +40,17 @@ CBCError, CBCClientError, CBCConfigError, + CBCEventError, CBCHttpError, CBCNetworkError, CBCServerError, HttpContext, ) +from sap_cloud_sdk.cbc._events import ( + CBCEventHandler, + ConsumptionVersionEvent, + FeatureActivationEvent, +) from sap_cloud_sdk.cbc._models import ( ApiError, ConfigurationData, @@ -53,6 +59,7 @@ ConsumptionVersion, ConsumptionVersions, EntityData, + FeatureActivationData, FeatureStatus, FeatureStatuses, ) @@ -69,10 +76,16 @@ "CBCError", "CBCClientError", "CBCConfigError", + "CBCEventError", "CBCHttpError", "CBCNetworkError", "CBCServerError", "HttpContext", + # event handler + "CBCEventHandler", + "ConsumptionVersionEvent", + "FeatureActivationData", + "FeatureActivationEvent", # models — consumption versions "ConsumptionVersion", "ConsumptionVersions", diff --git a/src/sap_cloud_sdk/cbc/_events.py b/src/sap_cloud_sdk/cbc/_events.py new file mode 100644 index 00000000..bd39682e --- /dev/null +++ b/src/sap_cloud_sdk/cbc/_events.py @@ -0,0 +1,224 @@ +"""CBC CloudEvent parsing and dispatch.""" + +from __future__ import annotations + +from collections.abc import Callable + +from cloudevents.core.base import BaseCloudEvent +from cloudevents.core.bindings.http import HTTPMessage, from_http_event +from pydantic import ValidationError + +from sap_cloud_sdk.cbc._models import ( + ConsumptionVersionEvent, + FeatureActivationData, + FeatureActivationEvent, + _ConsumptionVersionPayload, + _FeatureActivationItem, + _TenantContext, +) +from sap_cloud_sdk.cbc.exceptions import CBCEventError +from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics + +# --------------------------------------------------------------------------- +# Event type constants +# --------------------------------------------------------------------------- + +_CV_CREATED = "cbc.notification.consumptionversion.created" +_CV_UPDATED = "cbc.notification.consumptionversion.updated" +_FA_ACTIVATION = "cbc.notification.feature.activation.v1" + + +def _require(data: dict, key: str, event_type: str) -> object: + """Return ``data[key]``, raising ``CBCEventError`` if the key is absent.""" + value = data.get(key) + if value is None: + raise CBCEventError( + f"CBC event '{event_type}' is missing required field '{key}'" + ) + return value + + +# --------------------------------------------------------------------------- +# Handler +# --------------------------------------------------------------------------- + +ConsumptionVersionCallback = Callable[[ConsumptionVersionEvent], None] +FeatureActivationCallback = Callable[[FeatureActivationEvent], None] + + +class CBCEventHandler: + """Parses and dispatches incoming CBC CloudEvents to registered callbacks. + + Register one callback per event category, then call :meth:`handle_request` + from your webhook endpoint for every incoming POST:: + + handler = cbc.CBCEventHandler() + + def on_cv(event: cbc.ConsumptionVersionEvent) -> None: + ... + + def on_fa(event: cbc.FeatureActivationEvent) -> None: + for f in event.features: + update_cache(f.feature_id, f.enabled) + + handler.on_consumption_version_event(on_cv) + handler.on_feature_activation_event(on_fa) + + # In your webhook route: + handler.handle_request(request.headers, request.body) + + Raises: + CBCEventError: If the incoming request cannot be parsed as a valid + CloudEvent, or if the event data is structurally invalid for a + known event type. Unknown event types are silently ignored. + """ + + def __init__(self) -> None: + self._cv_callback: ConsumptionVersionCallback | None = None + self._fa_callback: FeatureActivationCallback | None = None + + @staticmethod + def _parse_cloud_event( + headers: dict[str, str], body: str | bytes + ) -> BaseCloudEvent: + """Parse raw HTTP headers and body into a :class:`BaseCloudEvent`. + + Raises: + CBCEventError: If the request cannot be parsed as a valid CloudEvent. + """ + raw = body.encode() if isinstance(body, str) else body + try: + return from_http_event(HTTPMessage(headers, raw)) + except Exception as exc: + raise CBCEventError( + f"Failed to parse incoming request as a CloudEvent: {exc}" + ) from exc + + @staticmethod + def _extract_app_tenant_id(raw_data: dict, event_type: str) -> str: + """Extract the app tenant id from the event context block. + + Raises: + CBCEventError: If the context field is absent or structurally invalid. + """ + try: + context = _TenantContext.model_validate( + _require(raw_data, "context", event_type) + ) + except ValidationError as exc: + raise CBCEventError( + f"CBC event '{event_type}' data is invalid: {exc}" + ) from exc + return context.app_tenant_id + + @staticmethod + def _parse_consumption_version_event(ce: BaseCloudEvent) -> ConsumptionVersionEvent: + """Parse a consumption-version CloudEvent into a :class:`ConsumptionVersionEvent`. + + Raises: + CBCEventError: If the data block is missing or structurally invalid. + """ + event_type = ce.get_type() + raw_data = ce.get_data() + if not isinstance(raw_data, dict): + raise CBCEventError(f"CBC event '{event_type}' has no parseable data block") + + context = CBCEventHandler._extract_app_tenant_id(raw_data, event_type) + try: + consumption = _ConsumptionVersionPayload.model_validate( + _require(raw_data, "consumption", event_type) + ) + except ValidationError as exc: + raise CBCEventError( + f"CBC event '{event_type}' data is invalid: {exc}" + ) from exc + + return ConsumptionVersionEvent( + app_tenant_id=context, + consumption_version_id=consumption.consumption_version_id, + ) + + @staticmethod + def _parse_feature_activation_event(ce: BaseCloudEvent) -> FeatureActivationEvent: + """Parse a feature-activation CloudEvent into a :class:`FeatureActivationEvent`. + + Raises: + CBCEventError: If the data block is missing, the context is absent, or + the features list is missing or empty. + """ + event_type = ce.get_type() + raw_data = ce.get_data() + if not isinstance(raw_data, dict): + raise CBCEventError(f"CBC event '{event_type}' has no parseable data block") + + app_tenant_id = CBCEventHandler._extract_app_tenant_id(raw_data, event_type) + + raw_features = raw_data.get("features") + if not raw_features: + raise CBCEventError(f"CBC event '{event_type}' has no features") + + try: + items = [_FeatureActivationItem.model_validate(f) for f in raw_features] + except ValidationError as exc: + raise CBCEventError( + f"CBC event '{event_type}' features are invalid: {exc}" + ) from exc + + features = [ + FeatureActivationData(feature_id=f.feature_id, enabled=f.enabled) + for f in items + ] + return FeatureActivationEvent(app_tenant_id=app_tenant_id, features=features) + + def on_consumption_version_event( + self, callback: ConsumptionVersionCallback + ) -> None: + """Register a callback for consumption-version events (created and updated). + + Args: + callback: Callable invoked with a :class:`ConsumptionVersionEvent` for + each ``consumptionversion.created`` or ``consumptionversion.updated`` + event received. Replaces any previously registered callback. + """ + self._cv_callback = callback + + def on_feature_activation_event(self, callback: FeatureActivationCallback) -> None: + """Register a callback for feature-activation events. + + Args: + callback: Callable invoked with a :class:`FeatureActivationEvent` for + each ``feature.activation.v1`` event received. Replaces any + previously registered callback. + """ + self._fa_callback = callback + + @record_metrics(Module.CBC, Operation.CBC_HANDLE_EVENT) + def handle_request( + self, + headers: dict[str, str], + body: str | bytes, + ) -> None: + """Parse an incoming CBC CloudEvent and dispatch it to registered callbacks. + + Args: + headers: HTTP request headers (needed for binary-mode CloudEvents). + body: Raw HTTP request body. + + Raises: + CBCEventError: If the request is not a valid CloudEvent, or if the + event data is structurally invalid for a known CBC event type. + """ + ce = self._parse_cloud_event(headers, body) + event_type: str = ce.get_type() + + if event_type in (_CV_CREATED, _CV_UPDATED): + if self._cv_callback is None: + return + self._cv_callback(self._parse_consumption_version_event(ce)) + + elif event_type == _FA_ACTIVATION: + if self._fa_callback is None: + return + self._fa_callback(self._parse_feature_activation_event(ce)) + + # unknown event type: silently ignored diff --git a/src/sap_cloud_sdk/cbc/_models.py b/src/sap_cloud_sdk/cbc/_models.py index 5f09385e..38025234 100644 --- a/src/sap_cloud_sdk/cbc/_models.py +++ b/src/sap_cloud_sdk/cbc/_models.py @@ -83,10 +83,6 @@ class FeatureStatus(_FrozenModel): feature_id: str = Field(alias="featureId") enabled: bool = False - def is_enabled(self) -> bool: - """Return whether this feature is enabled.""" - return self.enabled - class FeatureStatuses(_FrozenModel): """Activation status of all AI features for an app tenant. @@ -102,15 +98,21 @@ def is_enabled(self, feature_id: str) -> bool: Args: feature_id: Feature identifier to look up. + + Returns: + ``True`` if the feature is enabled, ``False`` if disabled or not present. """ feature = next((f for f in self.features if f.feature_id == feature_id), None) - return feature.is_enabled() if feature is not None else False + return feature.enabled if feature is not None else False def get(self, feature_id: str) -> FeatureStatus | None: """Return the :class:`FeatureStatus` for ``feature_id``, or ``None`` if absent. Args: feature_id: Feature identifier to look up. + + Returns: + Matching :class:`FeatureStatus`, or ``None`` if not found. """ return next((f for f in self.features if f.feature_id == feature_id), None) @@ -294,6 +296,67 @@ def get_entity_data( return co.get_entity_data(entity_id) if co is not None else None +# --------------------------------------------------------------------------- +# Event models +# --------------------------------------------------------------------------- + + +class _TenantContext(_FrozenModel): + app_tenant_id: str = Field(alias="appTenantId") + + +class _ConsumptionVersionPayload(_FrozenModel): + consumption_version_id: str = Field(alias="consumptionVersionId", min_length=1) + + +@dataclass +class ConsumptionVersionEvent: + """Parsed CBC consumption-version CloudEvent. + + Attributes: + app_tenant_id: Application tenant id from the event context. + consumption_version_id: The new consumption version id. + """ + + app_tenant_id: str + consumption_version_id: str + + +@dataclass +class FeatureActivationData: + """Activation state of one AI feature from a feature activation event. + + Attributes: + feature_id: Feature identifier. + enabled: Whether the feature is enabled. + """ + + feature_id: str + enabled: bool + + +class _FeatureActivationItem(_FrozenModel): + feature_id: str = Field(alias="featureId") + enabled: bool + + +@dataclass +class FeatureActivationEvent: + """Parsed CBC feature-activation CloudEvent. + + Carries the app tenant id and the full list of feature activation states + for the tenant. Apply the state directly — no follow-up call to CBC is + required. + + Attributes: + app_tenant_id: Application tenant id from the event context. + features: All feature activation states for the tenant. + """ + + app_tenant_id: str + features: list[FeatureActivationData] + + # --------------------------------------------------------------------------- # API error model # --------------------------------------------------------------------------- diff --git a/src/sap_cloud_sdk/cbc/client.py b/src/sap_cloud_sdk/cbc/client.py index bdb62516..e4fb11c2 100644 --- a/src/sap_cloud_sdk/cbc/client.py +++ b/src/sap_cloud_sdk/cbc/client.py @@ -33,6 +33,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any +from urllib.parse import quote as _url_quote import httpx @@ -438,7 +439,7 @@ def get_feature_status(self, feature_id: str) -> FeatureStatus | None: app_tenant_id = self._resolve_app_tenant_id() url = self._features_url( base_url, - f"/features?appTenantId={app_tenant_id}&featureId={feature_id}", + f"/features?appTenantId={app_tenant_id}&featureId={_url_quote(feature_id, safe='')}", ) statuses = FeatureStatuses.model_validate(self._request("GET", url).json()) return statuses.get(feature_id) diff --git a/src/sap_cloud_sdk/cbc/exceptions.py b/src/sap_cloud_sdk/cbc/exceptions.py index 30b552ec..b452b1f7 100644 --- a/src/sap_cloud_sdk/cbc/exceptions.py +++ b/src/sap_cloud_sdk/cbc/exceptions.py @@ -77,6 +77,12 @@ class CBCServerError(CBCHttpError): pass +class CBCEventError(CBCError): + """Raised when an incoming CloudEvent cannot be parsed or is structurally invalid.""" + + pass + + class CBCNetworkError(CBCError): """Raised for network-level failures (DNS, connection refused, timeouts).""" diff --git a/src/sap_cloud_sdk/cbc/user-guide.md b/src/sap_cloud_sdk/cbc/user-guide.md index 88650dac..acfb9476 100644 --- a/src/sap_cloud_sdk/cbc/user-guide.md +++ b/src/sap_cloud_sdk/cbc/user-guide.md @@ -140,6 +140,99 @@ modes = cbc_client.get_entity_data( ``` +## Event handling + +CBC pushes CloudEvents to your application's webhook endpoint when configuration +changes or AI features are activated or deactivated. Use `CBCEventHandler` to parse and dispatch +incoming events. + +### Setup + +```python +from sap_cloud_sdk import cbc + +handler = cbc.CBCEventHandler() + +def on_cv(event: cbc.ConsumptionVersionEvent) -> None: + # event.app_tenant_id, event.consumption_version_id + ... + +def on_fa(event: cbc.FeatureActivationEvent) -> None: + for f in event.features: + print(f.feature_id, f.enabled) + +handler.on_consumption_version_event(on_cv) +handler.on_feature_activation_event(on_fa) +``` + +### Wiring into your webhook route + +Call `handle_request` from your framework's route handler, passing the raw HTTP +headers and body: + +```python +# FastAPI example +@app.post("/cbc/events") +async def cbc_webhook(request: Request): + handler.handle_request(dict(request.headers), await request.body()) + return Response(status_code=200) +``` + +`handle_request` raises `CBCEventError` (a subclass of `CBCError`) if the +request cannot be parsed as a valid CloudEvent or if the event data is +structurally invalid. Your webhook route should return HTTP 4xx in that case +so CBC does not retry the delivery. + +Unknown event types are silently ignored. + +### Securing the webhook endpoint + +CBC delivers events over mTLS using a client certificate with the following +identity (stable across all CBC regions): + +``` +Subject: CN=cbc-notify-client, + OU=SAP Cloud Platform Clients+OU=Canary+OU=e804bfc8-44a1-4f27-8625-d7e434148210, + O=SAP SE, L=canary-eu, C=DE + +Issuer: CN=SAP PKI Certificate Service Client CA, + OU=SAP BTP Clients, + O=SAP SE, L=cf-eu12, C=DE +``` + +Your webhook endpoint should verify the incoming client certificate against +this exact Subject+Issuer DN pair before invoking `handle_request`. Accepting +events without certificate verification allows any caller to inject arbitrary +payloads. Certificate verification is done at the TLS layer of your web server +or API gateway — `CBCEventHandler` itself operates on the already-accepted HTTP +request. + +### Event types + +| Callback | Event types routed to it | +|---|---| +| `on_consumption_version_event` | `cbc.notification.consumptionversion.created`, `cbc.notification.consumptionversion.updated` | +| `on_feature_activation_event` | `cbc.notification.feature.activation.v1` | + +### Event objects + +**`ConsumptionVersionEvent`** + +| Attribute | Type | Description | +|---|---|---| +| `app_tenant_id` | `str` | Application tenant id from the event context | +| `consumption_version_id` | `str` | The new consumption version id | + +**`FeatureActivationEvent`** + +| Attribute | Type | Description | +|---|---|---| +| `app_tenant_id` | `str` | Application tenant id from the event context | +| `features` | `list[FeatureActivationData]` | All feature activation states for the tenant | + +Each `FeatureActivationData` item has `feature_id: str` and `enabled: bool`. + + ## Feature status Alongside configuration, CBC tracks which AI features are enabled for an app @@ -167,7 +260,7 @@ To fetch a single feature without pulling the whole list, use ```python feature = cbc_client.get_feature_status("ai_assisted_lead_routing") -if feature is not None and feature.is_enabled(): +if feature is not None and feature.enabled: ... ``` diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index 78a692dd..a7d32ce7 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -145,6 +145,7 @@ class Operation(str, Enum): CBC_GET_ENTITY_DATA = "get_entity_data" CBC_GET_FEATURE_STATUSES = "get_feature_statuses" CBC_GET_FEATURE_STATUS = "get_feature_status" + CBC_HANDLE_EVENT = "handle_event" # Bootstrap Operations BOOTSTRAP = "bootstrap" diff --git a/tests/cbc/unit/test_client.py b/tests/cbc/unit/test_client.py index e892f1c6..2bf71e51 100644 --- a/tests/cbc/unit/test_client.py +++ b/tests/cbc/unit/test_client.py @@ -466,7 +466,7 @@ def test_get_feature_status_returns_matching_feature(self): result = client.get_feature_status("ai_assisted_supplier_bid_analysis") assert result is not None assert result.feature_id == "ai_assisted_supplier_bid_analysis" - assert result.is_enabled() is True + assert result.enabled is True def test_get_feature_status_sends_both_query_params(self): client, mock_http = _make_client(app_tenant_id=_app_tid("tenant-9")) @@ -498,7 +498,7 @@ def test_get_feature_status_filters_to_requested_feature_from_full_list(self): result = client.get_feature_status("ai_assisted_tco_analysis") assert result is not None assert result.feature_id == "ai_assisted_tco_analysis" - assert result.is_enabled() is False + assert result.enabled is False # --------------------------------------------------------------------------- diff --git a/tests/cbc/unit/test_events.py b/tests/cbc/unit/test_events.py new file mode 100644 index 00000000..b9fd1ba9 --- /dev/null +++ b/tests/cbc/unit/test_events.py @@ -0,0 +1,314 @@ +"""Unit tests for CBCEventHandler and CBC event models.""" + +from __future__ import annotations + +import json + +import pytest + +from sap_cloud_sdk.cbc._events import CBCEventHandler +from sap_cloud_sdk.cbc._models import ( + ConsumptionVersionEvent, + FeatureActivationData, + FeatureActivationEvent, +) +from sap_cloud_sdk.cbc.exceptions import CBCEventError + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _structured_headers() -> dict[str, str]: + return {"Content-Type": "application/cloudevents+json"} + + +def _binary_headers(event_type: str) -> dict[str, str]: + return { + "ce-specversion": "1.0", + "ce-id": "0001", + "ce-source": "/test", + "ce-type": event_type, + "Content-Type": "application/json", + } + + +def _cv_body( + *, + consumption_version_id: str = "cv-1", + status: str = "FINETUNE", + app_tenant_id: str = "app-tenant-123", + cbc_tenant_id: str = "cbc-tenant-123", + include_context: bool = True, +) -> bytes: + data: dict = { + "consumption": { + "consumptionVersionId": consumption_version_id, + "status": status, + } + } + if include_context: + data["context"] = {"cbcTenantId": cbc_tenant_id, "appTenantId": app_tenant_id} + return json.dumps( + { + "specversion": "1.0", + "id": "0001", + "source": "/eu10/sap.alpha/cbc-tenant-123", + "type": "cbc.notification.consumptionversion.updated", + "datacontenttype": "application/json", + "data": data, + } + ).encode() + + +def _fa_body( + *, + features: list[dict] | None = None, + app_tenant_id: str = "app-tenant-123", + cbc_tenant_id: str = "cbc-tenant-123", + include_context: bool = True, +) -> bytes: + data: dict = { + "features": features + if features is not None + else [ + {"featureId": "ai_assisted_supplier_bid_analysis", "enabled": True}, + {"featureId": "ai_assisted_tco_analysis", "enabled": False}, + ] + } + if include_context: + data["context"] = {"cbcTenantId": cbc_tenant_id, "appTenantId": app_tenant_id} + return json.dumps( + { + "specversion": "1.0", + "id": "0001", + "source": "/test", + "type": "cbc.notification.feature.activation.v1", + "data": data, + } + ).encode() + + +# --------------------------------------------------------------------------- +# TestHandleRequest — consumption version events +# --------------------------------------------------------------------------- + + +class TestHandleRequestConsumptionVersion: + def test_created_event_dispatches_to_cv_callback(self): + handler = CBCEventHandler() + received: list[ConsumptionVersionEvent] = [] + handler.on_consumption_version_event(received.append) + + body = json.dumps( + { + "specversion": "1.0", + "id": "0001", + "source": "/test", + "type": "cbc.notification.consumptionversion.created", + "data": { + "consumption": {"consumptionVersionId": "cv-1", "status": "INITIALDEPLOY"}, + "context": {"cbcTenantId": "cbc-t1", "appTenantId": "app-t1"}, + }, + } + ).encode() + handler.handle_request(_structured_headers(), body) + + assert len(received) == 1 + assert received[0].app_tenant_id == "app-t1" + assert received[0].consumption_version_id == "cv-1" + + def test_updated_event_dispatches_to_cv_callback(self): + handler = CBCEventHandler() + received: list[ConsumptionVersionEvent] = [] + handler.on_consumption_version_event(received.append) + + handler.handle_request(_structured_headers(), _cv_body()) + + assert len(received) == 1 + assert received[0].app_tenant_id == "app-tenant-123" + assert received[0].consumption_version_id == "cv-1" + + def test_cv_event_without_callback_does_not_raise(self): + handler = CBCEventHandler() + handler.handle_request(_structured_headers(), _cv_body()) + + def test_cv_event_missing_context_raises_cbc_event_error(self): + handler = CBCEventHandler() + handler.on_consumption_version_event(lambda e: None) + with pytest.raises(CBCEventError, match="context"): + handler.handle_request(_structured_headers(), _cv_body(include_context=False)) + + def test_cv_event_missing_consumption_raises_cbc_event_error(self): + handler = CBCEventHandler() + handler.on_consumption_version_event(lambda e: None) + body = json.dumps( + { + "specversion": "1.0", + "id": "0001", + "source": "/test", + "type": "cbc.notification.consumptionversion.updated", + "data": { + "context": {"cbcTenantId": "cbc-t1", "appTenantId": "app-t1"}, + }, + } + ).encode() + with pytest.raises(CBCEventError, match="consumption"): + handler.handle_request(_structured_headers(), body) + + def test_cv_event_empty_consumption_version_id_raises_cbc_event_error(self): + handler = CBCEventHandler() + handler.on_consumption_version_event(lambda e: None) + with pytest.raises(CBCEventError): + handler.handle_request(_structured_headers(), _cv_body(consumption_version_id="")) + + def test_cv_event_binary_mode_dispatches_correctly(self): + handler = CBCEventHandler() + received: list[ConsumptionVersionEvent] = [] + handler.on_consumption_version_event(received.append) + + body = json.dumps( + { + "consumption": {"consumptionVersionId": "cv-binary", "status": "UPGRADE"}, + "context": {"cbcTenantId": "cbc-t1", "appTenantId": "app-t1"}, + } + ).encode() + handler.handle_request( + _binary_headers("cbc.notification.consumptionversion.updated"), body + ) + + assert len(received) == 1 + assert received[0].consumption_version_id == "cv-binary" + + +# --------------------------------------------------------------------------- +# TestHandleRequest — feature activation events +# --------------------------------------------------------------------------- + + +class TestHandleRequestFeatureActivation: + def test_fa_event_dispatches_to_fa_callback(self): + handler = CBCEventHandler() + received: list[FeatureActivationEvent] = [] + handler.on_feature_activation_event(received.append) + + handler.handle_request(_structured_headers(), _fa_body()) + + assert len(received) == 1 + assert received[0].app_tenant_id == "app-tenant-123" + assert len(received[0].features) == 2 + + def test_fa_event_parses_feature_ids_and_enabled_flags(self): + handler = CBCEventHandler() + received: list[FeatureActivationEvent] = [] + handler.on_feature_activation_event(received.append) + + handler.handle_request(_structured_headers(), _fa_body()) + + features = received[0].features + assert features[0].feature_id == "ai_assisted_supplier_bid_analysis" + assert features[0].enabled is True + assert features[1].feature_id == "ai_assisted_tco_analysis" + assert features[1].enabled is False + + def test_fa_event_without_callback_does_not_raise(self): + handler = CBCEventHandler() + handler.handle_request(_structured_headers(), _fa_body()) + + def test_fa_event_missing_context_raises_cbc_event_error(self): + handler = CBCEventHandler() + handler.on_feature_activation_event(lambda e: None) + with pytest.raises(CBCEventError, match="context"): + handler.handle_request(_structured_headers(), _fa_body(include_context=False)) + + def test_fa_event_empty_features_raises_cbc_event_error(self): + handler = CBCEventHandler() + handler.on_feature_activation_event(lambda e: None) + with pytest.raises(CBCEventError, match="features"): + handler.handle_request(_structured_headers(), _fa_body(features=[])) + + def test_fa_event_binary_mode_dispatches_correctly(self): + handler = CBCEventHandler() + received: list[FeatureActivationEvent] = [] + handler.on_feature_activation_event(received.append) + + body = json.dumps( + { + "features": [{"featureId": "ai_assisted_lead_routing", "enabled": True}], + "context": {"cbcTenantId": "cbc-t1", "appTenantId": "app-t1"}, + } + ).encode() + handler.handle_request(_binary_headers("cbc.notification.feature.activation.v1"), body) + + assert len(received) == 1 + assert received[0].features[0].feature_id == "ai_assisted_lead_routing" + + +# --------------------------------------------------------------------------- +# TestHandleRequest — unknown and malformed events +# --------------------------------------------------------------------------- + + +class TestHandleRequestUnknownAndMalformed: + def test_unknown_event_type_is_silently_ignored(self): + handler = CBCEventHandler() + handler.on_consumption_version_event(lambda e: (_ for _ in ()).throw(AssertionError())) + handler.on_feature_activation_event(lambda e: (_ for _ in ()).throw(AssertionError())) + + body = json.dumps( + { + "specversion": "1.0", + "id": "0001", + "source": "/test", + "type": "cbc.notification.some.unknown.event", + "data": {}, + } + ).encode() + handler.handle_request(_structured_headers(), body) + + def test_malformed_body_raises_cbc_event_error(self): + handler = CBCEventHandler() + with pytest.raises(CBCEventError): + handler.handle_request(_structured_headers(), b"not json at all") + + def test_body_as_str_is_accepted(self): + handler = CBCEventHandler() + received: list[ConsumptionVersionEvent] = [] + handler.on_consumption_version_event(received.append) + + handler.handle_request(_structured_headers(), _cv_body().decode()) + + assert len(received) == 1 + + +# --------------------------------------------------------------------------- +# TestEventModels +# --------------------------------------------------------------------------- + + +class TestConsumptionVersionEvent: + def test_repr_contains_key_fields(self): + e = ConsumptionVersionEvent(app_tenant_id="app-t1", consumption_version_id="cv-1") + assert "app-t1" in repr(e) + assert "cv-1" in repr(e) + + +class TestFeatureActivationData: + def test_enabled_returns_correct_value(self): + assert FeatureActivationData("f1", True).enabled is True + assert FeatureActivationData("f2", False).enabled is False + + def test_repr_contains_key_fields(self): + d = FeatureActivationData("ai_assisted_lead_routing", True) + assert "ai_assisted_lead_routing" in repr(d) + assert "True" in repr(d) + + +class TestFeatureActivationEvent: + def test_repr_contains_key_fields(self): + e = FeatureActivationEvent( + app_tenant_id="app-t1", + features=[FeatureActivationData("f1", True)], + ) + assert "app-t1" in repr(e) diff --git a/tests/cbc/unit/test_models.py b/tests/cbc/unit/test_models.py index 969e0c7a..33acc4cf 100644 --- a/tests/cbc/unit/test_models.py +++ b/tests/cbc/unit/test_models.py @@ -85,12 +85,10 @@ def test_parses_camel_case_feature_id_alias(self): ) assert f.feature_id == "ai_assisted_supplier_bid_analysis" assert f.enabled is True - assert f.is_enabled() is True def test_enabled_defaults_to_false_when_absent(self): f = FeatureStatus.model_validate({"featureId": "ai_assisted_tco_analysis"}) assert f.enabled is False - assert f.is_enabled() is False def test_ignores_extra_api_fields(self): f = FeatureStatus.model_validate( diff --git a/tests/core/unit/telemetry/test_operation.py b/tests/core/unit/telemetry/test_operation.py index dd772882..f8b24570 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -318,5 +318,5 @@ def test_operation_count(self): # 3 auditlog + 12 destination + 10 certificate + 10 fragment + 8 objectstore # + 2 extensibility + 7 aicore + 23 dms + 7 agentgateway + 13 agent_memory # + 5 data_anonymization + 52 adms + 6 print + 94 dpi_ng + 1 bootstrap + 3 output_management - # + 5 cbc = 261 - assert len(all_operations) == 261 + # + 6 cbc = 262 + assert len(all_operations) == 262 diff --git a/uv.lock b/uv.lock index 3be64207..5e3a46d2 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", @@ -603,6 +603,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, ] +[[package]] +name = "cloudevents" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/fe/3bae2187abea2682a1079c5618e45baf4c9df7e35017e1b9b7940955db59/cloudevents-2.2.0.tar.gz", hash = "sha256:fc52440a1fecde882b3b745361e1c7dd4e044e71a6b719cfec2e2c0aa1a19fa3", size = 48380, upload-time = "2026-06-11T14:10:02.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/d4/901b17ea39f4f1125fc92529cb842896309075406a78e13a7c544f0c5600/cloudevents-2.2.0-py3-none-any.whl", hash = "sha256:9ff8adff01b997d251d0fb54076557daa9906330e16afc1712a6447122f8f7a4", size = 98239, upload-time = "2026-06-11T14:10:01.059Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -805,6 +818,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, +] + [[package]] name = "distlib" version = "0.4.3" @@ -4353,6 +4378,7 @@ name = "sap-cloud-sdk" version = "0.62.0" source = { editable = "." } dependencies = [ + { name = "cloudevents" }, { name = "cryptography" }, { name = "grpcio" }, { name = "hatchling" }, @@ -4443,6 +4469,7 @@ dev = [ requires-dist = [ { name = "a2a-sdk", marker = "extra == 'extensibility'", specifier = ">=0.2.0,<1.0" }, { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.9.0,<4" }, + { name = "cloudevents", specifier = ">=2.2.0,<3" }, { name = "cryptography", specifier = ">=48.0.1,<51" }, { name = "django", marker = "extra == 'django'", specifier = ">=4.0,<6" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100.0,<1" },