diff --git a/.gitignore b/.gitignore index 7011160..e4e2b91 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,4 @@ mocks/ # Generated files PULL_REQUEST.md -RELEASE.md \ No newline at end of file +RELEASE.md diff --git a/src/sap_cloud_sdk/agent_memory/py.typed b/src/sap_cloud_sdk/agent_memory/py.typed index 7f8a6b0..1430505 100644 --- a/src/sap_cloud_sdk/agent_memory/py.typed +++ b/src/sap_cloud_sdk/agent_memory/py.typed @@ -1 +1 @@ -# Marker file for PEP 561 to indicate the 'agent_memory' package is typed. \ No newline at end of file +# Marker file for PEP 561 to indicate the 'agent_memory' package is typed. diff --git a/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index 240c639..76df2da 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -121,5 +121,3 @@ class AgentGatewayClient: **kwargs, ) -> str ``` - - diff --git a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py index 0fdfe7b..5ab63d2 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py @@ -29,6 +29,7 @@ from sap_cloud_sdk.core.auditlog_ng.config import ( AuditLogNGConfig, SCHEMA_URL, + endpoint_from_region, ) from sap_cloud_sdk.core.auditlog_ng.exceptions import ( AuditLogNGError, @@ -116,6 +117,8 @@ def create_client( __all__ = [ # Factory function "create_client", + # Helpers + "endpoint_from_region", # Client "AuditClient", # Configuration diff --git a/src/sap_cloud_sdk/core/auditlog_ng/config.py b/src/sap_cloud_sdk/core/auditlog_ng/config.py index d89c851..309ea32 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/config.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/config.py @@ -69,3 +69,40 @@ def __post_init__(self) -> None: raise ValueError("endpoint is required") _validate_source_arg(self.deployment_id, "deployment_id") _validate_source_arg(self.namespace, "namespace") + + +_ALS_NG_ENDPOINT_SUFFIX = ".als.services.cloud.sap:443" + + +def endpoint_from_region(region: str) -> str: + """Derive the ALS NG gRPC endpoint from a SPII ``deploymentRegion`` value. + + Maps ``assignedTenant.deploymentRegion`` (e.g. ``"us30"``) to the + corresponding ALS NG host:port (e.g. ``"us30.als.services.cloud.sap:443"``). + + Args: + region: ``assignedTenant.deploymentRegion`` from the SPII payload. + + Returns: + Endpoint string suitable for :class:`AuditLogNGConfig`. + + Raises: + ValueError: If *region* is empty or contains invalid characters. + + Example:: + + from sap_cloud_sdk.core.auditlog_ng import endpoint_from_region, create_client + + region = spii_payload["assignedTenant"]["deploymentRegion"] # e.g. "us30" + client = create_client( + endpoint=endpoint_from_region(region), + deployment_id=spii_payload["assignedTenant"]["deploymentId"], + namespace=spii_payload["assignedTenant"]["applicationNamespace"], + cert_file="...", + key_file="...", + ) + """ + if not region: + raise ValueError("region must not be empty") + _validate_source_arg(region, "region") + return f"{region}{_ALS_NG_ENDPOINT_SUFFIX}" diff --git a/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md b/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md index a0ef42f..2ca6bd5 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md +++ b/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md @@ -3,6 +3,7 @@ This module provides an OTLP/gRPC client for sending structured audit log events to the SAP Audit Log Service (v3/NG). It supports mTLS, insecure mode for local testing, and both binary protobuf and JSON serialization formats. + --- ## Overview @@ -19,7 +20,7 @@ The Auditlog NG client sends audit log events as OpenTelemetry (OTLP) LogRecords ### 1. Required Dependencies -``` +```text grpcio>=1.60.0 protobuf>=4.25.0 protovalidate>=0.13.0 @@ -40,7 +41,7 @@ All constructor parameters for `AuditClient`: | Parameter | Type | Required | Default | Description | |-----------------|---------|----------|----------------|-------------------------------------------------------------------------------------------------------| -| `endpoint` | `str` | ✅ Yes | — | OTLP gRPC endpoint of the Audit Log Service (`host:port`) | +| `endpoint` | `str` | ✅ Yes | — | OTLP gRPC endpoint of the Audit Log Service (`host:port`). When deploying on BTP, derive from the SPII payload: `endpoint_from_region(assignedTenant.deploymentRegion)` → e.g. `us30.als.services.cloud.sap:443`. | | `deployment_id` | `str` | ✅ Yes | — | Deployment/region identifier. Validated: only `[a-zA-Z0-9._-/~]` allowed. Raises `ValueError` if invalid. | | `namespace` | `str` | ✅ Yes | — | Audit log namespace (e.g. `sap.als`). Same character-set validation as `deployment_id`. | | `cert_file` | `str` | ❌ No | `None` | Path to the mTLS client certificate file (PEM). Required together with `key_file` for mTLS. | @@ -74,13 +75,25 @@ All constructor parameters for `AuditClient`: ### Step 1: Import the Client and Generated Protobuf ```python -from sap_cloud_sdk.core.auditlog_ng import create_client, AuditLogNGConfig +from sap_cloud_sdk.core.auditlog_ng import create_client, AuditLogNGConfig, endpoint_from_region from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import auditevent_pb2 as pb ``` ### Step 2: Initialize the Client -**With mTLS (production):** +**With SPII payload (BTP production):** + +```python +client = create_client( + endpoint=endpoint_from_region(spii_payload["assignedTenant"]["deploymentRegion"]), + deployment_id=spii_payload["assignedTenant"]["deploymentId"], + namespace=spii_payload["assignedTenant"]["applicationNamespace"], + cert_file="/path/to/client-certificate_chain.pem", + key_file="/path/to/private-key.pem", +) +``` + +**With explicit endpoint (production):** ```python client = create_client( @@ -237,6 +250,56 @@ Events are validated against protobuf constraints using `protovalidate` before s --- +## BTP / SPII Integration + +When running on BTP, the agent receives a SPII payload at tenant-assign time. Use `endpoint_from_region` to derive the ALS NG endpoint and store all required values via the Destination Service so they are available at emit time. + +### SPII field mapping + +| SPII field | Parameter | +| --- | --- | +| `assignedTenant.deploymentRegion` | `endpoint_from_region(region)` | +| `assignedTenant.deploymentId` | `deployment_id` | +| `assignedTenant.applicationNamespace` | `namespace` | +| `assignedTenant.applicationTenantId` | `event.common.tenant_id` (per event) | + +### At SPII assign time + +```python +from sap_cloud_sdk.core.auditlog_ng import endpoint_from_region +from sap_cloud_sdk.destination._models import Destination + +region = spii_payload["assignedTenant"]["deploymentRegion"] +destination = Destination( + name="AuditLogV3_Destination", + type="TCP", + url=endpoint_from_region(region), + properties={ + "deployment_id": spii_payload["assignedTenant"]["deploymentId"], + "namespace": spii_payload["assignedTenant"]["applicationNamespace"], + } +) +# persist via Destination Service client +``` + +### At emit time + +```python +from sap_cloud_sdk.core.auditlog_ng import create_client + +dest = await destination_client.get_destination("AuditLogV3_Destination") +with create_client( + endpoint=dest.url, + deployment_id=dest.properties["deployment_id"], + namespace=dest.properties["namespace"], + cert_file="/path/to/client-certificate_chain.pem", + key_file="/path/to/private-key.pem", +) as client: + client.send(event, "DataAccess") +``` + +--- + ## Running the Unit Tests ```bash diff --git a/tests/agent_memory/integration/agentmemory.feature b/tests/agent_memory/integration/agentmemory.feature index dbe3c56..e6804f2 100644 --- a/tests/agent_memory/integration/agentmemory.feature +++ b/tests/agent_memory/integration/agentmemory.feature @@ -88,4 +88,4 @@ Feature: Agent Memory Service Integration (v1 API) Scenario: Filter messages by metadata substring Given a message exists with agent "test-agent" invoker "test-user" group "conv-filter" role "USER" content "filter-test-message" and metadata "filter-marker" When I list messages filtered by metadata containing "filter-marker" - Then the result should contain the message with content "filter-test-message" \ No newline at end of file + Then the result should contain the message with content "filter-test-message" diff --git a/tests/agent_memory/integration/test_agentmemory_bdd.py b/tests/agent_memory/integration/test_agentmemory_bdd.py index fb7c450..adfc0bb 100644 --- a/tests/agent_memory/integration/test_agentmemory_bdd.py +++ b/tests/agent_memory/integration/test_agentmemory_bdd.py @@ -16,6 +16,7 @@ import pytest from pytest_bdd import given, scenario, then, when +from sap_cloud_sdk.agent_memory import MessageRole from sap_cloud_sdk.agent_memory.client import AgentMemoryClient # -- Scenarios ----------------------------------------------------------------- @@ -176,7 +177,7 @@ def message_exists_list(context, agent_memory_client): "test-agent", "test-user", "conv-list", - "USER", + MessageRole.USER, "Listed message", ) @@ -190,7 +191,7 @@ def message_exists_delete(context, agent_memory_client): "test-agent", "test-user", "conv-del", - "USER", + MessageRole.USER, "To be deleted", ) @@ -258,7 +259,7 @@ def add_message(context): "test-agent", "test-user", "conv-1", - "USER", + MessageRole.USER, "Hello!", ) @@ -352,7 +353,7 @@ def check_message_id(context): @then('the message should have role "USER"') def check_message_role(context): - assert context["message"].role == "USER" + assert context["message"].role == MessageRole.USER @then('the message should have content "Hello!"') @@ -458,7 +459,7 @@ def message_exists_filter(context, agent_memory_client): "test-agent", "test-user", "conv-filter", - "USER", + MessageRole.USER, "filter-test-message", metadata={"tag": "filter-marker"}, ) diff --git a/tests/core/unit/auditlog_ng/unit/test_client.py b/tests/core/unit/auditlog_ng/unit/test_client.py index 8707184..bff8fa4 100644 --- a/tests/core/unit/auditlog_ng/unit/test_client.py +++ b/tests/core/unit/auditlog_ng/unit/test_client.py @@ -34,7 +34,7 @@ def _make_config(**overrides: Unpack[ConfigKwargs]) -> AuditLogNGConfig: "namespace": "namespace-123", "insecure": True, } - defaults.update(overrides) # ty: ignore[invalid-argument-type] + defaults.update(overrides) return AuditLogNGConfig(**defaults) diff --git a/tests/core/unit/auditlog_ng/unit/test_config.py b/tests/core/unit/auditlog_ng/unit/test_config.py index 24b464c..52568aa 100644 --- a/tests/core/unit/auditlog_ng/unit/test_config.py +++ b/tests/core/unit/auditlog_ng/unit/test_config.py @@ -6,6 +6,7 @@ AuditLogNGConfig, SCHEMA_URL, _validate_source_arg, + endpoint_from_region, ) @@ -106,3 +107,27 @@ def test_defaults_for_optional_fields(self): assert config.key_file is None assert config.ca_file is None assert config.insecure is False + + +class TestEndpointFromRegion: + + def test_standard_region(self): + assert endpoint_from_region("us30") == "us30.als.services.cloud.sap:443" + + def test_eu_region(self): + assert endpoint_from_region("eu10") == "eu10.als.services.cloud.sap:443" + + def test_region_with_dashes(self): + assert endpoint_from_region("ap21") == "ap21.als.services.cloud.sap:443" + + def test_empty_region_raises(self): + with pytest.raises(ValueError, match="must not be empty"): + endpoint_from_region("") + + def test_region_with_spaces_raises(self): + with pytest.raises(ValueError, match="region"): + endpoint_from_region("us 30") + + def test_region_with_special_chars_raises(self): + with pytest.raises(ValueError, match="region"): + endpoint_from_region("us30@cloud") diff --git a/tests/dms/integration/conftest.py b/tests/dms/integration/conftest.py index 254a83b..9de8ed9 100644 --- a/tests/dms/integration/conftest.py +++ b/tests/dms/integration/conftest.py @@ -14,8 +14,7 @@ def dms_client(): client = create_client(instance="default") return client except Exception as e: - pytest.skip(f"DMS integration tests require credentials: {e}") # ty: ignore[invalid-argument-type, too-many-positional-arguments] - + pytest.skip(f"DMS integration tests require credentials: {e}") def _setup_cloud_mode(): """Common setup for cloud mode integration tests.""" diff --git a/tests/dms/integration/test_dms_bdd.py b/tests/dms/integration/test_dms_bdd.py index 3abe4e8..f68753d 100644 --- a/tests/dms/integration/test_dms_bdd.py +++ b/tests/dms/integration/test_dms_bdd.py @@ -128,7 +128,7 @@ def select_version_repo(context: DMSTestContext, dms_client: DMSClient): version_repo = r break if version_repo is None: - pytest.skip("No version-enabled repository available") # ty: ignore[invalid-argument-type, too-many-positional-arguments] + pytest.skip("No version-enabled repository available") context.repo = version_repo context.repo_id = version_repo.id diff --git a/tests/extensibility/unit/test_ums_caching.py b/tests/extensibility/unit/test_ums_caching.py index 9af3c30..fa1ee6e 100644 --- a/tests/extensibility/unit/test_ums_caching.py +++ b/tests/extensibility/unit/test_ums_caching.py @@ -334,4 +334,3 @@ def test_cache_hit_refreshes_lru_position(self): assert ("tenant-new", "default") in transport._cache finally: patcher.stop() - diff --git a/tests/extensibility/unit/test_ums_pagination.py b/tests/extensibility/unit/test_ums_pagination.py index c79a6ed..39198bf 100644 --- a/tests/extensibility/unit/test_ums_pagination.py +++ b/tests/extensibility/unit/test_ums_pagination.py @@ -340,4 +340,3 @@ def test_missing_page_info_stops_pagination(self): def test_max_pages_constant(self): """Safety limit constant is 100.""" assert _MAX_PAGES == 100 - diff --git a/tests/extensibility/unit/test_ums_parsing.py b/tests/extensibility/unit/test_ums_parsing.py index 542e972..0cb2af6 100644 --- a/tests/extensibility/unit/test_ums_parsing.py +++ b/tests/extensibility/unit/test_ums_parsing.py @@ -459,4 +459,3 @@ def test_tools_key_missing(self): } result = _transform_ums_response(data, "default") assert result.mcp_servers == [] -