From 63b16fb9ce04fd7c91ba79423bd772ac9ebf3da3 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Mon, 15 Jun 2026 09:21:46 -0300 Subject: [PATCH 1/8] feat: add destination resolution option --- .../core/auditlog_ng/__init__.py | 139 +++++++- .../auditlog_ng/unit/test_create_client.py | 311 +++++++++++++++++- 2 files changed, 441 insertions(+), 9 deletions(-) diff --git a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py index 321833dd..60bce1a4 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py @@ -6,7 +6,8 @@ The create_client() function accepts an AuditLogNGConfig and returns a ready-to-use AuditClient. -Usage: +Usage — explicit config:: + from sap_cloud_sdk.core.auditlog_ng import create_client, AuditLogNGConfig config = AuditLogNGConfig( @@ -18,6 +19,16 @@ ) client = create_client(config=config) +Usage — resolve from a Destination:: + + from sap_cloud_sdk.core.auditlog_ng import create_client + + client = create_client( + destination_name="my-audit-destination", + destination_instance="default", # optional, defaults to "default" + fragment_name="prod-fragment", # optional + ) + # Send an audit event (protobuf message) event_id = client.send(event, "DataAccess") client.close() @@ -42,10 +53,90 @@ record_error_metric as _record_error_metric, ) +_DESTINATION_PROP_DEPLOYMENT_ID = "deploymentId" +_DESTINATION_PROP_DEPLOYMENT_REGION = "deploymentRegion" +_DESTINATION_PROP_NAMESPACE = "namespace" + + +def _get_config_from_destination( + destination_name: str, + destination_instance: str, + fragment_name: str, +) -> dict: + """Resolve endpoint, deployment_id and namespace from a named Destination. + + The destination must expose these custom properties: + + - ``deploymentId`` (or ``deploymentRegion`` as fallback when absent/empty) + - ``namespace`` + + The destination ``url`` is used as the OTLP gRPC endpoint. + + Args: + destination_name: Name of the destination to resolve. + destination_instance: Destination service binding instance name. + Passed to ``create_client(instance=...)``; ``None`` uses the default. + fragment_name: Optional fragment merged before resolution. + + Returns: + dict with keys ``endpoint``, ``deployment_id``, ``namespace``. + + Raises: + ValueError: If the destination is not found or required properties + are missing. + """ + # Lazy import — keeps destination an optional dependency; importing auditlog_ng + # in environments without the destination package continues to work. + from sap_cloud_sdk.destination import ( + ConsumptionOptions, + create_client as _dest_create_client, + ) + + dest_client = _dest_create_client() + options = ConsumptionOptions(fragment_name=fragment_name) + destination = dest_client.get_destination( + name=destination_name, instance=destination_instance, options=options + ) + + if destination is None: + raise ValueError(f"Destination '{destination_name}' was not found") + + endpoint = destination.url + + props = destination.properties + + deployment_id = props.get(_DESTINATION_PROP_DEPLOYMENT_ID) or "" + if not deployment_id: + deployment_id = props.get(_DESTINATION_PROP_DEPLOYMENT_REGION) or "" + if not deployment_id: + raise ValueError( + f"Destination '{destination_name}' must provide either the " + f"'{_DESTINATION_PROP_DEPLOYMENT_ID}' or " + f"'{_DESTINATION_PROP_DEPLOYMENT_REGION}' property" + ) + + namespace = props.get(_DESTINATION_PROP_NAMESPACE) or "" + if not namespace: + raise ValueError( + f"Destination '{destination_name}' must provide the " + f"'{_DESTINATION_PROP_NAMESPACE}' property" + ) + + return { + "endpoint": endpoint, + "deployment_id": deployment_id, + "namespace": namespace, + } + def create_client( *, config: Optional[AuditLogNGConfig] = None, + # Destination-based resolution + destination_name: Optional[str] = None, + destination_instance: Optional[str] = None, + fragment_name: Optional[str] = None, + # Explicit connection parameters endpoint: Optional[str] = None, deployment_id: Optional[str] = None, namespace: Optional[str] = None, @@ -61,13 +152,33 @@ def create_client( ) -> AuditClient: """Create an AuditClient for sending audit events over OTLP/gRPC. - Either pass a pre-built ``config`` **or** the individual keyword arguments. - When ``config`` is provided the remaining keyword arguments are ignored. + Three mutually exclusive ways to provide configuration (evaluated in order): + + 1. **Explicit config object** — pass a pre-built :class:`AuditLogNGConfig` + via ``config``; all other keyword arguments are ignored. + + 2. **Destination-based resolution** — pass ``destination_name``, + ``destination_instance`` and ``fragment_name``). The Destination + module is used to fetch the named destination and extract ``endpoint``, + ``deployment_id`` (with fallback to ``deploymentRegion``), and + + ``namespace`` from its properties. The remaining keyword arguments + (``cert_file``, ``key_file``, ``ca_file``, ``insecure``, ``service_name``, + ``batch``, ``compression``, ``schema_url``) are still forwarded to the + resulting :class:`AuditLogNGConfig`. + + 3. **Explicit keyword arguments** — pass ``endpoint``, ``deployment_id``, + and ``namespace`` directly. Args: _telemetry_source: Internal parameter for telemetry. Not for external use. config: Optional explicit configuration. If provided, all other keyword arguments are ignored. + destination_name: Name of the SAP Destination to resolve. + destination_instance: Destination service binding instance name used + fragment_name: destination fragment + When set, ``destination_name``, ``destination_instance`` and ``fragment_name`` + are used to resolve ``endpoint`` / ``deployment_id`` / ``namespace`` arguments. endpoint: OTLP gRPC endpoint (``host:port``). deployment_id: Deployment identifier. namespace: Namespace identifier. @@ -85,16 +196,28 @@ def create_client( Raises: ClientCreationError: If client creation fails. - ValueError: If required parameters are missing. + ValueError: If required parameters are missing or destination + resolution fails. """ try: if config is None: try: - if not endpoint or not deployment_id or not namespace: - raise ValueError( - "endpoint, deployment_id, and namespace are required " - "when config is not provided" + if destination_name and destination_instance and fragment_name: + resolved = _get_config_from_destination( + destination_name=destination_name, + destination_instance=destination_instance, + fragment_name=fragment_name, ) + endpoint = resolved["endpoint"] + deployment_id = resolved["deployment_id"] + namespace = resolved["namespace"] + else: + if not endpoint or not deployment_id or not namespace: + raise ValueError( + "endpoint, deployment_id, and namespace are required " + "when config is not provided" + ) + config = AuditLogNGConfig( endpoint=endpoint, deployment_id=deployment_id, diff --git a/tests/core/unit/auditlog_ng/unit/test_create_client.py b/tests/core/unit/auditlog_ng/unit/test_create_client.py index 9da5b808..85380525 100644 --- a/tests/core/unit/auditlog_ng/unit/test_create_client.py +++ b/tests/core/unit/auditlog_ng/unit/test_create_client.py @@ -1,7 +1,7 @@ """Tests for create_client factory function.""" import pytest -from unittest.mock import patch, Mock +from unittest.mock import patch, Mock, MagicMock from sap_cloud_sdk.core.auditlog_ng import create_client, AuditClient from sap_cloud_sdk.core.auditlog_ng.config import AuditLogNGConfig @@ -185,3 +185,312 @@ def test_create_client_records_metric_once_with_source( Operation.AUDITLOG_CREATE_CLIENT, False, ) + + +# --------------------------------------------------------------------------- +# Destination-based resolution +# --------------------------------------------------------------------------- + +def _make_mock_destination( + url="audit.example.com:443", + deployment_id="dep-1", + deployment_region=None, + namespace="ns-1", +): + """Return a mock Destination with the given property values.""" + props = {} + if deployment_id is not None: + props["deploymentId"] = deployment_id + if deployment_region is not None: + props["deploymentRegion"] = deployment_region + if namespace is not None: + props["namespace"] = namespace + + dest = MagicMock() + dest.url = url + dest.properties = props + return dest + + +@patch("sap_cloud_sdk.core.auditlog_ng.client._create_log_exporter") +@patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider") +class TestCreateClientFromDestination: + + # ------------------------------------------------------------------ + # Happy path — all three args required to enter the destination path + # ------------------------------------------------------------------ + + def test_destination_happy_path(self, mock_provider_cls, mock_exporter_fn): + """Resolved destination with deploymentId sets endpoint/deployment_id/namespace.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + dest = _make_mock_destination( + url="audit.example.com:443", + deployment_id="dep-1", + namespace="ns-1", + ) + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + client = create_client( + destination_name="my-audit-dest", + destination_instance="my-instance", + fragment_name="prod", + insecure=True, + ) + + assert isinstance(client, AuditClient) + assert client._config.endpoint == "audit.example.com:443" + assert client._config.deployment_id == "dep-1" + assert client._config.namespace == "ns-1" + + def test_destination_create_client_called_without_instance(self, mock_provider_cls, mock_exporter_fn): + """destination_instance is accepted but _dest_create_client() is called without args.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + dest = _make_mock_destination() + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ) as mock_dest_factory: + create_client( + destination_name="my-audit-dest", + destination_instance="my-instance", + fragment_name="prod", + insecure=True, + ) + + mock_dest_factory.assert_called_once_with() + + def test_destination_fragment_name_forwarded(self, mock_provider_cls, mock_exporter_fn): + """fragment_name is always wrapped in ConsumptionOptions and forwarded to get_destination.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + dest = _make_mock_destination() + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + create_client( + destination_name="my-audit-dest", + destination_instance="my-instance", + fragment_name="prod", + insecure=True, + ) + + call_kwargs = dest_client.get_destination.call_args.kwargs + options = call_kwargs.get("options") + assert options is not None + assert options.fragment_name == "prod" + + def test_destination_name_without_instance_and_fragment_falls_through_to_explicit_args_guard( + self, mock_provider_cls, mock_exporter_fn + ): + """When destination_instance or fragment_name is missing, the destination path + is not entered and the explicit-args guard raises.""" + with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): + create_client(destination_name="my-audit-dest") + + def test_destination_name_without_fragment_falls_through_to_explicit_args_guard( + self, mock_provider_cls, mock_exporter_fn + ): + """destination_instance alone (no fragment_name) still falls through.""" + with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): + create_client(destination_name="my-audit-dest", destination_instance="inst") + + def test_destination_name_without_instance_falls_through_to_explicit_args_guard( + self, mock_provider_cls, mock_exporter_fn + ): + """fragment_name alone (no destination_instance) still falls through.""" + with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): + create_client(destination_name="my-audit-dest", fragment_name="prod") + + # ------------------------------------------------------------------ + # deploymentRegion fallback + # ------------------------------------------------------------------ + + def test_fallback_deployment_region_when_deployment_id_missing( + self, mock_provider_cls, mock_exporter_fn + ): + """When deploymentId is absent, deploymentRegion is used as deployment_id.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + dest = _make_mock_destination( + deployment_id=None, + deployment_region="eu10", + ) + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + client = create_client( + destination_name="my-audit-dest", + destination_instance="my-instance", + fragment_name="prod", + insecure=True, + ) + + assert client._config.deployment_id == "eu10" + + def test_fallback_deployment_region_when_deployment_id_empty( + self, mock_provider_cls, mock_exporter_fn + ): + """When deploymentId is an empty string, deploymentRegion is used instead.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + dest = _make_mock_destination( + deployment_id="", + deployment_region="eu20", + ) + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + client = create_client( + destination_name="my-audit-dest", + destination_instance="my-instance", + fragment_name="prod", + insecure=True, + ) + + assert client._config.deployment_id == "eu20" + + # ------------------------------------------------------------------ + # Missing required destination properties + # ------------------------------------------------------------------ + + def test_missing_both_deployment_props_raises( + self, mock_provider_cls, mock_exporter_fn + ): + """Raises ValueError when neither deploymentId nor deploymentRegion is present.""" + dest = _make_mock_destination(deployment_id=None, deployment_region=None) + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + with pytest.raises(ValueError, match="deploymentId.*deploymentRegion"): + create_client( + destination_name="my-audit-dest", + destination_instance="my-instance", + fragment_name="prod", + ) + + def test_missing_namespace_raises(self, mock_provider_cls, mock_exporter_fn): + """Raises ValueError when the namespace property is absent.""" + dest = _make_mock_destination(namespace=None) + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + with pytest.raises(ValueError, match="namespace"): + create_client( + destination_name="my-audit-dest", + destination_instance="my-instance", + fragment_name="prod", + ) + + def test_missing_url_propagates_as_endpoint_required(self, mock_provider_cls, mock_exporter_fn): + """When the destination URL is None, AuditLogNGConfig raises 'endpoint is required'.""" + dest = _make_mock_destination(url=None) + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + with pytest.raises(ValueError, match="endpoint is required"): + create_client( + destination_name="my-audit-dest", + destination_instance="my-instance", + fragment_name="prod", + ) + + def test_destination_not_found_raises(self, mock_provider_cls, mock_exporter_fn): + """Raises ValueError when get_destination returns None.""" + dest_client = MagicMock() + dest_client.get_destination.return_value = None + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + with pytest.raises(ValueError, match="not found"): + create_client( + destination_name="missing-dest", + destination_instance="my-instance", + fragment_name="prod", + ) + + # ------------------------------------------------------------------ + # No-destination path is fully preserved (regression) + # ------------------------------------------------------------------ + + def test_no_destination_explicit_args_still_works( + self, mock_provider_cls, mock_exporter_fn + ): + """Existing keyword-arg path is unaffected when destination_name is absent.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + client = create_client( + endpoint="localhost:4317", + deployment_id="dep-1", + namespace="ns-1", + insecure=True, + ) + + assert isinstance(client, AuditClient) + assert client._config.endpoint == "localhost:4317" + + def test_no_destination_config_object_still_works( + self, mock_provider_cls, mock_exporter_fn + ): + """Existing config-object path is unaffected when destination_name is absent.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + config = AuditLogNGConfig( + endpoint="localhost:4317", + deployment_id="dep-1", + namespace="ns-1", + insecure=True, + ) + + client = create_client(config=config) + + assert isinstance(client, AuditClient) From 93b4610fd4dfdf31537ead0a185e6e36d2985325 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Tue, 16 Jun 2026 15:03:39 -0300 Subject: [PATCH 2/8] feat: make fragment optional and set subaccount as level --- .../core/auditlog_ng/__init__.py | 54 ++++++++++--------- .../auditlog_ng/unit/test_create_client.py | 34 +++++++++--- 2 files changed, 55 insertions(+), 33 deletions(-) diff --git a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py index 60bce1a4..1506334f 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py @@ -25,7 +25,7 @@ client = create_client( destination_name="my-audit-destination", - destination_instance="default", # optional, defaults to "default" + destination_instance="my-binding-instance", fragment_name="prod-fragment", # optional ) @@ -61,7 +61,7 @@ def _get_config_from_destination( destination_name: str, destination_instance: str, - fragment_name: str, + fragment_name: Optional[str] = None, ) -> dict: """Resolve endpoint, deployment_id and namespace from a named Destination. @@ -71,12 +71,14 @@ def _get_config_from_destination( - ``namespace`` The destination ``url`` is used as the OTLP gRPC endpoint. + The lookup is always performed at ``ConsumptionLevel.SUBACCOUNT``. Args: destination_name: Name of the destination to resolve. - destination_instance: Destination service binding instance name. - Passed to ``create_client(instance=...)``; ``None`` uses the default. - fragment_name: Optional fragment merged before resolution. + destination_instance: Destination service binding instance name, + passed as ``instance=`` to ``destination.create_client()``. + fragment_name: Optional fragment name merged into the destination + before resolution. Wrapped in ``ConsumptionOptions`` when provided. Returns: dict with keys ``endpoint``, ``deployment_id``, ``namespace``. @@ -89,20 +91,20 @@ def _get_config_from_destination( # in environments without the destination package continues to work. from sap_cloud_sdk.destination import ( ConsumptionOptions, + ConsumptionLevel, create_client as _dest_create_client, ) - dest_client = _dest_create_client() - options = ConsumptionOptions(fragment_name=fragment_name) + dest_client = _dest_create_client(instance=destination_instance) + options = ConsumptionOptions(fragment_name=fragment_name) if fragment_name else None destination = dest_client.get_destination( - name=destination_name, instance=destination_instance, options=options + name=destination_name, options=options, level=ConsumptionLevel.SUBACCOUNT ) if destination is None: raise ValueError(f"Destination '{destination_name}' was not found") endpoint = destination.url - props = destination.properties deployment_id = props.get(_DESTINATION_PROP_DEPLOYMENT_ID) or "" @@ -157,15 +159,14 @@ def create_client( 1. **Explicit config object** — pass a pre-built :class:`AuditLogNGConfig` via ``config``; all other keyword arguments are ignored. - 2. **Destination-based resolution** — pass ``destination_name``, - ``destination_instance`` and ``fragment_name``). The Destination - module is used to fetch the named destination and extract ``endpoint``, - ``deployment_id`` (with fallback to ``deploymentRegion``), and - - ``namespace`` from its properties. The remaining keyword arguments - (``cert_file``, ``key_file``, ``ca_file``, ``insecure``, ``service_name``, - ``batch``, ``compression``, ``schema_url``) are still forwarded to the - resulting :class:`AuditLogNGConfig`. + 2. **Destination-based resolution** — pass ``destination_name`` and + ``destination_instance`` (both required); ``fragment_name`` is optional. + The Destination module resolves the named destination at subaccount level + and extracts ``endpoint``, ``deployment_id`` (with fallback to + ``deploymentRegion``), and ``namespace`` from its properties. + The remaining keyword arguments (``cert_file``, ``key_file``, ``ca_file``, + ``insecure``, ``service_name``, ``batch``, ``compression``, ``schema_url``) + are still forwarded to the resulting :class:`AuditLogNGConfig`. 3. **Explicit keyword arguments** — pass ``endpoint``, ``deployment_id``, and ``namespace`` directly. @@ -173,12 +174,13 @@ def create_client( Args: _telemetry_source: Internal parameter for telemetry. Not for external use. config: Optional explicit configuration. If provided, all other - keyword arguments are ignored. - destination_name: Name of the SAP Destination to resolve. - destination_instance: Destination service binding instance name used - fragment_name: destination fragment - When set, ``destination_name``, ``destination_instance`` and ``fragment_name`` - are used to resolve ``endpoint`` / ``deployment_id`` / ``namespace`` arguments. + keyword arguments are ignored. + destination_name: Name of the SAP Destination to resolve. Must be + combined with ``destination_instance`` to enter the destination path. + destination_instance: Destination service binding instance name, passed + as ``instance=`` to ``destination.create_client()``. Must be combined + with ``destination_name`` to enter the destination path. + fragment_name: Optional destination fragment name merged before resolution. endpoint: OTLP gRPC endpoint (``host:port``). deployment_id: Deployment identifier. namespace: Namespace identifier. @@ -197,12 +199,12 @@ def create_client( Raises: ClientCreationError: If client creation fails. ValueError: If required parameters are missing or destination - resolution fails. + resolution fails. """ try: if config is None: try: - if destination_name and destination_instance and fragment_name: + if destination_name and destination_instance: resolved = _get_config_from_destination( destination_name=destination_name, destination_instance=destination_instance, diff --git a/tests/core/unit/auditlog_ng/unit/test_create_client.py b/tests/core/unit/auditlog_ng/unit/test_create_client.py index 85380525..ce63740b 100644 --- a/tests/core/unit/auditlog_ng/unit/test_create_client.py +++ b/tests/core/unit/auditlog_ng/unit/test_create_client.py @@ -250,8 +250,8 @@ def test_destination_happy_path(self, mock_provider_cls, mock_exporter_fn): assert client._config.deployment_id == "dep-1" assert client._config.namespace == "ns-1" - def test_destination_create_client_called_without_instance(self, mock_provider_cls, mock_exporter_fn): - """destination_instance is accepted but _dest_create_client() is called without args.""" + def test_destination_create_client_called_with_instance(self, mock_provider_cls, mock_exporter_fn): + """destination_instance is forwarded as instance= to _dest_create_client().""" mock_provider = Mock() mock_provider.get_logger.return_value = Mock() mock_provider_cls.return_value = mock_provider @@ -271,7 +271,7 @@ def test_destination_create_client_called_without_instance(self, mock_provider_c insecure=True, ) - mock_dest_factory.assert_called_once_with() + mock_dest_factory.assert_called_once_with(instance="my-instance") def test_destination_fragment_name_forwarded(self, mock_provider_cls, mock_exporter_fn): """fragment_name is always wrapped in ConsumptionOptions and forwarded to get_destination.""" @@ -307,12 +307,32 @@ def test_destination_name_without_instance_and_fragment_falls_through_to_explici with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): create_client(destination_name="my-audit-dest") - def test_destination_name_without_fragment_falls_through_to_explicit_args_guard( + def test_destination_name_without_fragment_uses_destination_path( self, mock_provider_cls, mock_exporter_fn ): - """destination_instance alone (no fragment_name) still falls through.""" - with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): - create_client(destination_name="my-audit-dest", destination_instance="inst") + """destination_name + destination_instance without fragment_name still enters the + destination path; get_destination is called with options=None.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + dest = _make_mock_destination() + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ): + client = create_client( + destination_name="my-audit-dest", + destination_instance="inst", + insecure=True, + ) + + assert isinstance(client, AuditClient) + call_kwargs = dest_client.get_destination.call_args.kwargs + assert call_kwargs.get("options") is None def test_destination_name_without_instance_falls_through_to_explicit_args_guard( self, mock_provider_cls, mock_exporter_fn From fa0a33ca997b9f950a368cfd10f17b9ee6a3025b Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Tue, 16 Jun 2026 15:27:41 -0300 Subject: [PATCH 3/8] bump: version bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 33eb66a8..6bb8c155 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.26.1" +version = "0.26.2" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" From 6492c999e900d4d81b1b2c57a2485d630f6e8852 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Tue, 16 Jun 2026 16:07:39 -0300 Subject: [PATCH 4/8] refactor: apply PR skill comments --- .../core/auditlog_ng/__init__.py | 2 +- .../core/auditlog_ng/user-guide.md | 52 ++++++++++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py index 1506334f..9019f083 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py @@ -62,7 +62,7 @@ def _get_config_from_destination( destination_name: str, destination_instance: str, fragment_name: Optional[str] = None, -) -> dict: +) -> dict[str, str]: """Resolve endpoint, deployment_id and namespace from a named Destination. The destination must expose these custom properties: 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 430ee2dc..f4a4292b 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md +++ b/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md @@ -12,6 +12,7 @@ The Auditlog NG client sends audit log events as OpenTelemetry (OTLP) LogRecords - **mTLS** (mutual TLS with client certificates) - **Insecure** mode (local testing / no-auth) - **Binary protobuf** and **JSON** serialization formats +- **Destination-based configuration** — resolve connection parameters automatically from a named SAP Destination (SPII-based deployments) --- @@ -36,7 +37,31 @@ The client depends on generated protobuf classes. ## Configuration -All constructor parameters for `AuditClient`: +`create_client` supports three mutually exclusive ways to provide configuration, evaluated in this order: + +1. **Explicit config object** — pass a pre-built `AuditLogNGConfig` via `config=`. +2. **Destination-based resolution** — pass `destination_name` and `destination_instance`; connection parameters are resolved from the named SAP Destination automatically. +3. **Explicit keyword arguments** — pass `endpoint`, `deployment_id`, and `namespace` directly. + +### Destination-based configuration parameters + +| Parameter | Type | Required | Description | +|------------------------|--------|----------|-------------| +| `destination_name` | `str` | ✅ Yes | Name of the SAP Destination to resolve. | +| `destination_instance` | `str` | ✅ Yes | Destination service binding instance name. | +| `fragment_name` | `str` | ❌ No | Destination fragment merged before resolution (for tenant-specific overrides). | + +The destination must expose these custom properties: + +| Property | Required | Description | +|--------------------|----------|-------------| +| `deploymentId` | ✅ Yes (or `deploymentRegion`) | Deployment identifier. Falls back to `deploymentRegion` when absent or empty. | +| `deploymentRegion` | ✅ Fallback | Used as `deployment_id` when `deploymentId` is missing or empty. | +| `namespace` | ✅ Yes | Audit log namespace (e.g. `sap.als`). | + +The destination `url` is used as the OTLP gRPC endpoint. The lookup is always performed at subaccount level. + +### Explicit configuration parameters for `AuditClient`: | Parameter | Type | Required | Default | Description | |-----------------|---------|----------|----------------|-------------------------------------------------------------------------------------------------------| @@ -80,7 +105,30 @@ from sap_cloud_sdk.core.auditlog_ng.gen.sap.auditlog.auditevent.v2 import audite ### Step 2: Initialize the Client -**With mTLS (production):** +**From a Destination (SPII-based deployments):** + +```python +client = create_client( + destination_name="my_destination", + destination_instance="my-destination-binding", + # fragment_name="prod-fragment", # optional: merge a tenant-specific fragment +) +``` + +The SDK resolves `endpoint`, `deployment_id`, and `namespace` from the destination automatically. +You can still pass connection options alongside the destination parameters: + +```python +client = create_client( + destination_name="my_destination", + destination_instance="my-destination-binding", + fragment_name="prod-fragment", + service_name="my-agent", + batch=True, +) +``` + +**With mTLS (explicit configuration):** ```python client = create_client( From fad792f7d2a268985500b672fa09a3cf8bf92f65 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Wed, 17 Jun 2026 15:45:39 -0300 Subject: [PATCH 5/8] refactor: apply PR changes --- .../core/auditlog_ng/__init__.py | 43 +++++++++++-------- .../core/auditlog_ng/user-guide.md | 6 +-- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py index 9019f083..256a21d3 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py @@ -6,7 +6,8 @@ The create_client() function accepts an AuditLogNGConfig and returns a ready-to-use AuditClient. -Usage — explicit config:: +Usage: + explicit config: from sap_cloud_sdk.core.auditlog_ng import create_client, AuditLogNGConfig @@ -19,7 +20,8 @@ ) client = create_client(config=config) -Usage — resolve from a Destination:: +Usage: + resolve from a Destination: from sap_cloud_sdk.core.auditlog_ng import create_client @@ -35,6 +37,7 @@ """ from typing import Optional +from enum import Enum from sap_cloud_sdk.core.auditlog_ng.client import AuditClient from sap_cloud_sdk.core.auditlog_ng.config import ( @@ -53,14 +56,16 @@ record_error_metric as _record_error_metric, ) -_DESTINATION_PROP_DEPLOYMENT_ID = "deploymentId" -_DESTINATION_PROP_DEPLOYMENT_REGION = "deploymentRegion" -_DESTINATION_PROP_NAMESPACE = "namespace" + +class _DestinationProperties(Enum): + DEPLOYMENT_ID = "deploymentId" + DEPLOYMENT_REGION = "deploymentRegion" + NAMESPACE = "namespace" def _get_config_from_destination( destination_name: str, - destination_instance: str, + destination_instance: Optional[str] = "default", fragment_name: Optional[str] = None, ) -> dict[str, str]: """Resolve endpoint, deployment_id and namespace from a named Destination. @@ -96,7 +101,14 @@ def _get_config_from_destination( ) dest_client = _dest_create_client(instance=destination_instance) - options = ConsumptionOptions(fragment_name=fragment_name) if fragment_name else None + options = ( + ConsumptionOptions( + fragment_name=fragment_name, fragment_level=ConsumptionLevel.SUBACCOUNT + ) + if fragment_name + else None + ) + destination = dest_client.get_destination( name=destination_name, options=options, level=ConsumptionLevel.SUBACCOUNT ) @@ -107,21 +119,21 @@ def _get_config_from_destination( endpoint = destination.url props = destination.properties - deployment_id = props.get(_DESTINATION_PROP_DEPLOYMENT_ID) or "" + deployment_id = props.get(_DestinationProperties.DEPLOYMENT_ID.value) or "" if not deployment_id: - deployment_id = props.get(_DESTINATION_PROP_DEPLOYMENT_REGION) or "" + deployment_id = props.get(_DestinationProperties.DEPLOYMENT_REGION.value) or "" if not deployment_id: raise ValueError( f"Destination '{destination_name}' must provide either the " - f"'{_DESTINATION_PROP_DEPLOYMENT_ID}' or " - f"'{_DESTINATION_PROP_DEPLOYMENT_REGION}' property" + f"'{_DestinationProperties.DEPLOYMENT_ID.value}' or " + f"'{_DestinationProperties.DEPLOYMENT_REGION.value}' property" ) - namespace = props.get(_DESTINATION_PROP_NAMESPACE) or "" + namespace = props.get(_DestinationProperties.NAMESPACE.value) or "" if not namespace: raise ValueError( f"Destination '{destination_name}' must provide the " - f"'{_DESTINATION_PROP_NAMESPACE}' property" + f"'{_DestinationProperties.NAMESPACE.value}' property" ) return { @@ -164,9 +176,6 @@ def create_client( The Destination module resolves the named destination at subaccount level and extracts ``endpoint``, ``deployment_id`` (with fallback to ``deploymentRegion``), and ``namespace`` from its properties. - The remaining keyword arguments (``cert_file``, ``key_file``, ``ca_file``, - ``insecure``, ``service_name``, ``batch``, ``compression``, ``schema_url``) - are still forwarded to the resulting :class:`AuditLogNGConfig`. 3. **Explicit keyword arguments** — pass ``endpoint``, ``deployment_id``, and ``namespace`` directly. @@ -204,7 +213,7 @@ def create_client( try: if config is None: try: - if destination_name and destination_instance: + if destination_name: resolved = _get_config_from_destination( destination_name=destination_name, destination_instance=destination_instance, 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 f4a4292b..f6d58427 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md +++ b/src/sap_cloud_sdk/core/auditlog_ng/user-guide.md @@ -48,7 +48,7 @@ The client depends on generated protobuf classes. | Parameter | Type | Required | Description | |------------------------|--------|----------|-------------| | `destination_name` | `str` | ✅ Yes | Name of the SAP Destination to resolve. | -| `destination_instance` | `str` | ✅ Yes | Destination service binding instance name. | +| `destination_instance` | `str` | ❌ No | Destination service binding instance name. | | `fragment_name` | `str` | ❌ No | Destination fragment merged before resolution (for tenant-specific overrides). | The destination must expose these custom properties: @@ -59,13 +59,13 @@ The destination must expose these custom properties: | `deploymentRegion` | ✅ Fallback | Used as `deployment_id` when `deploymentId` is missing or empty. | | `namespace` | ✅ Yes | Audit log namespace (e.g. `sap.als`). | -The destination `url` is used as the OTLP gRPC endpoint. The lookup is always performed at subaccount level. +The destination `url` is used as the OTLP endpoint. The lookup is always performed at subaccount level. ### Explicit configuration 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 endpoint of the Audit Log Service (`host:port`) | | `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. | From fc17be83abfb58e56fc8604bf73cba2addf52190 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Wed, 17 Jun 2026 15:45:52 -0300 Subject: [PATCH 6/8] bump: bump version --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6bb8c155..60d58226 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.26.2" +version = "0.28.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/uv.lock b/uv.lock index b46da9eb..3d2154f6 100644 --- a/uv.lock +++ b/uv.lock @@ -3696,7 +3696,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.26.0" +version = "0.26.2" source = { editable = "." } dependencies = [ { name = "grpcio" }, From 35beb71a6c8e5ba4631b2aec18fd13b4a62cf5bd Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Wed, 17 Jun 2026 15:55:28 -0300 Subject: [PATCH 7/8] bump: bump version --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 3d410818..16a53008 100644 --- a/uv.lock +++ b/uv.lock @@ -3696,7 +3696,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.27.0" +version = "0.28.0" source = { editable = "." } dependencies = [ { name = "grpcio" }, From 314fbb32d3f2143c59fd803b75d7fe90fb3897ab Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Wed, 17 Jun 2026 16:16:18 -0300 Subject: [PATCH 8/8] refactor: update tests --- .../auditlog_ng/unit/test_create_client.py | 57 ++++++++++++++++--- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/tests/core/unit/auditlog_ng/unit/test_create_client.py b/tests/core/unit/auditlog_ng/unit/test_create_client.py index ce63740b..f4bd5cb1 100644 --- a/tests/core/unit/auditlog_ng/unit/test_create_client.py +++ b/tests/core/unit/auditlog_ng/unit/test_create_client.py @@ -294,18 +294,38 @@ def test_destination_fragment_name_forwarded(self, mock_provider_cls, mock_expor insecure=True, ) + from sap_cloud_sdk.destination import ConsumptionLevel + call_kwargs = dest_client.get_destination.call_args.kwargs options = call_kwargs.get("options") assert options is not None assert options.fragment_name == "prod" + assert options.fragment_level == ConsumptionLevel.SUBACCOUNT - def test_destination_name_without_instance_and_fragment_falls_through_to_explicit_args_guard( + def test_destination_name_alone_enters_destination_path( self, mock_provider_cls, mock_exporter_fn ): - """When destination_instance or fragment_name is missing, the destination path - is not entered and the explicit-args guard raises.""" - with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): - create_client(destination_name="my-audit-dest") + """destination_name alone enters the destination path (destination_instance + defaults to 'default'); the explicit-args guard is NOT raised.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + dest = _make_mock_destination() + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ) as mock_dest_factory: + client = create_client( + destination_name="my-audit-dest", + insecure=True, + ) + + mock_dest_factory.assert_called_once_with(instance=None) + assert isinstance(client, AuditClient) def test_destination_name_without_fragment_uses_destination_path( self, mock_provider_cls, mock_exporter_fn @@ -334,12 +354,31 @@ def test_destination_name_without_fragment_uses_destination_path( call_kwargs = dest_client.get_destination.call_args.kwargs assert call_kwargs.get("options") is None - def test_destination_name_without_instance_falls_through_to_explicit_args_guard( + def test_destination_name_without_instance_uses_default_instance( self, mock_provider_cls, mock_exporter_fn ): - """fragment_name alone (no destination_instance) still falls through.""" - with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): - create_client(destination_name="my-audit-dest", fragment_name="prod") + """destination_name with fragment_name but no destination_instance enters the + destination path, calling _dest_create_client with instance=None.""" + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + dest = _make_mock_destination() + dest_client = MagicMock() + dest_client.get_destination.return_value = dest + + with patch( + "sap_cloud_sdk.destination.create_client", + return_value=dest_client, + ) as mock_dest_factory: + client = create_client( + destination_name="my-audit-dest", + fragment_name="prod", + insecure=True, + ) + + mock_dest_factory.assert_called_once_with(instance=None) + assert isinstance(client, AuditClient) # ------------------------------------------------------------------ # deploymentRegion fallback