From 63b16fb9ce04fd7c91ba79423bd772ac9ebf3da3 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Mon, 15 Jun 2026 09:21:46 -0300 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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 07/12] 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 08/12] 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 From 774d86e02c1dc19db945cbe15a2ddbc546c8cf65 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Thu, 18 Jun 2026 15:31:58 -0300 Subject: [PATCH 09/12] refactor: address PR comments --- .../core/auditlog_ng/__init__.py | 37 +++++----- .../auditlog_ng/unit/test_create_client.py | 70 ++++++++++++++----- 2 files changed, 71 insertions(+), 36 deletions(-) diff --git a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py index 256a21d3..74198503 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py @@ -64,8 +64,8 @@ class _DestinationProperties(Enum): def _get_config_from_destination( - destination_name: str, - destination_instance: Optional[str] = "default", + destination_name: Optional[str], + destination_instance: Optional[str], fragment_name: Optional[str] = None, ) -> dict[str, str]: """Resolve endpoint, deployment_id and namespace from a named Destination. @@ -75,7 +75,7 @@ def _get_config_from_destination( - ``deploymentId`` (or ``deploymentRegion`` as fallback when absent/empty) - ``namespace`` - The destination ``url`` is used as the OTLP gRPC endpoint. + The destination ``url`` is used as the OTLP endpoint. The lookup is always performed at ``ConsumptionLevel.SUBACCOUNT``. Args: @@ -86,11 +86,14 @@ def _get_config_from_destination( before resolution. Wrapped in ``ConsumptionOptions`` when provided. Returns: - dict with keys ``endpoint``, ``deployment_id``, ``namespace``. + dict with keys ``endpoint``, ``deployment_id``, ``namespace`` + when destination is found. - Raises: - ValueError: If the destination is not found or required properties - are missing. + Returns: + None: If destination is not found. + + Return: + ValueError: If required properties are missing. """ # Lazy import — keeps destination an optional dependency; importing auditlog_ng # in environments without the destination package continues to work. @@ -114,7 +117,7 @@ def _get_config_from_destination( ) if destination is None: - raise ValueError(f"Destination '{destination_name}' was not found") + return {} endpoint = destination.url props = destination.properties @@ -147,8 +150,8 @@ def create_client( *, config: Optional[AuditLogNGConfig] = None, # Destination-based resolution - destination_name: Optional[str] = None, - destination_instance: Optional[str] = None, + destination_name: Optional[str] = "AuditLogV3_Destination", + destination_instance: Optional[str] = "default", fragment_name: Optional[str] = None, # Explicit connection parameters endpoint: Optional[str] = None, @@ -213,12 +216,12 @@ def create_client( try: if config is None: try: - if destination_name: - resolved = _get_config_from_destination( - destination_name=destination_name, - destination_instance=destination_instance, - fragment_name=fragment_name, - ) + resolved = _get_config_from_destination( + destination_name=destination_name, + destination_instance=destination_instance, + fragment_name=fragment_name, + ) + if resolved: endpoint = resolved["endpoint"] deployment_id = resolved["deployment_id"] namespace = resolved["namespace"] @@ -226,7 +229,7 @@ def create_client( if not endpoint or not deployment_id or not namespace: raise ValueError( "endpoint, deployment_id, and namespace are required " - "when config is not provided" + "when config or valid destination is not provided" ) config = AuditLogNGConfig( 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 f4bd5cb1..b977907a 100644 --- a/tests/core/unit/auditlog_ng/unit/test_create_client.py +++ b/tests/core/unit/auditlog_ng/unit/test_create_client.py @@ -45,7 +45,11 @@ def test_create_client_with_keyword_args(self, mock_provider_cls, mock_exporter_ assert isinstance(client, AuditClient) - def test_create_client_missing_endpoint_raises(self): + @patch( + "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", + return_value=None, + ) + def test_create_client_missing_endpoint_raises(self, _mock_dest): with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): create_client(deployment_id="dep-1", namespace="ns-1") @@ -66,7 +70,11 @@ def test_create_client_missing_endpoint_raises(self): ), ], ) - def test_create_client_config_errors_record_error_metric(self, kwargs, match): + @patch( + "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", + return_value=None, + ) + def test_create_client_config_errors_record_error_metric(self, _mock_dest, kwargs, match): with patch( "sap_cloud_sdk.core.auditlog_ng._record_error_metric" ) as mock_error_metric: @@ -85,19 +93,35 @@ def test_create_client_config_errors_record_error_metric(self, kwargs, match): Operation.AUDITLOG_CREATE_CLIENT, ) - def test_create_client_missing_deployment_id_raises(self): + @patch( + "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", + return_value=None, + ) + def test_create_client_missing_deployment_id_raises(self, _mock_dest): with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): create_client(endpoint="localhost:4317", namespace="ns-1") - def test_create_client_missing_namespace_raises(self): + @patch( + "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", + return_value=None, + ) + def test_create_client_missing_namespace_raises(self, _mock_dest): with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): create_client(endpoint="localhost:4317", deployment_id="dep-1") - def test_create_client_no_args_raises(self): + @patch( + "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", + return_value=None, + ) + def test_create_client_no_args_raises(self, _mock_dest): with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): create_client() - def test_create_client_invalid_deployment_id_raises(self): + @patch( + "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", + return_value=None, + ) + def test_create_client_invalid_deployment_id_raises(self, _mock_dest): with pytest.raises(ValueError, match="deployment_id"): create_client( endpoint="localhost:4317", @@ -107,8 +131,12 @@ def test_create_client_invalid_deployment_id_raises(self): @patch("sap_cloud_sdk.core.auditlog_ng.client._create_log_exporter") @patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider") + @patch( + "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", + return_value=None, + ) def test_create_client_unexpected_exception_wraps_in_client_creation_error( - self, mock_provider_cls, mock_exporter_fn + self, _mock_dest, mock_provider_cls, mock_exporter_fn ): mock_provider_cls.side_effect = RuntimeError("Unexpected failure") @@ -324,7 +352,7 @@ def test_destination_name_alone_enters_destination_path( insecure=True, ) - mock_dest_factory.assert_called_once_with(instance=None) + mock_dest_factory.assert_called_once_with(instance="default") assert isinstance(client, AuditClient) def test_destination_name_without_fragment_uses_destination_path( @@ -358,7 +386,7 @@ def test_destination_name_without_instance_uses_default_instance( self, mock_provider_cls, mock_exporter_fn ): """destination_name with fragment_name but no destination_instance enters the - destination path, calling _dest_create_client with instance=None.""" + destination path, calling _dest_create_client with instance='default'.""" mock_provider = Mock() mock_provider.get_logger.return_value = Mock() mock_provider_cls.return_value = mock_provider @@ -377,7 +405,7 @@ def test_destination_name_without_instance_uses_default_instance( insecure=True, ) - mock_dest_factory.assert_called_once_with(instance=None) + mock_dest_factory.assert_called_once_with(instance="default") assert isinstance(client, AuditClient) # ------------------------------------------------------------------ @@ -498,7 +526,7 @@ def test_missing_url_propagates_as_endpoint_required(self, mock_provider_cls, mo ) def test_destination_not_found_raises(self, mock_provider_cls, mock_exporter_fn): - """Raises ValueError when get_destination returns None.""" + """Raises ValueError when get_destination returns None and no explicit args are given.""" dest_client = MagicMock() dest_client.get_destination.return_value = None @@ -506,7 +534,7 @@ def test_destination_not_found_raises(self, mock_provider_cls, mock_exporter_fn) "sap_cloud_sdk.destination.create_client", return_value=dest_client, ): - with pytest.raises(ValueError, match="not found"): + with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): create_client( destination_name="missing-dest", destination_instance="my-instance", @@ -520,17 +548,21 @@ def test_destination_not_found_raises(self, mock_provider_cls, mock_exporter_fn) 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.""" + """Existing keyword-arg path is unaffected when destination resolution returns None.""" 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, - ) + with patch( + "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", + return_value=None, + ): + 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" From f029c37149f55984797feca806f62b174d7c3f19 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Thu, 18 Jun 2026 15:40:32 -0300 Subject: [PATCH 10/12] refactor: update unit tests --- .../auditlog_ng/unit/test_create_client.py | 70 +++++++++++++++---- 1 file changed, 57 insertions(+), 13 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 b977907a..689235d9 100644 --- a/tests/core/unit/auditlog_ng/unit/test_create_client.py +++ b/tests/core/unit/auditlog_ng/unit/test_create_client.py @@ -31,7 +31,11 @@ def test_create_client_with_config(self, mock_provider_cls, mock_exporter_fn): @patch("sap_cloud_sdk.core.auditlog_ng.client._create_log_exporter") @patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider") - def test_create_client_with_keyword_args(self, mock_provider_cls, mock_exporter_fn): + @patch( + "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", + return_value={}, + ) + def test_create_client_with_keyword_args(self, _mock_dest, mock_provider_cls, mock_exporter_fn): mock_provider = Mock() mock_provider.get_logger.return_value = Mock() mock_provider_cls.return_value = mock_provider @@ -47,7 +51,7 @@ def test_create_client_with_keyword_args(self, mock_provider_cls, mock_exporter_ @patch( "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value=None, + return_value={}, ) def test_create_client_missing_endpoint_raises(self, _mock_dest): with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): @@ -72,7 +76,7 @@ def test_create_client_missing_endpoint_raises(self, _mock_dest): ) @patch( "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value=None, + return_value={}, ) def test_create_client_config_errors_record_error_metric(self, _mock_dest, kwargs, match): with patch( @@ -95,7 +99,7 @@ def test_create_client_config_errors_record_error_metric(self, _mock_dest, kwarg @patch( "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value=None, + return_value={}, ) def test_create_client_missing_deployment_id_raises(self, _mock_dest): with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): @@ -103,7 +107,7 @@ def test_create_client_missing_deployment_id_raises(self, _mock_dest): @patch( "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value=None, + return_value={}, ) def test_create_client_missing_namespace_raises(self, _mock_dest): with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): @@ -111,7 +115,7 @@ def test_create_client_missing_namespace_raises(self, _mock_dest): @patch( "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value=None, + return_value={}, ) def test_create_client_no_args_raises(self, _mock_dest): with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): @@ -119,7 +123,7 @@ def test_create_client_no_args_raises(self, _mock_dest): @patch( "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value=None, + return_value={}, ) def test_create_client_invalid_deployment_id_raises(self, _mock_dest): with pytest.raises(ValueError, match="deployment_id"): @@ -133,7 +137,7 @@ def test_create_client_invalid_deployment_id_raises(self, _mock_dest): @patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider") @patch( "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value=None, + return_value={}, ) def test_create_client_unexpected_exception_wraps_in_client_creation_error( self, _mock_dest, mock_provider_cls, mock_exporter_fn @@ -167,7 +171,11 @@ def test_create_client_unexpected_exception_wraps_in_client_creation_error( @patch("sap_cloud_sdk.core.auditlog_ng.client._create_log_exporter") @patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider") - def test_config_keyword_args_are_forwarded(self, mock_provider_cls, mock_exporter_fn): + @patch( + "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", + return_value={}, + ) + def test_config_keyword_args_are_forwarded(self, _mock_dest, mock_provider_cls, mock_exporter_fn): mock_provider = Mock() mock_provider.get_logger.return_value = Mock() mock_provider_cls.return_value = mock_provider @@ -245,9 +253,45 @@ def _make_mock_destination( class TestCreateClientFromDestination: # ------------------------------------------------------------------ - # Happy path — all three args required to enter the destination path + # Happy path — destination resolution is always attempted # ------------------------------------------------------------------ + def test_destination_always_attempted_with_defaults( + self, mock_provider_cls, mock_exporter_fn + ): + """_get_config_from_destination is always called, even with no explicit args. + + With defaults destination_name='AuditLogV3_Destination' and + destination_instance='default', create_client() always attempts + destination resolution before falling back to explicit args. + """ + mock_provider = Mock() + mock_provider.get_logger.return_value = Mock() + mock_provider_cls.return_value = mock_provider + + dest = _make_mock_destination( + url="audit.default.com:443", + deployment_id="dep-default", + namespace="ns-default", + ) + 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(insecure=True) + + mock_dest_factory.assert_called_once_with(instance="default") + dest_client.get_destination.assert_called_once() + call_kwargs = dest_client.get_destination.call_args.kwargs + assert call_kwargs["name"] == "AuditLogV3_Destination" + assert isinstance(client, AuditClient) + assert client._config.endpoint == "audit.default.com:443" + assert client._config.deployment_id == "dep-default" + assert client._config.namespace == "ns-default" + def test_destination_happy_path(self, mock_provider_cls, mock_exporter_fn): """Resolved destination with deploymentId sets endpoint/deployment_id/namespace.""" mock_provider = Mock() @@ -548,14 +592,14 @@ def test_destination_not_found_raises(self, mock_provider_cls, mock_exporter_fn) def test_no_destination_explicit_args_still_works( self, mock_provider_cls, mock_exporter_fn ): - """Existing keyword-arg path is unaffected when destination resolution returns None.""" + """Existing keyword-arg path is unaffected when destination resolution returns empty dict.""" mock_provider = Mock() mock_provider.get_logger.return_value = Mock() mock_provider_cls.return_value = mock_provider with patch( "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value=None, + return_value={}, ): client = create_client( endpoint="localhost:4317", @@ -570,7 +614,7 @@ def test_no_destination_explicit_args_still_works( 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.""" + """Existing config-object path is unaffected; destination resolution is skipped when config is given.""" mock_provider = Mock() mock_provider.get_logger.return_value = Mock() mock_provider_cls.return_value = mock_provider From abc929ae8770a732218f4302c13f0193ef201d17 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Thu, 25 Jun 2026 15:58:02 -0300 Subject: [PATCH 11/12] bugfix: add tenant to destination based resolution --- .../core/auditlog_ng/__init__.py | 94 ++++++++++------ .../auditlog_ng/unit/test_create_client.py | 102 +++++++++--------- 2 files changed, 111 insertions(+), 85 deletions(-) diff --git a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py index 74198503..5273d000 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/__init__.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/__init__.py @@ -21,16 +21,28 @@ client = create_client(config=config) Usage: - resolve from a Destination: + resolve from a Destination (requires tenant): from sap_cloud_sdk.core.auditlog_ng import create_client client = create_client( + tenant="my-tenant-subdomain", destination_name="my-audit-destination", destination_instance="my-binding-instance", fragment_name="prod-fragment", # optional ) +Usage: + explicit keyword arguments: + + from sap_cloud_sdk.core.auditlog_ng import create_client + + client = create_client( + endpoint="audit.example.com:443", + deployment_id="my-deployment", + namespace="namespace-123", + ) + # Send an audit event (protobuf message) event_id = client.send(event, "DataAccess") client.close() @@ -67,6 +79,7 @@ def _get_config_from_destination( destination_name: Optional[str], destination_instance: Optional[str], fragment_name: Optional[str] = None, + tenant: Optional[str] = None, ) -> dict[str, str]: """Resolve endpoint, deployment_id and namespace from a named Destination. @@ -84,16 +97,16 @@ def _get_config_from_destination( passed as ``instance=`` to ``destination.create_client()``. fragment_name: Optional fragment name merged into the destination before resolution. Wrapped in ``ConsumptionOptions`` when provided. + tenant: Tenant subdomain forwarded as ``tenant=`` to + ``get_destination()``. Returns: dict with keys ``endpoint``, ``deployment_id``, ``namespace`` - when destination is found. + when destination is found, or an empty dict when not found. - Returns: - None: If destination is not found. - - Return: - ValueError: If required properties are missing. + Raises: + ValueError: If required properties (``deploymentId``/``deploymentRegion`` + or ``namespace``) are missing from the resolved destination. """ # Lazy import — keeps destination an optional dependency; importing auditlog_ng # in environments without the destination package continues to work. @@ -113,7 +126,10 @@ def _get_config_from_destination( ) destination = dest_client.get_destination( - name=destination_name, options=options, level=ConsumptionLevel.SUBACCOUNT + name=destination_name, + options=options, + tenant=tenant, + level=ConsumptionLevel.SUBACCOUNT, ) if destination is None: @@ -153,6 +169,7 @@ def create_client( destination_name: Optional[str] = "AuditLogV3_Destination", destination_instance: Optional[str] = "default", fragment_name: Optional[str] = None, + tenant: Optional[str] = None, # Explicit connection parameters endpoint: Optional[str] = None, deployment_id: Optional[str] = None, @@ -174,28 +191,35 @@ 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`` 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. + 2. **Destination-based resolution** — pass ``tenant`` (required to activate + this path). ``destination_name`` and ``destination_instance`` identify + the destination; ``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. 3. **Explicit keyword arguments** — pass ``endpoint``, ``deployment_id``, - and ``namespace`` directly. + and ``namespace`` directly (used when ``tenant`` is not provided). 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. Must be - combined with ``destination_instance`` to enter the destination path. + tenant: Tenant subdomain used for destination-based resolution. + When provided, ``destination_name`` and ``destination_instance`` + are used to look up the destination. + destination_name: Name of the SAP Destination to resolve. Only used + when ``tenant`` is provided. 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. + as ``instance=`` to ``destination.create_client()``. Only used + when ``tenant`` is provided. fragment_name: Optional destination fragment name merged before resolution. - endpoint: OTLP gRPC endpoint (``host:port``). - deployment_id: Deployment identifier. - namespace: Namespace identifier. + endpoint: OTLP endpoint (``host:port``). Required when ``tenant`` + is not provided and ``config`` is not given. + deployment_id: Deployment identifier. Required when ``tenant`` is not + provided and ``config`` is not given. + namespace: Namespace identifier. Required when ``tenant`` is not + provided and ``config`` is not given. cert_file: Path to client certificate (PEM) for mTLS. key_file: Path to client private key (PEM) for mTLS. ca_file: Path to CA certificate (PEM) for server verification. @@ -216,26 +240,28 @@ def create_client( try: if config is None: try: - resolved = _get_config_from_destination( - destination_name=destination_name, - destination_instance=destination_instance, - fragment_name=fragment_name, - ) - if resolved: - endpoint = resolved["endpoint"] - deployment_id = resolved["deployment_id"] - namespace = resolved["namespace"] + if tenant: + resolved = _get_config_from_destination( + destination_name=destination_name, + destination_instance=destination_instance, + fragment_name=fragment_name, + tenant=tenant, + ) + if resolved: + 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 or valid destination is not provided" + "when config or valid tenant subdomain is not provided" ) config = AuditLogNGConfig( - endpoint=endpoint, - deployment_id=deployment_id, - namespace=namespace, + endpoint=endpoint or "", + deployment_id=deployment_id or "", + namespace=namespace or "", cert_file=cert_file, key_file=key_file, ca_file=ca_file, 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 689235d9..14a303d9 100644 --- a/tests/core/unit/auditlog_ng/unit/test_create_client.py +++ b/tests/core/unit/auditlog_ng/unit/test_create_client.py @@ -31,11 +31,7 @@ def test_create_client_with_config(self, mock_provider_cls, mock_exporter_fn): @patch("sap_cloud_sdk.core.auditlog_ng.client._create_log_exporter") @patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider") - @patch( - "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value={}, - ) - def test_create_client_with_keyword_args(self, _mock_dest, mock_provider_cls, mock_exporter_fn): + def test_create_client_with_keyword_args(self, mock_provider_cls, mock_exporter_fn): mock_provider = Mock() mock_provider.get_logger.return_value = Mock() mock_provider_cls.return_value = mock_provider @@ -49,11 +45,7 @@ def test_create_client_with_keyword_args(self, _mock_dest, mock_provider_cls, mo assert isinstance(client, AuditClient) - @patch( - "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value={}, - ) - def test_create_client_missing_endpoint_raises(self, _mock_dest): + def test_create_client_missing_endpoint_raises(self): with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): create_client(deployment_id="dep-1", namespace="ns-1") @@ -74,11 +66,7 @@ def test_create_client_missing_endpoint_raises(self, _mock_dest): ), ], ) - @patch( - "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value={}, - ) - def test_create_client_config_errors_record_error_metric(self, _mock_dest, kwargs, match): + def test_create_client_config_errors_record_error_metric(self, kwargs, match): with patch( "sap_cloud_sdk.core.auditlog_ng._record_error_metric" ) as mock_error_metric: @@ -97,35 +85,19 @@ def test_create_client_config_errors_record_error_metric(self, _mock_dest, kwarg Operation.AUDITLOG_CREATE_CLIENT, ) - @patch( - "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value={}, - ) - def test_create_client_missing_deployment_id_raises(self, _mock_dest): + def test_create_client_missing_deployment_id_raises(self): with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): create_client(endpoint="localhost:4317", namespace="ns-1") - @patch( - "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value={}, - ) - def test_create_client_missing_namespace_raises(self, _mock_dest): + def test_create_client_missing_namespace_raises(self): with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): create_client(endpoint="localhost:4317", deployment_id="dep-1") - @patch( - "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value={}, - ) - def test_create_client_no_args_raises(self, _mock_dest): + def test_create_client_no_args_raises(self): with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): create_client() - @patch( - "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value={}, - ) - def test_create_client_invalid_deployment_id_raises(self, _mock_dest): + def test_create_client_invalid_deployment_id_raises(self): with pytest.raises(ValueError, match="deployment_id"): create_client( endpoint="localhost:4317", @@ -171,11 +143,7 @@ def test_create_client_unexpected_exception_wraps_in_client_creation_error( @patch("sap_cloud_sdk.core.auditlog_ng.client._create_log_exporter") @patch("sap_cloud_sdk.core.auditlog_ng.client.LoggerProvider") - @patch( - "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination", - return_value={}, - ) - def test_config_keyword_args_are_forwarded(self, _mock_dest, mock_provider_cls, mock_exporter_fn): + def test_config_keyword_args_are_forwarded(self, mock_provider_cls, mock_exporter_fn): mock_provider = Mock() mock_provider.get_logger.return_value = Mock() mock_provider_cls.return_value = mock_provider @@ -256,14 +224,13 @@ class TestCreateClientFromDestination: # Happy path — destination resolution is always attempted # ------------------------------------------------------------------ - def test_destination_always_attempted_with_defaults( + def test_destination_triggered_by_tenant( self, mock_provider_cls, mock_exporter_fn ): - """_get_config_from_destination is always called, even with no explicit args. + """_get_config_from_destination is called only when tenant is provided. - With defaults destination_name='AuditLogV3_Destination' and - destination_instance='default', create_client() always attempts - destination resolution before falling back to explicit args. + With tenant set and defaults destination_name='AuditLogV3_Destination' and + destination_instance='default', create_client() resolves config from the destination. """ mock_provider = Mock() mock_provider.get_logger.return_value = Mock() @@ -281,17 +248,34 @@ def test_destination_always_attempted_with_defaults( "sap_cloud_sdk.destination.create_client", return_value=dest_client, ) as mock_dest_factory: - client = create_client(insecure=True) + client = create_client(tenant="my-tenant", insecure=True) mock_dest_factory.assert_called_once_with(instance="default") dest_client.get_destination.assert_called_once() call_kwargs = dest_client.get_destination.call_args.kwargs assert call_kwargs["name"] == "AuditLogV3_Destination" + assert call_kwargs["tenant"] == "my-tenant" assert isinstance(client, AuditClient) assert client._config.endpoint == "audit.default.com:443" assert client._config.deployment_id == "dep-default" assert client._config.namespace == "ns-default" + def test_destination_not_triggered_without_tenant( + self, mock_provider_cls, mock_exporter_fn + ): + """_get_config_from_destination is NOT called when tenant is not provided. + + Without a tenant, create_client() skips destination resolution and + requires explicit endpoint/deployment_id/namespace. + """ + with patch( + "sap_cloud_sdk.core.auditlog_ng._get_config_from_destination" + ) as mock_get_config: + with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): + create_client(insecure=True) + + mock_get_config.assert_not_called() + def test_destination_happy_path(self, mock_provider_cls, mock_exporter_fn): """Resolved destination with deploymentId sets endpoint/deployment_id/namespace.""" mock_provider = Mock() @@ -311,6 +295,7 @@ def test_destination_happy_path(self, mock_provider_cls, mock_exporter_fn): return_value=dest_client, ): client = create_client( + tenant="my-tenant", destination_name="my-audit-dest", destination_instance="my-instance", fragment_name="prod", @@ -337,6 +322,7 @@ def test_destination_create_client_called_with_instance(self, mock_provider_cls, return_value=dest_client, ) as mock_dest_factory: create_client( + tenant="my-tenant", destination_name="my-audit-dest", destination_instance="my-instance", fragment_name="prod", @@ -360,6 +346,7 @@ def test_destination_fragment_name_forwarded(self, mock_provider_cls, mock_expor return_value=dest_client, ): create_client( + tenant="my-tenant", destination_name="my-audit-dest", destination_instance="my-instance", fragment_name="prod", @@ -377,7 +364,7 @@ def test_destination_fragment_name_forwarded(self, mock_provider_cls, mock_expor def test_destination_name_alone_enters_destination_path( self, mock_provider_cls, mock_exporter_fn ): - """destination_name alone enters the destination path (destination_instance + """tenant + destination_name 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() @@ -392,6 +379,7 @@ def test_destination_name_alone_enters_destination_path( return_value=dest_client, ) as mock_dest_factory: client = create_client( + tenant="my-tenant", destination_name="my-audit-dest", insecure=True, ) @@ -402,7 +390,7 @@ def test_destination_name_alone_enters_destination_path( def test_destination_name_without_fragment_uses_destination_path( self, mock_provider_cls, mock_exporter_fn ): - """destination_name + destination_instance without fragment_name still enters the + """tenant + 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() @@ -417,6 +405,7 @@ def test_destination_name_without_fragment_uses_destination_path( return_value=dest_client, ): client = create_client( + tenant="my-tenant", destination_name="my-audit-dest", destination_instance="inst", insecure=True, @@ -429,7 +418,7 @@ def test_destination_name_without_fragment_uses_destination_path( def test_destination_name_without_instance_uses_default_instance( self, mock_provider_cls, mock_exporter_fn ): - """destination_name with fragment_name but no destination_instance enters the + """tenant + destination_name with fragment_name but no destination_instance enters the destination path, calling _dest_create_client with instance='default'.""" mock_provider = Mock() mock_provider.get_logger.return_value = Mock() @@ -444,6 +433,7 @@ def test_destination_name_without_instance_uses_default_instance( return_value=dest_client, ) as mock_dest_factory: client = create_client( + tenant="my-tenant", destination_name="my-audit-dest", fragment_name="prod", insecure=True, @@ -476,6 +466,7 @@ def test_fallback_deployment_region_when_deployment_id_missing( return_value=dest_client, ): client = create_client( + tenant="my-tenant", destination_name="my-audit-dest", destination_instance="my-instance", fragment_name="prod", @@ -504,6 +495,7 @@ def test_fallback_deployment_region_when_deployment_id_empty( return_value=dest_client, ): client = create_client( + tenant="my-tenant", destination_name="my-audit-dest", destination_instance="my-instance", fragment_name="prod", @@ -530,6 +522,7 @@ def test_missing_both_deployment_props_raises( ): with pytest.raises(ValueError, match="deploymentId.*deploymentRegion"): create_client( + tenant="my-tenant", destination_name="my-audit-dest", destination_instance="my-instance", fragment_name="prod", @@ -547,6 +540,7 @@ def test_missing_namespace_raises(self, mock_provider_cls, mock_exporter_fn): ): with pytest.raises(ValueError, match="namespace"): create_client( + tenant="my-tenant", destination_name="my-audit-dest", destination_instance="my-instance", fragment_name="prod", @@ -564,13 +558,18 @@ def test_missing_url_propagates_as_endpoint_required(self, mock_provider_cls, mo ): with pytest.raises(ValueError, match="endpoint is required"): create_client( + tenant="my-tenant", 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 and no explicit args are given.""" + """Raises ValueError when get_destination returns None and no explicit args are given. + + When tenant is set but the destination is not found, _get_config_from_destination + returns {} and AuditLogNGConfig is called with endpoint=None, raising 'endpoint is required'. + """ dest_client = MagicMock() dest_client.get_destination.return_value = None @@ -578,8 +577,9 @@ def test_destination_not_found_raises(self, mock_provider_cls, mock_exporter_fn) "sap_cloud_sdk.destination.create_client", return_value=dest_client, ): - with pytest.raises(ValueError, match="endpoint, deployment_id, and namespace are required"): + with pytest.raises(ValueError, match="endpoint is required"): create_client( + tenant="my-tenant", destination_name="missing-dest", destination_instance="my-instance", fragment_name="prod", From 09731ab60cd084f774e1c3c2543ae883c9d49296 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Thu, 25 Jun 2026 16:15:49 -0300 Subject: [PATCH 12/12] bump: version bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 725e69cc..b297bd73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.29.0" +version = "0.29.1" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0"