diff --git a/pyproject.toml b/pyproject.toml index a9e4a46a..80243ae8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.35.0" +version = "0.35.3" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/agentgateway/_customer.py b/src/sap_cloud_sdk/agentgateway/_customer.py index be5019ea..f915f140 100644 --- a/src/sap_cloud_sdk/agentgateway/_customer.py +++ b/src/sap_cloud_sdk/agentgateway/_customer.py @@ -464,6 +464,15 @@ async def _list_server_tools( server_name = init_result.serverInfo.name result = await session.list_tools() + if result is None or result.tools is None: + logger.warning( + "list_tools() returned no tools (response=%r); dependency %r skipped — " + "check MCP server health", + result, + dependency.ord_id, + ) + return [] + return [ MCPTool( name=t.name, diff --git a/src/sap_cloud_sdk/agentgateway/_lob.py b/src/sap_cloud_sdk/agentgateway/_lob.py index 83ad4ebd..5c75d73c 100644 --- a/src/sap_cloud_sdk/agentgateway/_lob.py +++ b/src/sap_cloud_sdk/agentgateway/_lob.py @@ -325,6 +325,14 @@ async def list_server_tools( else fragment_name ) result = await session.list_tools() + if result is None or result.tools is None: + logger.warning( + "list_tools() returned no tools (response=%r); fragment %r skipped — " + "check MCP server health and OpenTelemetry MCP instrumentation", + result, + fragment_name, + ) + return [] return [ MCPTool( name=t.name, @@ -388,12 +396,35 @@ async def get_mcp_tools_lob( len(server_tools), fragment_name, ) + # intentional: fragment failure must not abort remaining fragments + # (HTTPStatusError: 403 → warning + continue; other status → exception log + skip) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 403: + logger.warning( + "HTTP 403 listing tools from fragment '%s' with system token — " + "pass user_token to list_mcp_tools() for user-scoped tool discovery", + fragment_name, + ) + continue + logger.exception( + "Failed to load tools from fragment '%s' — skipping", + fragment_name, + ) + # intentional: fragment failure must not abort remaining fragments + # (unexpected errors → exception log + skip) except Exception: logger.exception( "Failed to load tools from fragment '%s' — skipping", fragment_name, ) + if fragments and not tools: + logger.warning( + "No MCP tools loaded from %d fragment(s) for tenant '%s'", + len(fragments), + tenant_subdomain, + ) + logger.info("Loaded %d MCP tool(s) from %d fragment(s)", len(tools), len(fragments)) return tools diff --git a/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index 686d9f37..ceecbf65 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -270,3 +270,19 @@ class MCPTool: url: str fragment_name: str | None ``` + +## Troubleshooting (LoB MCP) + +### Empty or null tool lists + +If during `list_mcp_tools()` discovery the MCP session returns no tool list (`session.list_tools()` is `None` or `tools` is `None`), the SDK logs a warning and skips that destination fragment instead of raising an error. Discovery continues with remaining fragments. This often happens when OpenTelemetry MCP instrumentation swallows errors. SDK 0.35.3+ treats null responses defensively; see the telemetry and OpenTelemetry sections below for optional agent-side workarounds. + +### HTTP 403 during discovery + +Many LoB landscapes require a **user-scoped token** for MCP tool listing. Pass `user_token` on **every** `list_mcp_tools()` call that must see user-scoped tools. If one code path calls `list_mcp_tools()` without `user_token` while another passes it, you may get HTTP 403 on some fragments, zero tools from that path, and misleading errors such as “no tool for role” even when ordId mapping is correct. + +### OpenTelemetry and MCP + +Call `auto_instrument()` from `sap_cloud_sdk.core.telemetry` before importing MCP or AI libraries. The SDK does **not** remove OpenTelemetry MCP wrappers during `auto_instrument()` — instrumentation should stay enabled. + +If you still see null tool lists during `list_mcp_tools()` with MCP OTel enabled, you can apply an agent-side unwrap after `auto_instrument()` and before MCP imports (for example `opentelemetry.instrumentation.utils.unwrap` on `BaseSession.send_request` and both `streamablehttp_client` / `streamable_http_client` in `mcp.client.streamable_http`) until `opentelemetry-instrumentation-mcp` is fixed upstream. Prefer the SDK None guard so discovery continues without disabling telemetry. diff --git a/src/sap_cloud_sdk/core/telemetry/user-guide.md b/src/sap_cloud_sdk/core/telemetry/user-guide.md index 019bc1ac..4b6f72e6 100644 --- a/src/sap_cloud_sdk/core/telemetry/user-guide.md +++ b/src/sap_cloud_sdk/core/telemetry/user-guide.md @@ -29,6 +29,9 @@ from litellm import completion # LLM calls are now automatically traced ``` +If OpenTelemetry MCP instrumentation (`opentelemetry-instrumentation-mcp`) swallows protocol errors during `list_mcp_tools()` discovery (the underlying MCP `session.list_tools()` may return `None`), the Agent Gateway SDK logs a warning and skips that fragment instead of crashing (SDK 0.35.3+). Removing instrumentation is not recommended. Until upstream fixes land, agents may keep an application-side unwrap after `auto_instrument()` (see Agent Gateway user guide — OpenTelemetry and MCP). + + ### 2. Add business context with a parent span Wrap your LLM calls to add the context autoinstrumentation can't provide: diff --git a/tests/agentgateway/unit/test_lob.py b/tests/agentgateway/unit/test_lob.py index e2e66723..0f309a7d 100644 --- a/tests/agentgateway/unit/test_lob.py +++ b/tests/agentgateway/unit/test_lob.py @@ -3,6 +3,7 @@ import os from unittest.mock import patch, MagicMock, AsyncMock +import httpx import pytest from sap_cloud_sdk.agentgateway._fragments import ( @@ -21,6 +22,7 @@ fetch_user_auth, get_ias_client_id_lob, get_mcp_tools_lob, + list_server_tools, get_agent_cards_lob, _fetch_agent_card, call_mcp_tool_lob, @@ -29,8 +31,10 @@ from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.config import ClientConfig from sap_cloud_sdk.destination import ConsumptionOptions, ConsumptionLevel -from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError, MCPServerNotFoundError -from sap_cloud_sdk.destination import ConsumptionLevel +from sap_cloud_sdk.agentgateway.exceptions import ( + AgentGatewaySDKError, + MCPServerNotFoundError, +) # Aliases for use in existing test assertions _LABEL_KEY = LABEL_KEY @@ -109,7 +113,9 @@ def test_strips_trailing_slashes_from_url(self): mock_dest.auth_tokens[0].http_header = {"value": header_value} mock_dest.url = "https://agw.example.com/v1/mcp///" - with patch("sap_cloud_sdk.agentgateway._lob.create_destination_client") as mock_client: + with patch( + "sap_cloud_sdk.agentgateway._lob.create_destination_client" + ) as mock_client: mock_client.return_value.get_destination.return_value = mock_dest result = _fetch_auth_token("dest-name", "tenant-sub") @@ -290,7 +296,9 @@ def test_returns_fragment_name(self): fragment = MagicMock() fragment.name = "sap-managed-runtime-agw-subscriber-ias-user-abc123" - with patch("sap_cloud_sdk.agentgateway._fragments.create_fragment_client") as mock_client: + with patch( + "sap_cloud_sdk.agentgateway._fragments.create_fragment_client" + ) as mock_client: mock_client.return_value.list_instance_fragments.return_value = [fragment] result = get_ias_user_fragment_name("tenant-sub") @@ -302,7 +310,9 @@ def test_uses_correct_filter_labels(self): fragment = MagicMock() fragment.name = "ias-user-fragment" - with patch("sap_cloud_sdk.agentgateway._fragments.create_fragment_client") as mock_client: + with patch( + "sap_cloud_sdk.agentgateway._fragments.create_fragment_client" + ) as mock_client: mock_client.return_value.list_instance_fragments.return_value = [fragment] get_ias_user_fragment_name("tenant-sub") @@ -316,10 +326,14 @@ def test_uses_correct_filter_labels(self): def test_raises_when_no_fragment_found(self): """Raise MCPServerNotFoundError when no IAS user fragment exists.""" - with patch("sap_cloud_sdk.agentgateway._fragments.create_fragment_client") as mock_client: + with patch( + "sap_cloud_sdk.agentgateway._fragments.create_fragment_client" + ) as mock_client: mock_client.return_value.list_instance_fragments.return_value = [] - with pytest.raises(MCPServerNotFoundError, match="No IAS user fragment found"): + with pytest.raises( + MCPServerNotFoundError, match="No IAS user fragment found" + ): get_ias_user_fragment_name("tenant-sub") @@ -399,7 +413,9 @@ async def test_reuses_cached_system_auth(self): async def test_raises_when_only_token_cache_provided(self): """Raise ValueError when token_cache given without gateway_url_cache.""" with pytest.raises(ValueError, match="both be provided or both be None"): - await fetch_system_auth("tenant-sub", token_cache=_TokenCache(ClientConfig())) + await fetch_system_auth( + "tenant-sub", token_cache=_TokenCache(ClientConfig()) + ) @pytest.mark.asyncio async def test_raises_when_only_gateway_url_cache_provided(self): @@ -424,10 +440,16 @@ async def test_fetches_user_auth_with_ias_user_fragment(self): with patch.dict(os.environ, {"APPFND_CONHOS_LANDSCAPE": "eu10"}): with ( - patch("sap_cloud_sdk.agentgateway._lob.get_ias_user_fragment_name") as mock_ias_user, - patch("sap_cloud_sdk.agentgateway._lob._fetch_auth_token") as mock_fetch, + patch( + "sap_cloud_sdk.agentgateway._lob.get_ias_user_fragment_name" + ) as mock_ias_user, + patch( + "sap_cloud_sdk.agentgateway._lob._fetch_auth_token" + ) as mock_fetch, ): - mock_ias_user.return_value = "sap-managed-runtime-agw-subscriber-ias-user-abc" + mock_ias_user.return_value = ( + "sap-managed-runtime-agw-subscriber-ias-user-abc" + ) mock_fetch.return_value = (raw_token, gateway_url) result = await fetch_user_auth("user-jwt", "tenant-sub") @@ -440,7 +462,10 @@ async def test_fetches_user_auth_with_ias_user_fragment(self): assert call_args[0][1] == "tenant-sub" options = call_args[0][2] assert options.user_token == "user-jwt" - assert options.fragment_name == "sap-managed-runtime-agw-subscriber-ias-user-abc" + assert ( + options.fragment_name + == "sap-managed-runtime-agw-subscriber-ias-user-abc" + ) assert options.fragment_level == ConsumptionLevel.INSTANCE @pytest.mark.asyncio @@ -481,13 +506,17 @@ async def test_reuses_cached_user_auth(self): async def test_raises_when_only_token_cache_provided(self): """Raise ValueError when token_cache given without gateway_url_cache.""" with pytest.raises(ValueError, match="both be provided or both be None"): - await fetch_user_auth("user-jwt", "tenant-sub", token_cache=_TokenCache(ClientConfig())) + await fetch_user_auth( + "user-jwt", "tenant-sub", token_cache=_TokenCache(ClientConfig()) + ) @pytest.mark.asyncio async def test_raises_when_only_gateway_url_cache_provided(self): """Raise ValueError when gateway_url_cache given without token_cache.""" with pytest.raises(ValueError, match="both be provided or both be None"): - await fetch_user_auth("user-jwt", "tenant-sub", gateway_url_cache=_GatewayUrlCache()) + await fetch_user_auth( + "user-jwt", "tenant-sub", gateway_url_cache=_GatewayUrlCache() + ) # ============================================================ @@ -599,6 +628,205 @@ async def mock_list_tools_fn(*args, **kwargs): assert len(result) == 1 assert result[0].name == "tool2" + @pytest.mark.asyncio + async def test_logs_hint_on_http_403_with_system_token(self, caplog): + """Log a user_token hint when system auth gets HTTP 403 on list_tools.""" + import logging + + caplog.set_level(logging.WARNING, logger="sap_cloud_sdk.agentgateway._lob") + + fragment = MagicMock() + fragment.name = "mcp-server-a" + fragment.properties = {"URL": "https://example.com/mcp"} + + response = MagicMock() + response.status_code = 403 + exc = httpx.HTTPStatusError( + "Forbidden", + request=MagicMock(), + response=response, + ) + + with ( + patch("sap_cloud_sdk.agentgateway._lob.list_mcp_fragments") as mock_list, + patch( + "sap_cloud_sdk.agentgateway._lob.list_server_tools", + new_callable=AsyncMock, + side_effect=exc, + ), + ): + mock_list.return_value = [fragment] + + result = await get_mcp_tools_lob("tenant-sub", "system-token", 60.0) + + assert result == [] + assert "HTTP 403" in caplog.text + assert "user_token" in caplog.text + assert "list_mcp_tools" in caplog.text + assert "Phase 2" not in caplog.text + + @pytest.mark.asyncio + async def test_warns_when_all_fragments_fail_to_load(self, caplog): + """Log aggregate warning when every fragment fails tool discovery.""" + import logging + + caplog.set_level(logging.WARNING, logger="sap_cloud_sdk.agentgateway._lob") + + fragment = MagicMock() + fragment.name = "mcp-server-a" + fragment.properties = {"URL": "https://example.com/mcp"} + + response = MagicMock() + response.status_code = 403 + exc = httpx.HTTPStatusError( + "Forbidden", + request=MagicMock(), + response=response, + ) + + with ( + patch("sap_cloud_sdk.agentgateway._lob.list_mcp_fragments") as mock_list, + patch( + "sap_cloud_sdk.agentgateway._lob.list_server_tools", + new_callable=AsyncMock, + side_effect=exc, + ), + ): + mock_list.return_value = [fragment] + + result = await get_mcp_tools_lob("tenant-sub", "system-token", 60.0) + + assert result == [] + assert "No MCP tools loaded from 1 fragment(s)" in caplog.text + + @pytest.mark.asyncio + async def test_continues_when_fragment_returns_empty_from_none_guard(self): + """Continue processing when one fragment hits the list_tools None guard.""" + fragment1 = MagicMock() + fragment1.name = "mcp-server1" + fragment1.properties = {"URL": "https://example1.com/mcp"} + + fragment2 = MagicMock() + fragment2.name = "mcp-server2" + fragment2.properties = {"URL": "https://example2.com/mcp"} + + mock_tool = MCPTool( + name="tool2", + server_name="server2", + description="Test", + input_schema={}, + url="https://example2.com/mcp", + fragment_name="mcp-server2", + ) + + call_count = 0 + + async def mock_list_tools_fn(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return [] + return [mock_tool] + + with ( + patch("sap_cloud_sdk.agentgateway._lob.list_mcp_fragments") as mock_list, + patch( + "sap_cloud_sdk.agentgateway._lob.list_server_tools", + side_effect=mock_list_tools_fn, + ), + ): + mock_list.return_value = [fragment1, fragment2] + + result = await get_mcp_tools_lob("tenant-sub", "system-token", 60.0) + + assert len(result) == 1 + assert result[0].name == "tool2" + + +# ============================================================ +# Test: list_server_tools +# ============================================================ + + +class TestListServerTools: + """Tests for list_server_tools async function.""" + + @pytest.mark.asyncio + async def test_returns_empty_when_list_tools_returns_none(self, caplog): + """Return empty list when MCP list_tools response is None (OTel instrumentation).""" + import logging + + caplog.set_level(logging.WARNING, logger="sap_cloud_sdk.agentgateway._lob") + + with ( + patch("sap_cloud_sdk.agentgateway._lob.httpx.AsyncClient") as mock_http, + patch( + "sap_cloud_sdk.agentgateway._lob.streamable_http_client" + ) as mock_stream, + patch("sap_cloud_sdk.agentgateway._lob.ClientSession") as mock_session, + ): + mock_http_instance = AsyncMock() + mock_http.return_value.__aenter__.return_value = mock_http_instance + + mock_stream.return_value.__aenter__.return_value = ( + AsyncMock(), + AsyncMock(), + None, + ) + + mock_session_instance = AsyncMock() + mock_init = MagicMock() + mock_init.serverInfo.name = "test-server" + mock_session_instance.initialize = AsyncMock(return_value=mock_init) + mock_session_instance.list_tools = AsyncMock(return_value=None) + mock_session.return_value.__aenter__.return_value = mock_session_instance + + result = await list_server_tools( + "https://example.com/mcp", "auth-token", "frag-a", 60.0 + ) + + assert result == [] + assert "list_tools() returned no tools" in caplog.text + + @pytest.mark.asyncio + async def test_returns_empty_when_tools_attribute_is_none(self, caplog): + """Return empty list when list_tools result has tools=None.""" + import logging + + caplog.set_level(logging.WARNING, logger="sap_cloud_sdk.agentgateway._lob") + + with ( + patch("sap_cloud_sdk.agentgateway._lob.httpx.AsyncClient") as mock_http, + patch( + "sap_cloud_sdk.agentgateway._lob.streamable_http_client" + ) as mock_stream, + patch("sap_cloud_sdk.agentgateway._lob.ClientSession") as mock_session, + ): + mock_http_instance = AsyncMock() + mock_http.return_value.__aenter__.return_value = mock_http_instance + + mock_stream.return_value.__aenter__.return_value = ( + AsyncMock(), + AsyncMock(), + None, + ) + + mock_session_instance = AsyncMock() + mock_init = MagicMock() + mock_init.serverInfo.name = "test-server" + mock_session_instance.initialize = AsyncMock(return_value=mock_init) + mock_list_result = MagicMock() + mock_list_result.tools = None + mock_session_instance.list_tools = AsyncMock(return_value=mock_list_result) + mock_session.return_value.__aenter__.return_value = mock_session_instance + + result = await list_server_tools( + "https://example.com/mcp", "auth-token", "frag-a", 60.0 + ) + + assert result == [] + assert "list_tools() returned no tools" in caplog.text + # ============================================================ # Test: call_mcp_tool_lob @@ -716,15 +944,16 @@ class TestOrdIdFromUrl: def test_extracts_ord_id_from_standard_url(self): """Return the second-to-last path segment as ord_id.""" - assert _ord_id_from_url( - "https://agw.example.com/v1/a2a/sap.s4:agent:v1/tenant-abc" - ) == "sap.s4:agent:v1" + assert ( + _ord_id_from_url( + "https://agw.example.com/v1/a2a/sap.s4:agent:v1/tenant-abc" + ) + == "sap.s4:agent:v1" + ) def test_strips_trailing_slash(self): """Handle trailing slash on URL.""" - assert _ord_id_from_url( - "https://agw.example.com/v1/a2a/ord-1/gt-1/" - ) == "ord-1" + assert _ord_id_from_url("https://agw.example.com/v1/a2a/ord-1/gt-1/") == "ord-1" def test_returns_empty_for_single_segment(self): """Return empty string when URL has only one path segment.""" @@ -746,7 +975,9 @@ def test_lists_fragments_with_a2a_label(self): with patch( "sap_cloud_sdk.agentgateway._fragments.create_fragment_client" ) as mock_client: - mock_client.return_value.list_instance_fragments.return_value = [mock_fragment] + mock_client.return_value.list_instance_fragments.return_value = [ + mock_fragment + ] result = list_a2a_fragments("tenant-sub") assert result == [mock_fragment] @@ -830,7 +1061,9 @@ async def test_raises_on_non_200_status(self): mock_http.return_value.__aenter__.return_value = mock_http_instance with pytest.raises(AgentGatewaySDKError, match="404"): - await _fetch_agent_card("https://agw.example.com/base", "auth-token", 60.0) + await _fetch_agent_card( + "https://agw.example.com/base", "auth-token", 60.0 + ) @pytest.mark.asyncio async def test_raises_on_request_error(self): @@ -845,7 +1078,9 @@ async def test_raises_on_request_error(self): mock_http.return_value.__aenter__.return_value = mock_http_instance with pytest.raises(AgentGatewaySDKError, match="Agent card request failed"): - await _fetch_agent_card("https://agw.example.com/base", "auth-token", 60.0) + await _fetch_agent_card( + "https://agw.example.com/base", "auth-token", 60.0 + ) # ============================================================ @@ -881,9 +1116,7 @@ async def test_returns_agents_for_all_fragments(self): return_value=AgentCard(raw=card_payload), ), ): - result = await get_agent_cards_lob( - "tenant-sub", "system-token", 60.0 - ) + result = await get_agent_cards_lob("tenant-sub", "system-token", 60.0) assert len(result) == 1 assert isinstance(result[0], Agent) @@ -904,8 +1137,12 @@ async def test_returns_empty_list_when_no_fragments(self): @pytest.mark.asyncio async def test_filters_by_agent_names(self): """Fetch all cards then keep only those whose agent card name matches.""" - frag_1 = self._make_fragment("frag-1", "https://agw.example.com/v1/a2a/ord-1/t1") - frag_2 = self._make_fragment("frag-2", "https://agw.example.com/v1/a2a/ord-2/t2") + frag_1 = self._make_fragment( + "frag-1", "https://agw.example.com/v1/a2a/ord-1/t1" + ) + frag_2 = self._make_fragment( + "frag-2", "https://agw.example.com/v1/a2a/ord-2/t2" + ) async def _cards_by_ord(fragment_url, token, timeout): if "ord-1" in fragment_url: @@ -933,8 +1170,12 @@ async def _cards_by_ord(fragment_url, token, timeout): @pytest.mark.asyncio async def test_filters_by_ord_ids(self): """Only include fragments whose ordId (from URL) is in the ord_ids filter.""" - frag_1 = self._make_fragment("frag-1", "https://agw.example.com/v1/a2a/ord-1/t1") - frag_2 = self._make_fragment("frag-2", "https://agw.example.com/v1/a2a/ord-2/t2") + frag_1 = self._make_fragment( + "frag-1", "https://agw.example.com/v1/a2a/ord-1/t1" + ) + frag_2 = self._make_fragment( + "frag-2", "https://agw.example.com/v1/a2a/ord-2/t2" + ) with ( patch( @@ -1026,8 +1267,14 @@ def test_returns_client_id_from_destination_properties(self): mock_dest_client.get_destination.return_value = mock_dest with ( - patch("sap_cloud_sdk.agentgateway._lob._ias_dest_name", return_value="sap-managed-runtime-ias-eu10"), - patch("sap_cloud_sdk.agentgateway._lob.create_destination_client", return_value=mock_dest_client), + patch( + "sap_cloud_sdk.agentgateway._lob._ias_dest_name", + return_value="sap-managed-runtime-ias-eu10", + ), + patch( + "sap_cloud_sdk.agentgateway._lob.create_destination_client", + return_value=mock_dest_client, + ), ): result = get_ias_client_id_lob() @@ -1043,10 +1290,18 @@ def test_raises_when_destination_not_found(self): mock_dest_client.get_destination.return_value = None with ( - patch("sap_cloud_sdk.agentgateway._lob._ias_dest_name", return_value="sap-managed-runtime-ias-eu10"), - patch("sap_cloud_sdk.agentgateway._lob.create_destination_client", return_value=mock_dest_client), + patch( + "sap_cloud_sdk.agentgateway._lob._ias_dest_name", + return_value="sap-managed-runtime-ias-eu10", + ), + patch( + "sap_cloud_sdk.agentgateway._lob.create_destination_client", + return_value=mock_dest_client, + ), ): - with pytest.raises(AgentGatewaySDKError, match="sap-managed-runtime-ias-eu10"): + with pytest.raises( + AgentGatewaySDKError, match="sap-managed-runtime-ias-eu10" + ): get_ias_client_id_lob() def test_returns_empty_string_when_property_absent(self): @@ -1056,14 +1311,23 @@ def test_returns_empty_string_when_property_absent(self): mock_dest_client.get_destination.return_value = mock_dest with ( - patch("sap_cloud_sdk.agentgateway._lob._ias_dest_name", return_value="sap-managed-runtime-ias-eu10"), - patch("sap_cloud_sdk.agentgateway._lob.create_destination_client", return_value=mock_dest_client), + patch( + "sap_cloud_sdk.agentgateway._lob._ias_dest_name", + return_value="sap-managed-runtime-ias-eu10", + ), + patch( + "sap_cloud_sdk.agentgateway._lob.create_destination_client", + return_value=mock_dest_client, + ), ): result = get_ias_client_id_lob() assert result == "" def test_raises_when_landscape_env_not_set(self): - with patch("sap_cloud_sdk.agentgateway._lob._ias_dest_name", side_effect=EnvironmentError("APPFND_CONHOS_LANDSCAPE not set")): + with patch( + "sap_cloud_sdk.agentgateway._lob._ias_dest_name", + side_effect=EnvironmentError("APPFND_CONHOS_LANDSCAPE not set"), + ): with pytest.raises(EnvironmentError, match="APPFND_CONHOS_LANDSCAPE"): get_ias_client_id_lob() diff --git a/tests/core/unit/telemetry/test_auto_instrument.py b/tests/core/unit/telemetry/test_auto_instrument.py index 98ed6328..667834e9 100644 --- a/tests/core/unit/telemetry/test_auto_instrument.py +++ b/tests/core/unit/telemetry/test_auto_instrument.py @@ -15,16 +15,49 @@ def mock_traceloop_components(): """Fixture that mocks Traceloop SDK components.""" with ExitStack() as stack: mocks = { - 'traceloop': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.Traceloop')), - 'grpc_exporter': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.GRPCSpanExporter')), - 'http_exporter': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.HTTPSpanExporter')), - 'console_exporter': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.ConsoleSpanExporter')), - 'transformer': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.GenAIAttributeTransformer')), - 'baggage_processor': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.BaggageSpanProcessor')), - 'propagated_processor': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.PropagatedAttributesSpanProcessor')), - 'get_tracer_provider': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.trace.get_tracer_provider', return_value=create_autospec(SDKTracerProvider))), - 'create_resource': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.create_resource_attributes_from_env')), - 'get_app_name': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument._get_app_name')), + "traceloop": stack.enter_context( + patch("sap_cloud_sdk.core.telemetry.auto_instrument.Traceloop") + ), + "grpc_exporter": stack.enter_context( + patch("sap_cloud_sdk.core.telemetry.auto_instrument.GRPCSpanExporter") + ), + "http_exporter": stack.enter_context( + patch("sap_cloud_sdk.core.telemetry.auto_instrument.HTTPSpanExporter") + ), + "console_exporter": stack.enter_context( + patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.ConsoleSpanExporter" + ) + ), + "transformer": stack.enter_context( + patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.GenAIAttributeTransformer" + ) + ), + "baggage_processor": stack.enter_context( + patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.BaggageSpanProcessor" + ) + ), + "propagated_processor": stack.enter_context( + patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.PropagatedAttributesSpanProcessor" + ) + ), + "get_tracer_provider": stack.enter_context( + patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.trace.get_tracer_provider", + return_value=create_autospec(SDKTracerProvider), + ) + ), + "create_resource": stack.enter_context( + patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.create_resource_attributes_from_env" + ) + ), + "get_app_name": stack.enter_context( + patch("sap_cloud_sdk.core.telemetry.auto_instrument._get_app_name") + ), } yield mocks @@ -34,344 +67,492 @@ class TestAutoInstrument: def test_auto_instrument_with_endpoint_success(self, mock_traceloop_components): """Test successful auto-instrumentation with valid endpoint.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {'service.name': 'test-app'} + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = { + "service.name": "test-app" + } - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument() # Verify Traceloop was initialized - mock_traceloop_components['traceloop'].init.assert_called_once() - call_kwargs = mock_traceloop_components['traceloop'].init.call_args[1] - assert call_kwargs['app_name'] == 'test-app' - assert call_kwargs['should_enrich_metrics'] is True - assert call_kwargs['disable_batch'] is False - - def test_auto_instrument_uses_grpc_exporter_by_default(self, mock_traceloop_components): + mock_traceloop_components["traceloop"].init.assert_called_once() + call_kwargs = mock_traceloop_components["traceloop"].init.call_args[1] + assert call_kwargs["app_name"] == "test-app" + assert call_kwargs["should_enrich_metrics"] is True + assert call_kwargs["disable_batch"] is False + + def test_auto_instrument_uses_grpc_exporter_by_default( + self, mock_traceloop_components + ): """Test that auto_instrument uses gRPC exporter by default, letting it read endpoint from env.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument() - mock_traceloop_components['grpc_exporter'].assert_called_once_with() - mock_traceloop_components['http_exporter'].assert_not_called() + mock_traceloop_components["grpc_exporter"].assert_called_once_with() + mock_traceloop_components["http_exporter"].assert_not_called() - def test_auto_instrument_creates_resource_with_attributes(self, mock_traceloop_components): + def test_auto_instrument_creates_resource_with_attributes( + self, mock_traceloop_components + ): """Test that auto_instrument creates resource with correct attributes.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = { - 'service.name': 'test-app', - 'sap.cloud_sdk.language': 'python' + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = { + "service.name": "test-app", + "sap.cloud_sdk.language": "python", } - with patch.dict('os.environ', { - 'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317', - 'APPFND_CONHOS_APP_NAME': 'test-app', - 'HOSTNAME': 'test-host', - 'APPFND_CONHOS_REGION': 'us-east-1', - 'APPFND_CONHOS_ENVIRONMENT': 'production', - 'APPFND_CONHOS_SUBACCOUNTID': 'sub-123' - }, clear=True): + with patch.dict( + "os.environ", + { + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", + "APPFND_CONHOS_APP_NAME": "test-app", + "HOSTNAME": "test-host", + "APPFND_CONHOS_REGION": "us-east-1", + "APPFND_CONHOS_ENVIRONMENT": "production", + "APPFND_CONHOS_SUBACCOUNTID": "sub-123", + }, + clear=True, + ): auto_instrument() # Verify resource was created - mock_traceloop_components['create_resource'].assert_called_once() + mock_traceloop_components["create_resource"].assert_called_once() def test_auto_instrument_logs_initialization(self, mock_traceloop_components): """Test that auto_instrument logs initialization messages.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): - with patch('sap_cloud_sdk.core.telemetry.auto_instrument.logger') as mock_logger: + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.logger" + ) as mock_logger: auto_instrument() # Verify info logs were called assert mock_logger.info.call_count >= 1 # Check for initialization message info_calls = [call[0][0] for call in mock_logger.info.call_args_list] - assert any('initialized successfully' in msg.lower() for msg in info_calls) + assert any( + "initialized successfully" in msg.lower() for msg in info_calls + ) def test_auto_instrument_with_trailing_slash(self, mock_traceloop_components): """Test that auto_instrument works with a trailing slash endpoint (exporter reads from env).""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317/'}, clear=True): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317/"}, + clear=True, + ): auto_instrument() - mock_traceloop_components['grpc_exporter'].assert_called_once_with() + mock_traceloop_components["grpc_exporter"].assert_called_once_with() - def test_auto_instrument_with_http_protobuf_protocol(self, mock_traceloop_components): + def test_auto_instrument_with_http_protobuf_protocol( + self, mock_traceloop_components + ): """Test that auto_instrument uses HTTP exporter when OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch.dict('os.environ', { - 'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4318', - 'OTEL_EXPORTER_OTLP_PROTOCOL': 'http/protobuf' - }, clear=True): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch.dict( + "os.environ", + { + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318", + "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf", + }, + clear=True, + ): auto_instrument() - mock_traceloop_components['http_exporter'].assert_called_once_with() - mock_traceloop_components['grpc_exporter'].assert_not_called() + mock_traceloop_components["http_exporter"].assert_called_once_with() + mock_traceloop_components["grpc_exporter"].assert_not_called() - def test_auto_instrument_passes_transformer_to_traceloop(self, mock_traceloop_components): + def test_auto_instrument_passes_transformer_to_traceloop( + self, mock_traceloop_components + ): """Test that auto_instrument passes the GenAIAttributeTransformer as exporter to Traceloop.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} mock_transformer_instance = MagicMock() - mock_traceloop_components['transformer'].return_value = mock_transformer_instance - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + mock_traceloop_components[ + "transformer" + ].return_value = mock_transformer_instance + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument() # Verify transformer was created with base exporter - mock_traceloop_components['transformer'].assert_called_once() + mock_traceloop_components["transformer"].assert_called_once() # Verify Traceloop.init was called with the transformer as exporter - call_kwargs = mock_traceloop_components['traceloop'].init.call_args[1] - assert call_kwargs['exporter'] == mock_transformer_instance + call_kwargs = mock_traceloop_components["traceloop"].init.call_args[1] + assert call_kwargs["exporter"] == mock_transformer_instance - def test_auto_instrument_legacy_schema_parameter_ignored(self, mock_traceloop_components): + def test_auto_instrument_legacy_schema_parameter_ignored( + self, mock_traceloop_components + ): """Test that legacy_schema parameter is accepted but doesn't affect behavior.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): # Should not raise an error auto_instrument() auto_instrument() auto_instrument() # Verify Traceloop was initialized each time - assert mock_traceloop_components['traceloop'].init.call_count == 3 + assert mock_traceloop_components["traceloop"].init.call_count == 3 def test_auto_instrument_with_console_exporter(self, mock_traceloop_components): """Test that auto_instrument uses ConsoleSpanExporter when OTEL_TRACES_EXPORTER=console.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} - with patch.dict('os.environ', {'OTEL_TRACES_EXPORTER': 'console'}, clear=True): + with patch.dict("os.environ", {"OTEL_TRACES_EXPORTER": "console"}, clear=True): auto_instrument() - mock_traceloop_components['console_exporter'].assert_called_once_with() - mock_traceloop_components['grpc_exporter'].assert_not_called() - mock_traceloop_components['http_exporter'].assert_not_called() - mock_traceloop_components['traceloop'].init.assert_called_once() + mock_traceloop_components["console_exporter"].assert_called_once_with() + mock_traceloop_components["grpc_exporter"].assert_not_called() + mock_traceloop_components["http_exporter"].assert_not_called() + mock_traceloop_components["traceloop"].init.assert_called_once() - def test_auto_instrument_console_exporter_case_insensitive(self, mock_traceloop_components): + def test_auto_instrument_console_exporter_case_insensitive( + self, mock_traceloop_components + ): """Test that OTEL_TRACES_EXPORTER=console matching is case insensitive.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} - for value in ['CONSOLE', 'Console', 'CONSOLE']: - mock_traceloop_components['console_exporter'].reset_mock() - mock_traceloop_components['traceloop'].reset_mock() - with patch.dict('os.environ', {'OTEL_TRACES_EXPORTER': value}, clear=True): + for value in ["CONSOLE", "Console", "CONSOLE"]: + mock_traceloop_components["console_exporter"].reset_mock() + mock_traceloop_components["traceloop"].reset_mock() + with patch.dict("os.environ", {"OTEL_TRACES_EXPORTER": value}, clear=True): auto_instrument() - mock_traceloop_components['console_exporter'].assert_called_once_with() + mock_traceloop_components["console_exporter"].assert_called_once_with() - def test_auto_instrument_console_wins_when_both_set(self, mock_traceloop_components): + def test_auto_instrument_console_wins_when_both_set( + self, mock_traceloop_components + ): """Test that console exporter is used when OTEL_TRACES_EXPORTER=console, even if OTLP endpoint is also set.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch.dict('os.environ', { - 'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317', - 'OTEL_TRACES_EXPORTER': 'console', - }, clear=True): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch.dict( + "os.environ", + { + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", + "OTEL_TRACES_EXPORTER": "console", + }, + clear=True, + ): auto_instrument() - mock_traceloop_components['console_exporter'].assert_called_once_with() - mock_traceloop_components['grpc_exporter'].assert_not_called() - mock_traceloop_components['http_exporter'].assert_not_called() + mock_traceloop_components["console_exporter"].assert_called_once_with() + mock_traceloop_components["grpc_exporter"].assert_not_called() + mock_traceloop_components["http_exporter"].assert_not_called() - def test_auto_instrument_console_wraps_with_transformer(self, mock_traceloop_components): + def test_auto_instrument_console_wraps_with_transformer( + self, mock_traceloop_components + ): """Test that ConsoleSpanExporter is wrapped with GenAIAttributeTransformer.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} mock_console_instance = MagicMock() - mock_traceloop_components['console_exporter'].return_value = mock_console_instance + mock_traceloop_components[ + "console_exporter" + ].return_value = mock_console_instance - with patch.dict('os.environ', {'OTEL_TRACES_EXPORTER': 'console'}, clear=True): + with patch.dict("os.environ", {"OTEL_TRACES_EXPORTER": "console"}, clear=True): auto_instrument() - mock_traceloop_components['transformer'].assert_called_once_with(mock_console_instance) + mock_traceloop_components["transformer"].assert_called_once_with( + mock_console_instance + ) def test_auto_instrument_without_endpoint_or_console(self): """Test that auto_instrument warns when neither OTLP endpoint nor console exporter is configured.""" - with patch.dict('os.environ', {}, clear=True): - with patch('sap_cloud_sdk.core.telemetry.auto_instrument.logger') as mock_logger: + with patch.dict("os.environ", {}, clear=True): + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.logger" + ) as mock_logger: auto_instrument() mock_logger.warning.assert_called_once() warning_message = mock_logger.warning.call_args[0][0] assert "OTEL_EXPORTER_OTLP_ENDPOINT not set" in warning_message - def test_auto_instrument_disable_batch_can_be_set_to_true(self, mock_traceloop_components): + def test_auto_instrument_disable_batch_can_be_set_to_true( + self, mock_traceloop_components + ): """Test that disable_batch=True can be explicitly passed to use SimpleSpanProcessor.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument(disable_batch=True) - call_kwargs = mock_traceloop_components['traceloop'].init.call_args[1] - assert call_kwargs['disable_batch'] is True + call_kwargs = mock_traceloop_components["traceloop"].init.call_args[1] + assert call_kwargs["disable_batch"] is True - def test_auto_instrument_passes_baggage_span_processor(self, mock_traceloop_components): + def test_auto_instrument_passes_baggage_span_processor( + self, mock_traceloop_components + ): """Test that auto_instrument registers a BaggageSpanProcessor on the tracer provider.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} mock_processor_instance = MagicMock() - mock_traceloop_components['baggage_processor'].return_value = mock_processor_instance - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + mock_traceloop_components[ + "baggage_processor" + ].return_value = mock_processor_instance + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument() - mock_traceloop_components['baggage_processor'].assert_called_once() - mock_traceloop_components['get_tracer_provider'].return_value.add_span_processor.assert_any_call(mock_processor_instance) + mock_traceloop_components["baggage_processor"].assert_called_once() + mock_traceloop_components[ + "get_tracer_provider" + ].return_value.add_span_processor.assert_any_call(mock_processor_instance) - def test_auto_instrument_registers_propagated_attributes_processor(self, mock_traceloop_components): + def test_auto_instrument_registers_propagated_attributes_processor( + self, mock_traceloop_components + ): """Test that auto_instrument registers a PropagatedAttributesSpanProcessor on the tracer provider.""" - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} mock_processor_instance = MagicMock() - mock_traceloop_components['propagated_processor'].return_value = mock_processor_instance - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + mock_traceloop_components[ + "propagated_processor" + ].return_value = mock_processor_instance + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument() - mock_traceloop_components['propagated_processor'].assert_called_once() - mock_traceloop_components['get_tracer_provider'].return_value.add_span_processor.assert_any_call(mock_processor_instance) + mock_traceloop_components["propagated_processor"].assert_called_once() + mock_traceloop_components[ + "get_tracer_provider" + ].return_value.add_span_processor.assert_any_call(mock_processor_instance) - def test_auto_instrument_merges_resource_when_wrapper_installed(self, mock_traceloop_components): + def test_auto_instrument_merges_resource_when_wrapper_installed( + self, mock_traceloop_components + ): """When an OTel auto-instrumentation wrapper (e.g. an OpenTelemetry-Operator init-container injection) has pre-installed a TracerProvider whose Resource carries the standard `telemetry.auto.version` marker, auto_instrument merges sap-cloud-sdk attrs onto that provider's existing Resource — preserving the operator-supplied attrs and adding our SAP enrichment on top.""" - mock_traceloop_components['get_app_name'].return_value = 'cloud-sdk-app' + mock_traceloop_components["get_app_name"].return_value = "cloud-sdk-app" sap_attrs = { - 'service.name': 'cloud-sdk-app', - 'sap.cloud_sdk.name': 'SAP Cloud SDK for Python', - 'sap.cloud_sdk.language': 'python', - 'sap.solution_area': 'fina', - 'mlflow.experiment_id': '1635264705567712', + "service.name": "cloud-sdk-app", + "sap.cloud_sdk.name": "SAP Cloud SDK for Python", + "sap.cloud_sdk.language": "python", + "sap.solution_area": "fina", + "mlflow.experiment_id": "1635264705567712", } - mock_traceloop_components['create_resource'].return_value = sap_attrs + mock_traceloop_components["create_resource"].return_value = sap_attrs wrapper_provider = SDKTracerProvider( - resource=Resource.create({ - 'telemetry.auto.version': '0.62b1', - 'k8s.deployment.name': 'cloud-sdk-app-deployment', - 'service.name': 'cloud-sdk-app-deployment', - }) + resource=Resource.create( + { + "telemetry.auto.version": "0.62b1", + "k8s.deployment.name": "cloud-sdk-app-deployment", + "service.name": "cloud-sdk-app-deployment", + } + ) ) - mock_traceloop_components['get_tracer_provider'].return_value = wrapper_provider + mock_traceloop_components["get_tracer_provider"].return_value = wrapper_provider - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument() merged_attrs = wrapper_provider.resource.attributes # Operator-supplied attrs are preserved. - assert merged_attrs['telemetry.auto.version'] == '0.62b1' - assert merged_attrs['k8s.deployment.name'] == 'cloud-sdk-app-deployment' + assert merged_attrs["telemetry.auto.version"] == "0.62b1" + assert merged_attrs["k8s.deployment.name"] == "cloud-sdk-app-deployment" # sap-cloud-sdk attrs are added. - assert merged_attrs['sap.cloud_sdk.name'] == 'SAP Cloud SDK for Python' - assert merged_attrs['sap.cloud_sdk.language'] == 'python' - assert merged_attrs['sap.solution_area'] == 'fina' - assert merged_attrs['mlflow.experiment_id'] == '1635264705567712' - - def test_auto_instrument_skips_merge_when_no_wrapper_marker(self, mock_traceloop_components): + assert merged_attrs["sap.cloud_sdk.name"] == "SAP Cloud SDK for Python" + assert merged_attrs["sap.cloud_sdk.language"] == "python" + assert merged_attrs["sap.solution_area"] == "fina" + assert merged_attrs["mlflow.experiment_id"] == "1635264705567712" + + def test_auto_instrument_skips_merge_when_no_wrapper_marker( + self, mock_traceloop_components + ): """When the active TracerProvider's Resource lacks the `telemetry.auto.version` marker (e.g. a self-installed provider, or no wrapper at all), auto_instrument does not mutate the provider's Resource.""" - mock_traceloop_components['get_app_name'].return_value = 'cloud-sdk-app' - mock_traceloop_components['create_resource'].return_value = { - 'sap.cloud_sdk.name': 'SAP Cloud SDK for Python', - 'sap.solution_area': 'fina', + mock_traceloop_components["get_app_name"].return_value = "cloud-sdk-app" + mock_traceloop_components["create_resource"].return_value = { + "sap.cloud_sdk.name": "SAP Cloud SDK for Python", + "sap.solution_area": "fina", } - initial_resource = Resource.create({'service.name': 'self-installed'}) + initial_resource = Resource.create({"service.name": "self-installed"}) plain_provider = SDKTracerProvider(resource=initial_resource) - mock_traceloop_components['get_tracer_provider'].return_value = plain_provider + mock_traceloop_components["get_tracer_provider"].return_value = plain_provider - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument() # Resource is the original instance — no merge took place. assert plain_provider.resource is initial_resource - assert 'sap.cloud_sdk.name' not in plain_provider.resource.attributes - assert 'sap.solution_area' not in plain_provider.resource.attributes + assert "sap.cloud_sdk.name" not in plain_provider.resource.attributes + assert "sap.solution_area" not in plain_provider.resource.attributes - def test_auto_instrument_merge_overrides_colliding_service_name(self, mock_traceloop_components): + def test_auto_instrument_merge_overrides_colliding_service_name( + self, mock_traceloop_components + ): """On a wrapper-installed provider, sap-cloud-sdk attrs override colliding keys: service.name from APPFND_CONHOS_APP_NAME wins over the operator's k8s-deployment-derived service.name.""" - mock_traceloop_components['get_app_name'].return_value = 'cloud-sdk-app' - mock_traceloop_components['create_resource'].return_value = { - 'service.name': 'cloud-sdk-app', + mock_traceloop_components["get_app_name"].return_value = "cloud-sdk-app" + mock_traceloop_components["create_resource"].return_value = { + "service.name": "cloud-sdk-app", } wrapper_provider = SDKTracerProvider( - resource=Resource.create({ - 'telemetry.auto.version': '0.62b1', - 'service.name': 'operator-supplied-name', - }) + resource=Resource.create( + { + "telemetry.auto.version": "0.62b1", + "service.name": "operator-supplied-name", + } + ) ) - mock_traceloop_components['get_tracer_provider'].return_value = wrapper_provider + mock_traceloop_components["get_tracer_provider"].return_value = wrapper_provider - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument() - assert wrapper_provider.resource.attributes['service.name'] == 'cloud-sdk-app' - + assert ( + wrapper_provider.resource.attributes["service.name"] == "cloud-sdk-app" + ) class TestAutoInstrumentMiddlewares: """Tests for the middlewares parameter of auto_instrument.""" - def test_middlewares_register_called_and_processor_added(self, mock_traceloop_components): - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} + def test_middlewares_register_called_and_processor_added( + self, mock_traceloop_components + ): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} middleware = MagicMock() - with patch('sap_cloud_sdk.core.telemetry.auto_instrument._register_middleware_processors') as mock_reg: - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument._register_middleware_processors" + ) as mock_reg: + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument(middlewares=[middleware]) mock_reg.assert_called_once_with([middleware]) - def test_middlewares_none_does_not_register_processor(self, mock_traceloop_components): - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch('sap_cloud_sdk.core.telemetry.auto_instrument._register_middleware_processors') as mock_reg: - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + def test_middlewares_none_does_not_register_processor( + self, mock_traceloop_components + ): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument._register_middleware_processors" + ) as mock_reg: + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument(middlewares=None) mock_reg.assert_not_called() - def test_empty_middlewares_list_does_not_register_processor(self, mock_traceloop_components): - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} - - with patch('sap_cloud_sdk.core.telemetry.auto_instrument._register_middleware_processors') as mock_reg: - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + def test_empty_middlewares_list_does_not_register_processor( + self, mock_traceloop_components + ): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} + + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument._register_middleware_processors" + ) as mock_reg: + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument(middlewares=[]) mock_reg.assert_not_called() - def test_register_middleware_processors_calls_register_and_adds_processor(self, mock_traceloop_components): - from sap_cloud_sdk.core.telemetry.auto_instrument import _register_middleware_processors - from sap_cloud_sdk.core.telemetry.middleware.span_processor import MiddlewareSpanProcessor + def test_register_middleware_processors_calls_register_and_adds_processor( + self, mock_traceloop_components + ): + from sap_cloud_sdk.core.telemetry.auto_instrument import ( + _register_middleware_processors, + ) + from sap_cloud_sdk.core.telemetry.middleware.span_processor import ( + MiddlewareSpanProcessor, + ) from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider from unittest.mock import create_autospec provider = create_autospec(SDKTracerProvider) middleware = MagicMock() - with patch('sap_cloud_sdk.core.telemetry.auto_instrument.trace.get_tracer_provider', return_value=provider): + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.trace.get_tracer_provider", + return_value=provider, + ): _register_middleware_processors([middleware]) middleware.register.assert_called_once_with() @@ -379,29 +560,51 @@ def test_register_middleware_processors_calls_register_and_adds_processor(self, call_arg = provider.add_span_processor.call_args[0][0] assert isinstance(call_arg, MiddlewareSpanProcessor) - def test_register_middleware_processors_skips_when_unknown_provider(self, mock_traceloop_components): - from sap_cloud_sdk.core.telemetry.auto_instrument import _register_middleware_processors + def test_register_middleware_processors_skips_when_unknown_provider( + self, mock_traceloop_components + ): + from sap_cloud_sdk.core.telemetry.auto_instrument import ( + _register_middleware_processors, + ) middleware = MagicMock() unknown_provider = MagicMock() # not a TracerProvider instance - with patch('sap_cloud_sdk.core.telemetry.auto_instrument.trace.get_tracer_provider', return_value=unknown_provider): - with patch('sap_cloud_sdk.core.telemetry.auto_instrument.logger') as mock_logger: + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.trace.get_tracer_provider", + return_value=unknown_provider, + ): + with patch( + "sap_cloud_sdk.core.telemetry.auto_instrument.logger" + ) as mock_logger: _register_middleware_processors([middleware]) mock_logger.warning.assert_called_once() middleware.register.assert_not_called() unknown_provider.add_span_processor.assert_not_called() - def test_baggage_and_middleware_processors_both_added(self, mock_traceloop_components): - mock_traceloop_components['get_app_name'].return_value = 'test-app' - mock_traceloop_components['create_resource'].return_value = {} + def test_baggage_and_middleware_processors_both_added( + self, mock_traceloop_components + ): + mock_traceloop_components["get_app_name"].return_value = "test-app" + mock_traceloop_components["create_resource"].return_value = {} middleware = MagicMock() mock_baggage_instance = MagicMock() - mock_traceloop_components['baggage_processor'].return_value = mock_baggage_instance - - with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + mock_traceloop_components[ + "baggage_processor" + ].return_value = mock_baggage_instance + + with patch.dict( + "os.environ", + {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317"}, + clear=True, + ): auto_instrument(middlewares=[middleware]) # add_span_processor called 3 times: baggage, propagated attributes, middleware - assert mock_traceloop_components['get_tracer_provider'].return_value.add_span_processor.call_count == 3 + assert ( + mock_traceloop_components[ + "get_tracer_provider" + ].return_value.add_span_processor.call_count + == 3 + ) diff --git a/uv.lock b/uv.lock index 92e88dcb..347b1f25 100644 --- a/uv.lock +++ b/uv.lock @@ -925,7 +925,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", size = 284764, upload-time = "2026-05-20T13:09:10.204Z" }, { url = "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", size = 603479, upload-time = "2026-05-20T14:00:04.757Z" }, { url = "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", size = 615495, upload-time = "2026-05-20T14:05:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/8fd452fd81adb9ec79c8275c1375702ab0fd6bee4952da12eaa09b9508d8/greenlet-3.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360", size = 623515, upload-time = "2026-05-20T14:09:07.853Z" }, { url = "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", size = 614754, upload-time = "2026-05-20T13:14:24.947Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bc/c318aa9f3ffc77320fddcee3d892be957b42e2ff947198d9450b004f3a38/greenlet-3.5.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747", size = 418439, upload-time = "2026-05-20T14:01:38.446Z" }, { url = "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", size = 1574097, upload-time = "2026-05-20T14:02:24.003Z" }, { url = "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", size = 1641058, upload-time = "2026-05-20T13:14:31.83Z" }, { url = "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", size = 238089, upload-time = "2026-05-20T13:14:03.229Z" }, @@ -933,7 +935,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, @@ -941,7 +945,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, @@ -949,7 +955,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, @@ -957,14 +965,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, @@ -972,7 +984,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, @@ -3685,7 +3699,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.35.0" +version = "0.35.3" source = { editable = "." } dependencies = [ { name = "grpcio" },