From 62047f0e258eb9b57ad0bc3f3e9ecb2459ce58a3 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Thu, 20 Aug 2026 12:14:19 -0300 Subject: [PATCH 01/10] fix: add subdomain validatorvalidator to core --- src/sap_cloud_sdk/agentgateway/agw_client.py | 5 +- src/sap_cloud_sdk/agentgateway/user-guide.md | 4 +- .../core/telemetry/user-guide.md | 13 +- src/sap_cloud_sdk/core/url_utils.py | 20 +++ src/sap_cloud_sdk/destination/_http.py | 3 + src/sap_cloud_sdk/dms/_auth.py | 3 + .../outputmanagement/user-guide.md | 152 ++++++++---------- tests/agentgateway/unit/test_agw_client.py | 44 +++++ tests/core/unit/test_url_utils.py | 38 +++++ tests/destination/unit/test_http.py | 25 +++ tests/dms/unit/test_auth.py | 129 +++++++++++++++ uv.lock | 24 +-- 12 files changed, 352 insertions(+), 108 deletions(-) create mode 100644 src/sap_cloud_sdk/core/url_utils.py create mode 100644 tests/core/unit/test_url_utils.py create mode 100644 tests/dms/unit/test_auth.py diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index fb1d63f8..25b4d4c8 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -37,6 +37,7 @@ MCPTool, MCPToolFilter, ) +from sap_cloud_sdk.core.url_utils import _validate_tenant_subdomain from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics @@ -155,10 +156,12 @@ def _resolve_value( def _resolve_tenant_subdomain(self) -> str: """Resolve tenant subdomain from string or callable.""" - return self._resolve_value( + resolved = self._resolve_value( self._tenant_subdomain, "tenant_subdomain is required for LoB agent flow.", ) + _validate_tenant_subdomain(resolved) + return resolved @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_GET_SYSTEM_AUTH) async def get_system_auth(self) -> AuthResult: diff --git a/src/sap_cloud_sdk/agentgateway/user-guide.md b/src/sap_cloud_sdk/agentgateway/user-guide.md index ab3cd3f5..c578bff3 100644 --- a/src/sap_cloud_sdk/agentgateway/user-guide.md +++ b/src/sap_cloud_sdk/agentgateway/user-guide.md @@ -271,9 +271,9 @@ Both fields default to empty lists. `agent_names` is applied after fetching; `or from sap_cloud_sdk.agentgateway import MCPToolFilter MCPToolFilter( - names=[], # tool names to include (matched against MCPTool.name); empty = no filter + names=[], # tool names to include (matched against MCPTool.name); empty = no filter ord_ids=[], # ORD IDs to include (extracted from fragment URL for LoB, or matched - # against IntegrationDependency.ord_id for customer agents); empty = no filter + # against IntegrationDependency.ord_id for customer agents); empty = no filter ) ``` diff --git a/src/sap_cloud_sdk/core/telemetry/user-guide.md b/src/sap_cloud_sdk/core/telemetry/user-guide.md index 93400e41..ae6ac4c6 100644 --- a/src/sap_cloud_sdk/core/telemetry/user-guide.md +++ b/src/sap_cloud_sdk/core/telemetry/user-guide.md @@ -295,6 +295,7 @@ from litellm import completion logger = logging.getLogger(__name__) + async def handle_request(query: str, user_id: str): set_tenant_id("bh7sjh...") @@ -435,10 +436,10 @@ The `record_metrics` decorator records request and error counters for any SDK mo ```python from sap_cloud_sdk.core.telemetry import record_metrics + class MyClient: @record_metrics("my_module", "my_operation") - def my_method(self): - ... + def my_method(self): ... ``` Each call to the decorated method increments `sap.cloud_sdk.capability.requests`. On exception it increments `sap.cloud_sdk.capability.errors` and re-raises. Metrics are emitted only when `OTEL_EXPORTER_OTLP_ENDPOINT` is set — no-op otherwise. @@ -450,10 +451,10 @@ For modules that live inside this package, use the `Module` and `Operation` enum ```python from sap_cloud_sdk.core.telemetry import record_metrics, Module, Operation + class DestinationClient: @record_metrics(Module.DESTINATION, Operation.DESTINATION_GET_DESTINATION) - def get_destination(self, name: str): - ... + def get_destination(self, name: str): ... ``` ### Using plain strings (external packages) @@ -463,10 +464,10 @@ External packages that depend on `sap-cloud-sdk` can pass plain strings directly ```python from sap_cloud_sdk.core.telemetry import record_metrics + class MyExternalClient: @record_metrics("my_module", "my_operation") - def my_method(self): - ... + def my_method(self): ... ``` The `Module` enum values are still the canonical form for OSS modules. Plain strings are the extension point for packages that have their own release lifecycle. diff --git a/src/sap_cloud_sdk/core/url_utils.py b/src/sap_cloud_sdk/core/url_utils.py new file mode 100644 index 00000000..1bb4b1ec --- /dev/null +++ b/src/sap_cloud_sdk/core/url_utils.py @@ -0,0 +1,20 @@ +import re + + +_SUBDOMAIN_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?$") + + +def _validate_tenant_subdomain(tenant_subdomain: str | None) -> None: + """Validate that *tenant_subdomain* is a single RFC 1123 DNS label. + + A valid label contains only ASCII letters, digits, and hyphens, must not + start or end with a hyphen, and is at most 63 characters long. + If *tenant_subdomain* is ``None``, the call is a no-op. + + Raises: + ValueError: If *tenant_subdomain* does not match the expected format. + """ + if tenant_subdomain is None: + return + if not _SUBDOMAIN_RE.fullmatch(tenant_subdomain): + raise ValueError(f"Invalid tenant_subdomain: {tenant_subdomain!r}") diff --git a/src/sap_cloud_sdk/destination/_http.py b/src/sap_cloud_sdk/destination/_http.py index b61f461e..8cc5cebe 100644 --- a/src/sap_cloud_sdk/destination/_http.py +++ b/src/sap_cloud_sdk/destination/_http.py @@ -18,6 +18,7 @@ from sap_cloud_sdk.destination.config import DestinationConfig from sap_cloud_sdk.destination.exceptions import HttpError +from sap_cloud_sdk.core.url_utils import _validate_tenant_subdomain # API version constants API_V1 = "v1" @@ -64,6 +65,8 @@ def get_token(self, tenant_subdomain: Optional[str] = None) -> str: token_url = self._config.token_url identityzone = self._config.identityzone + _validate_tenant_subdomain(tenant_subdomain) + if tenant_subdomain: try: token_url = token_url.replace(str(identityzone), tenant_subdomain) diff --git a/src/sap_cloud_sdk/dms/_auth.py b/src/sap_cloud_sdk/dms/_auth.py index 767f775c..909619c5 100644 --- a/src/sap_cloud_sdk/dms/_auth.py +++ b/src/sap_cloud_sdk/dms/_auth.py @@ -10,6 +10,7 @@ DMSPermissionDeniedException, ) from sap_cloud_sdk.dms.model import DMSCredentials +from sap_cloud_sdk.core.url_utils import _validate_tenant_subdomain logger = logging.getLogger(__name__) @@ -65,6 +66,8 @@ def get_token(self, tenant_subdomain: Optional[str] = None) -> str: def _resolve_token_url(self, tenant_subdomain: Optional[str]) -> str: if not tenant_subdomain: return self._credentials.token_url + _validate_tenant_subdomain(tenant_subdomain) + logger.debug("Resolving token URL for tenant '%s'", tenant_subdomain) return self._credentials.token_url.replace( self._credentials.identityzone, diff --git a/src/sap_cloud_sdk/outputmanagement/user-guide.md b/src/sap_cloud_sdk/outputmanagement/user-guide.md index 99c3435c..a13b0e6f 100644 --- a/src/sap_cloud_sdk/outputmanagement/user-guide.md +++ b/src/sap_cloud_sdk/outputmanagement/user-guide.md @@ -68,9 +68,9 @@ response = client.send_email( "PurchaseOrder": { "orderId": "PO-12345", "vendor": "ACME Corp", - "total": 1500.00 + "total": 1500.00, } - } + }, ) # Check the result @@ -94,7 +94,7 @@ response = client.send_email( to=["user@example.com"], business_document={"Document": {"id": "123"}}, cc=["manager@example.com"], # Optional - template_language="en" # Optional, default: "en" + template_language="en", # Optional, default: "en" ) ``` @@ -103,18 +103,13 @@ response = client.send_email( response = client.send_email( notification_template_key="INVOICE_NOTIFICATION", to=["customer@example.com"], - business_document={ - "Invoice": { - "invoiceNumber": "INV-2024-001", - "amount": 5000.00 - } - }, + business_document={"Invoice": {"invoiceNumber": "INV-2024-001", "amount": 5000.00}}, cc=["accounting@company.com"], template_language="en", attachment_urls=[ "https://dms.example.com/browser/root?objectId=12345&cmisselector=content", - "https://dms.example.com/browser/root?objectId=67890&cmisselector=content" - ] + "https://dms.example.com/browser/root?objectId=67890&cmisselector=content", + ], ) ``` @@ -128,7 +123,7 @@ output_request = client.create_output_request( business_document={"Document": {"id": "123"}}, cc=["manager@example.com"], # Optional template_language="en", # Optional - attachment_urls=["https://dms.example.com/..."] # Optional + attachment_urls=["https://dms.example.com/..."], # Optional ) # Inspect or modify the request @@ -156,7 +151,7 @@ response = await client.send_email_with_mcp( notification_template_key="TEMPLATE_KEY", to_emails=["user@example.com"], business_document={"Document": {"id": "123"}}, - mcp_tool=mcp_tool_instance + mcp_tool=mcp_tool_instance, ) ``` @@ -167,17 +162,14 @@ response = await client.send_email_with_mcp( notification_template_key="CONTRACT_NOTIFICATION", to_emails=["legal@company.com"], business_document={ - "Contract": { - "contractId": "CNT-2024-100", - "partyName": "Partner Corp" - } + "Contract": {"contractId": "CNT-2024-100", "partyName": "Partner Corp"} }, cc_email="manager@company.com", attachment_urls=[ "https://dms.example.com/browser/root?objectId=12345&cmisselector=content", - "https://dms.example.com/browser/root?objectId=67890&cmisselector=content" + "https://dms.example.com/browser/root?objectId=67890&cmisselector=content", ], - mcp_tool=mcp_tool_instance + mcp_tool=mcp_tool_instance, ) ``` @@ -202,9 +194,9 @@ response = client.send_email( "orderId": "ORD-789", "customerName": "John Doe", "orderDate": "2024-01-15", - "totalAmount": 2500.00 + "totalAmount": 2500.00, } - } + }, ) if response.error: @@ -230,9 +222,9 @@ response = client.send_email( "Invoice": { "invoiceNumber": "INV-2024-001", "amount": 5000.00, - "dueDate": "2024-02-15" + "dueDate": "2024-02-15", } - } + }, ) ``` @@ -248,13 +240,8 @@ client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") response = client.send_email( notification_template_key="WELCOME_EMAIL", to=["user@example.com"], - business_document={ - "User": { - "userId": "U12345", - "name": "Jane Smith" - } - }, - template_language="de" # German template + business_document={"User": {"userId": "U12345", "name": "Jane Smith"}}, + template_language="de", # German template ) ``` @@ -273,14 +260,11 @@ response = client.send_email( notification_template_key="CONTRACT_NOTIFICATION", to=["legal@company.com"], business_document={ - "Contract": { - "contractId": "CNT-2024-100", - "partyName": "Partner Corp" - } + "Contract": {"contractId": "CNT-2024-100", "partyName": "Partner Corp"} }, attachment_urls=[ "https://dms.example.com/browser/root?objectId=12345&cmisselector=content" - ] + ], ) ``` @@ -297,17 +281,13 @@ response = client.send_email( notification_template_key="REPORT_PACKAGE", to=["management@company.com"], business_document={ - "Report": { - "reportId": "RPT-Q1-2024", - "quarter": "Q1", - "year": 2024 - } + "Report": {"reportId": "RPT-Q1-2024", "quarter": "Q1", "year": 2024} }, attachment_urls=[ "https://dms.example.com/browser/root?objectId=12345&cmisselector=content", "https://dms.example.com/browser/root?objectId=67890&cmisselector=content", - "https://dms.example.com/browser/root?objectId=11111&cmisselector=content" - ] + "https://dms.example.com/browser/root?objectId=11111&cmisselector=content", + ], ) ``` @@ -328,7 +308,7 @@ client = create_client() client = create_client( destination_name="ARIBA_OUTPUT_SERVICE", access_strategy="PROVIDER_ONLY", - instance="default" + instance="default", ) ``` @@ -341,14 +321,12 @@ from sap_cloud_sdk.outputmanagement import create_client # Provider-only access (default) client = create_client( - destination_name="ARIBA_OUTPUT_SERVICE", - access_strategy="PROVIDER_ONLY" + destination_name="ARIBA_OUTPUT_SERVICE", access_strategy="PROVIDER_ONLY" ) # Subscriber-only access client = create_client( - destination_name="ARIBA_OUTPUT_SERVICE", - access_strategy="SUBSCRIBER_ONLY" + destination_name="ARIBA_OUTPUT_SERVICE", access_strategy="SUBSCRIBER_ONLY" ) ``` @@ -360,8 +338,7 @@ Specify a custom destination service instance: from sap_cloud_sdk.outputmanagement import create_client client = create_client( - destination_name="ARIBA_OUTPUT_SERVICE", - instance="my-custom-instance" + destination_name="ARIBA_OUTPUT_SERVICE", instance="my-custom-instance" ) ``` @@ -378,15 +355,12 @@ client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") output_request = client.create_output_request( notification_template_key="CUSTOM_NOTIFICATION", to=["recipient@example.com"], - business_document={ - "CustomDocument": { - "id": "DOC-456", - "type": "Important" - } - }, + business_document={"CustomDocument": {"id": "DOC-456", "type": "Important"}}, cc=["supervisor@example.com"], template_language="en", - attachment_urls=["https://dms.example.com/browser/root?objectId=999&cmisselector=content"] + attachment_urls=[ + "https://dms.example.com/browser/root?objectId=999&cmisselector=content" + ], ) # Step 2: Inspect or modify the request if needed @@ -411,7 +385,7 @@ client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") response = client.send_email( notification_template_key="NOTIFICATION", to=["user@example.com"], - business_document={"Document": {"id": "123"}} + business_document={"Document": {"id": "123"}}, ) if response.error: @@ -433,7 +407,7 @@ client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") response = client.send_email( notification_template_key="", # Invalid: empty template key to=[], # Invalid: no recipients - business_document={} # Invalid: empty document + business_document={}, # Invalid: empty document ) if response.error: @@ -456,7 +430,7 @@ try: response = client.send_email( notification_template_key="NOTIFICATION", to=["user@example.com"], - business_document={"Document": {"id": "123"}} + business_document={"Document": {"id": "123"}}, ) if response.error: @@ -491,13 +465,15 @@ for order in orders: response = client.send_email( notification_template_key="ORDER_CONFIRMATION", to=[order.customer_email], - business_document={"Order": order.to_dict()} + business_document={"Order": order.to_dict()}, ) if response.error: print(f"Failed to send email for order {order.id}: {response.error.message}") else: - print(f"Email sent for order {order.id}, Request ID: {response.outputRequestId}") + print( + f"Email sent for order {order.id}, Request ID: {response.outputRequestId}" + ) ``` ### 2. Validate Input Before Sending @@ -507,6 +483,7 @@ Validate your data before calling the API: ```python from sap_cloud_sdk.outputmanagement import create_client + def send_order_confirmation(order): # Validate input if not order.customer_email: @@ -521,12 +498,7 @@ def send_order_confirmation(order): response = client.send_email( notification_template_key="ORDER_CONFIRMATION", to=[order.customer_email], - business_document={ - "Order": { - "orderId": order.order_id, - "total": order.total - } - } + business_document={"Order": {"orderId": order.order_id, "total": order.total}}, ) return response @@ -542,7 +514,7 @@ business_document = { "Invoice": { "invoiceNumber": "INV-2024-001", # Clear identifier "customerId": "CUST-12345", - "amount": 1000.00 + "amount": 1000.00, } } @@ -550,7 +522,7 @@ business_document = { business_document = { "Invoice": { "id": "123", # Too generic - "amount": 1000.00 + "amount": 1000.00, } } ``` @@ -563,6 +535,7 @@ Always handle errors and provide meaningful feedback: from sap_cloud_sdk.outputmanagement import create_client import time + def send_notification_with_retry(template_key, recipients, document, max_retries=3): client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") @@ -571,14 +544,14 @@ def send_notification_with_retry(template_key, recipients, document, max_retries response = client.send_email( notification_template_key=template_key, to=recipients, - business_document=document + business_document=document, ) if response.error: if response.error.code in ["NETWORK_ERROR", "SERVICE_UNAVAILABLE"]: if attempt < max_retries - 1: print(f"Retrying... (attempt {attempt + 1}/{max_retries})") - time.sleep(2 ** attempt) # Exponential backoff + time.sleep(2**attempt) # Exponential backoff continue print(f"Failed to send email: {response.error.message}") @@ -588,8 +561,10 @@ def send_notification_with_retry(template_key, recipients, document, max_retries except Exception as e: if attempt < max_retries - 1: - print(f"Error occurred, retrying... (attempt {attempt + 1}/{max_retries})") - time.sleep(2 ** attempt) + print( + f"Error occurred, retrying... (attempt {attempt + 1}/{max_retries})" + ) + time.sleep(2**attempt) continue raise @@ -611,7 +586,7 @@ client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") response = client.send_email( notification_template_key="NOTIFICATION", to=["user@example.com"], - business_document={"Document": {"id": "123"}} + business_document={"Document": {"id": "123"}}, ) if response.error: @@ -647,7 +622,7 @@ from sap_cloud_sdk.outputmanagement import create_client client = create_client( destination_name="ARIBA_OUTPUT_SERVICE", access_strategy="PROVIDER_ONLY", - instance="default" + instance="default", ) # Using environment variables @@ -682,7 +657,7 @@ response = client.send_email( business_document={"Document": {"id": "123"}}, cc=["manager@example.com"], template_language="en", - attachment_urls=["https://dms.example.com/..."] + attachment_urls=["https://dms.example.com/..."], ) ``` @@ -710,7 +685,7 @@ output_request = client.create_output_request( notification_template_key="NOTIFICATION", to=["user@example.com"], business_document={"Document": {"id": "123"}}, - cc=["manager@example.com"] + cc=["manager@example.com"], ) ``` @@ -760,7 +735,7 @@ response = await client.send_email_with_mcp( notification_template_key="NOTIFICATION", to_emails=["user@example.com"], business_document={"Document": {"id": "123"}}, - mcp_tool=mcp_tool_instance + mcp_tool=mcp_tool_instance, ) ``` @@ -771,6 +746,7 @@ response = await client.send_email_with_mcp( ```python from sap_cloud_sdk.outputmanagement import create_client + def send_order_confirmation(order): client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") @@ -788,12 +764,12 @@ def send_order_confirmation(order): { "productName": item.product_name, "quantity": item.quantity, - "price": float(item.price) + "price": float(item.price), } for item in order.items - ] + ], } - } + }, ) return response @@ -804,6 +780,7 @@ def send_order_confirmation(order): ```python from sap_cloud_sdk.outputmanagement import create_client + def send_invoice_with_pdf(invoice, pdf_dms_url): client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") @@ -817,10 +794,10 @@ def send_invoice_with_pdf(invoice, pdf_dms_url): "invoiceDate": invoice.date.isoformat(), "dueDate": invoice.due_date.isoformat(), "amount": float(invoice.amount), - "currency": invoice.currency + "currency": invoice.currency, } }, - attachment_urls=[pdf_dms_url] + attachment_urls=[pdf_dms_url], ) return response @@ -831,6 +808,7 @@ def send_invoice_with_pdf(invoice, pdf_dms_url): ```python from sap_cloud_sdk.outputmanagement import create_client + def send_bulk_notification(recipients, notification_data): client = create_client(destination_name="ARIBA_OUTPUT_SERVICE") @@ -842,9 +820,9 @@ def send_bulk_notification(recipients, notification_data): "notificationId": notification_data["id"], "title": notification_data["title"], "message": notification_data["message"], - "timestamp": notification_data["timestamp"] + "timestamp": notification_data["timestamp"], } - } + }, ) return response @@ -901,7 +879,7 @@ from sap_cloud_sdk.outputmanagement import ( ValidationException, NetworkException, DestinationNotFoundException, - DestinationAccessException + DestinationAccessException, ) try: @@ -910,7 +888,7 @@ try: response = client.send_email( notification_template_key="NOTIFICATION", to=["user@example.com"], - business_document={"Document": {"id": "123"}} + business_document={"Document": {"id": "123"}}, ) if response.error: diff --git a/tests/agentgateway/unit/test_agw_client.py b/tests/agentgateway/unit/test_agw_client.py index f950946a..714c3ec9 100644 --- a/tests/agentgateway/unit/test_agw_client.py +++ b/tests/agentgateway/unit/test_agw_client.py @@ -122,6 +122,50 @@ def test_raises_on_callable_returning_empty(self): AgentGatewayClient._resolve_value(get_empty, "test error") +# ============================================================ +# Test: AgentGatewayClient._resolve_tenant_subdomain +# ============================================================ + + +class TestResolveTenantSubdomain: + """Tests for AgentGatewayClient._resolve_tenant_subdomain.""" + + def test_valid_string_subdomain_returns_value(self): + client = AgentGatewayClient(tenant_subdomain="tenant-123") + assert client._resolve_tenant_subdomain() == "tenant-123" + + def test_callable_subdomain_is_resolved_then_validated(self): + client = AgentGatewayClient(tenant_subdomain=lambda: "dynamic-tenant") + assert client._resolve_tenant_subdomain() == "dynamic-tenant" + + def test_invalid_subdomain_raises_value_error(self): + client = AgentGatewayClient(tenant_subdomain="-bad-subdomain") + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + client._resolve_tenant_subdomain() + + def test_invalid_subdomain_from_callable_raises_value_error(self): + client = AgentGatewayClient(tenant_subdomain=lambda: "has.dot") + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + client._resolve_tenant_subdomain() + + def test_none_subdomain_raises_sdk_error(self): + client = AgentGatewayClient(tenant_subdomain=None) + with pytest.raises(AgentGatewaySDKError, match="tenant_subdomain is required"): + client._resolve_tenant_subdomain() + + @patch("sap_cloud_sdk.agentgateway.agw_client._validate_tenant_subdomain") + def test_validator_is_called_with_resolved_value(self, mock_validate): + client = AgentGatewayClient(tenant_subdomain="tenant-abc") + client._resolve_tenant_subdomain() + mock_validate.assert_called_once_with("tenant-abc") + + @patch("sap_cloud_sdk.agentgateway.agw_client._validate_tenant_subdomain") + def test_validator_called_with_result_of_callable(self, mock_validate): + client = AgentGatewayClient(tenant_subdomain=lambda: "from-callable") + client._resolve_tenant_subdomain() + mock_validate.assert_called_once_with("from-callable") + + # ============================================================ # Test: get_system_auth # ============================================================ diff --git a/tests/core/unit/test_url_utils.py b/tests/core/unit/test_url_utils.py new file mode 100644 index 00000000..4d68da62 --- /dev/null +++ b/tests/core/unit/test_url_utils.py @@ -0,0 +1,38 @@ +"""Unit tests for sap_cloud_sdk.core.url_utils.""" + +import pytest + +from sap_cloud_sdk.core.url_utils import _validate_tenant_subdomain + + +class TestValidateTenantSubdomain: + + @pytest.mark.parametrize("valid", [ + "tenant", + "tenant-123", + "my-company-subdomain", + "a", + "a" * 63, + "A1b2C3", + "xn--nxasmq6b", # punycoded label — valid RFC 1123 label chars + ]) + def test_valid_subdomains_do_not_raise(self, valid): + _validate_tenant_subdomain(valid) # must not raise + + @pytest.mark.parametrize("invalid, description", [ + ("", "empty string"), + ("-leading-hyphen", "starts with hyphen"), + ("trailing-hyphen-", "ends with hyphen"), + ("-both-", "starts and ends with hyphen"), + ("a" * 64, "64 chars — one over the 63-char limit"), + ("has.dot", "dot is not a valid label char"), + ("has space", "space is not allowed"), + ("under_score", "underscore is not allowed"), + ("has/slash", "slash is not allowed"), + ]) + def test_invalid_subdomains_raise_value_error(self, invalid, description): + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + _validate_tenant_subdomain(invalid) + + def test_none_is_a_no_op(self): + _validate_tenant_subdomain(None) # must not raise diff --git a/tests/destination/unit/test_http.py b/tests/destination/unit/test_http.py index 50680311..d78a92b9 100644 --- a/tests/destination/unit/test_http.py +++ b/tests/destination/unit/test_http.py @@ -72,6 +72,31 @@ def test_missing_access_token_raises(self, mock_oauth): with pytest.raises(HttpError, match="missing access_token"): provider.get_token() + @patch("sap_cloud_sdk.destination._http.OAuth2Session") + def test_invalid_tenant_subdomain_raises_value_error(self, mock_oauth): + mock_session = MagicMock() + mock_oauth.return_value = mock_session + + binding = DestinationConfig( + url="https://destination.example.com", + token_url="https://provider-zone.authentication.region/oauth/token", + client_id="cid", + client_secret="csecret", + identityzone="provider-zone", + ) + provider = TokenProvider(binding) + + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + provider.get_token(tenant_subdomain="-invalid") + + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + provider.get_token(tenant_subdomain="has.dot") + + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + provider.get_token(tenant_subdomain="trailing-hyphen-") + + mock_session.fetch_token.assert_not_called() + @patch("sap_cloud_sdk.destination._http.OAuth2Session") def test_tenant_subdomain_replaces_identityzone(self, mock_oauth): mock_session = MagicMock() diff --git a/tests/dms/unit/test_auth.py b/tests/dms/unit/test_auth.py new file mode 100644 index 00000000..49b6becb --- /dev/null +++ b/tests/dms/unit/test_auth.py @@ -0,0 +1,129 @@ +"""Unit tests for sap_cloud_sdk.dms._auth.Auth.""" + +import pytest +from unittest.mock import patch + +from sap_cloud_sdk.dms._auth import Auth, _MAX_CACHE_SIZE +from sap_cloud_sdk.dms.model import DMSCredentials + + +def _make_credentials(identityzone: str = "provider-zone") -> DMSCredentials: + return DMSCredentials( + uri="https://dms.example.com", + token_url=f"https://{identityzone}.authentication.region", + client_id="cid", + client_secret="csecret", + identityzone=identityzone, + ) + + +class TestResolveTokenUrl: + def test_no_subdomain_returns_provider_url(self): + creds = _make_credentials() + auth = Auth(creds) + assert auth._resolve_token_url(None) == creds.token_url + assert auth._resolve_token_url("") == creds.token_url + + def test_valid_subdomain_replaces_identityzone(self): + creds = _make_credentials() + auth = Auth(creds) + result = auth._resolve_token_url("tenant-123") + assert result == "https://tenant-123.authentication.region" + + def test_invalid_subdomain_raises_value_error(self): + creds = _make_credentials() + auth = Auth(creds) + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + auth._resolve_token_url("-bad") + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + auth._resolve_token_url("has.dot") + + @patch("sap_cloud_sdk.dms._auth._validate_tenant_subdomain") + def test_resolve_token_url_calls_validator(self, mock_validate): + creds = _make_credentials() + auth = Auth(creds) + + auth._resolve_token_url("tenant-abc") + mock_validate.assert_called_once_with("tenant-abc") + + @patch("sap_cloud_sdk.dms._auth._validate_tenant_subdomain") + def test_validator_not_called_when_no_subdomain(self, mock_validate): + creds = _make_credentials() + auth = Auth(creds) + + auth._resolve_token_url(None) + auth._resolve_token_url("") + mock_validate.assert_not_called() + + +class TestGetToken: + def test_returns_token(self): + creds = _make_credentials() + auth = Auth(creds) + with patch.object( + auth, + "_fetch_token", + return_value={"access_token": "tok-1", "expires_in": 3600}, + ): + assert auth.get_token() == "tok-1" + + def test_caches_token_on_second_call(self): + creds = _make_credentials() + auth = Auth(creds) + with patch.object( + auth, + "_fetch_token", + return_value={"access_token": "tok-1", "expires_in": 3600}, + ) as mock_fetch: + auth.get_token() + auth.get_token() + mock_fetch.assert_called_once() + + def test_subscriber_and_provider_cached_separately(self): + creds = _make_credentials() + auth = Auth(creds) + with patch.object( + auth, + "_fetch_token", + side_effect=[ + {"access_token": "prov-tok", "expires_in": 3600}, + {"access_token": "sub-tok", "expires_in": 3600}, + ], + ) as mock_fetch: + prov = auth.get_token() + sub = auth.get_token(tenant_subdomain="tenant-x") + assert prov == "prov-tok" + assert sub == "sub-tok" + assert mock_fetch.call_count == 2 + + def test_invalid_subdomain_raises_before_fetch(self): + creds = _make_credentials() + auth = Auth(creds) + with patch.object( + auth, + "_fetch_token", + return_value={"access_token": "tok", "expires_in": 3600}, + ) as mock_fetch: + with pytest.raises(ValueError, match="Invalid tenant_subdomain"): + auth.get_token(tenant_subdomain="-invalid") + mock_fetch.assert_not_called() + + def test_cache_evicts_oldest_when_full(self): + creds = _make_credentials() + auth = Auth(creds) + side_effects = [ + {"access_token": f"tok-{i}", "expires_in": 3600} + for i in range(_MAX_CACHE_SIZE + 1) + ] + with patch.object(auth, "_fetch_token", side_effect=side_effects): + for i in range(_MAX_CACHE_SIZE): + auth.get_token(tenant_subdomain=f"tenant-{i:02d}") + + assert len(auth._cache) == _MAX_CACHE_SIZE + assert "tenant-00" in auth._cache + + # One more entry should evict the oldest (tenant-00) + auth.get_token(tenant_subdomain="tenant-99") + assert len(auth._cache) == _MAX_CACHE_SIZE + assert "tenant-00" not in auth._cache + assert "tenant-99" in auth._cache diff --git a/uv.lock b/uv.lock index 2c98946e..3b25d8c6 100644 --- a/uv.lock +++ b/uv.lock @@ -161,9 +161,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "wrapt", marker = "python_full_version < '3.13'" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -615,8 +615,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "aiologic" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -665,9 +665,9 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "asgiref", marker = "python_full_version < '3.12'" }, - { name = "sqlparse", marker = "python_full_version < '3.12'" }, - { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" } wheels = [ @@ -685,9 +685,9 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "asgiref", marker = "python_full_version >= '3.12'" }, - { name = "sqlparse", marker = "python_full_version >= '3.12'" }, - { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } wheels = [ @@ -3925,7 +3925,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.45.0" +version = "0.45.1" source = { editable = "." } dependencies = [ { name = "cryptography" }, From f1ab0ea4e4d051c72d0ebd53f64344c74e48d9e1 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Thu, 20 Aug 2026 13:54:45 -0300 Subject: [PATCH 02/10] bump: version bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c6b0bcde..28174475 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.45.1" +version = "0.45.2" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" From 116315b3c93e218d8f4d52e09e7a2769744deb86 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Thu, 20 Aug 2026 14:33:56 -0300 Subject: [PATCH 03/10] refactor: undo changes in uv.lock --- uv.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/uv.lock b/uv.lock index 3b25d8c6..43f3a018 100644 --- a/uv.lock +++ b/uv.lock @@ -161,9 +161,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio" }, - { name = "typing-extensions" }, - { name = "wrapt" }, + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -615,8 +615,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic" }, - { name = "typing-extensions" }, + { name = "aiologic, marker = "python_full_version < '3.13'"" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -665,9 +665,9 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "asgiref", marker = "python_full_version < '3.13'" }, + { name = "sqlparse", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" } wheels = [ @@ -685,9 +685,9 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "asgiref", marker = "python_full_version >= '3.12'" }, + { name = "sqlparse", marker = "python_full_version >= '3.12'" }, + { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } wheels = [ @@ -3925,7 +3925,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.45.1" +version = "0.45.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, From 2ea23b0f4d983c3df1b5e274b5c6131460d13484 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Thu, 20 Aug 2026 14:36:44 -0300 Subject: [PATCH 04/10] refactor: undo changes in uv.lock --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 43f3a018..2c98946e 100644 --- a/uv.lock +++ b/uv.lock @@ -615,7 +615,7 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic, marker = "python_full_version < '3.13'"" }, + { name = "aiologic", marker = "python_full_version < '3.13'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } @@ -665,8 +665,8 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "asgiref", marker = "python_full_version < '3.13'" }, - { name = "sqlparse", marker = "python_full_version < '3.13'" }, + { name = "asgiref", marker = "python_full_version < '3.12'" }, + { name = "sqlparse", marker = "python_full_version < '3.12'" }, { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" } From a83d7bcbda9c5aea5ad342f3b3c7405d85e1f982 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Thu, 20 Aug 2026 15:15:14 -0300 Subject: [PATCH 05/10] refactor: rename url_utils to tenant --- src/sap_cloud_sdk/agentgateway/agw_client.py | 2 +- .../core/{url_utils.py => _tenant.py} | 0 src/sap_cloud_sdk/destination/_http.py | 2 +- src/sap_cloud_sdk/dms/_auth.py | 2 +- .../{test_url_utils.py => test_tenant.py} | 2 +- uv.lock | 24 +++++++++---------- 6 files changed, 16 insertions(+), 16 deletions(-) rename src/sap_cloud_sdk/core/{url_utils.py => _tenant.py} (100%) rename tests/core/unit/{test_url_utils.py => test_tenant.py} (94%) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 25b4d4c8..05d2ccc6 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -37,7 +37,7 @@ MCPTool, MCPToolFilter, ) -from sap_cloud_sdk.core.url_utils import _validate_tenant_subdomain +from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain from sap_cloud_sdk.agentgateway._token_cache import _GatewayUrlCache, _TokenCache from sap_cloud_sdk.agentgateway.exceptions import AgentGatewaySDKError from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics diff --git a/src/sap_cloud_sdk/core/url_utils.py b/src/sap_cloud_sdk/core/_tenant.py similarity index 100% rename from src/sap_cloud_sdk/core/url_utils.py rename to src/sap_cloud_sdk/core/_tenant.py diff --git a/src/sap_cloud_sdk/destination/_http.py b/src/sap_cloud_sdk/destination/_http.py index 8cc5cebe..95bbfdb2 100644 --- a/src/sap_cloud_sdk/destination/_http.py +++ b/src/sap_cloud_sdk/destination/_http.py @@ -18,7 +18,7 @@ from sap_cloud_sdk.destination.config import DestinationConfig from sap_cloud_sdk.destination.exceptions import HttpError -from sap_cloud_sdk.core.url_utils import _validate_tenant_subdomain +from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain # API version constants API_V1 = "v1" diff --git a/src/sap_cloud_sdk/dms/_auth.py b/src/sap_cloud_sdk/dms/_auth.py index 909619c5..cedb032d 100644 --- a/src/sap_cloud_sdk/dms/_auth.py +++ b/src/sap_cloud_sdk/dms/_auth.py @@ -10,7 +10,7 @@ DMSPermissionDeniedException, ) from sap_cloud_sdk.dms.model import DMSCredentials -from sap_cloud_sdk.core.url_utils import _validate_tenant_subdomain +from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain logger = logging.getLogger(__name__) diff --git a/tests/core/unit/test_url_utils.py b/tests/core/unit/test_tenant.py similarity index 94% rename from tests/core/unit/test_url_utils.py rename to tests/core/unit/test_tenant.py index 4d68da62..b1d259d4 100644 --- a/tests/core/unit/test_url_utils.py +++ b/tests/core/unit/test_tenant.py @@ -2,7 +2,7 @@ import pytest -from sap_cloud_sdk.core.url_utils import _validate_tenant_subdomain +from sap_cloud_sdk.core._tenant import _validate_tenant_subdomain class TestValidateTenantSubdomain: diff --git a/uv.lock b/uv.lock index 2c98946e..316c661e 100644 --- a/uv.lock +++ b/uv.lock @@ -161,9 +161,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "wrapt", marker = "python_full_version < '3.13'" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -615,8 +615,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "aiologic" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -665,9 +665,9 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "asgiref", marker = "python_full_version < '3.12'" }, - { name = "sqlparse", marker = "python_full_version < '3.12'" }, - { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" } wheels = [ @@ -685,9 +685,9 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "asgiref", marker = "python_full_version >= '3.12'" }, - { name = "sqlparse", marker = "python_full_version >= '3.12'" }, - { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } wheels = [ @@ -3925,7 +3925,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.45.0" +version = "0.45.2" source = { editable = "." } dependencies = [ { name = "cryptography" }, From e5cc6e9fa235b65b3be516e937486435cb30059e Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Thu, 20 Aug 2026 15:17:14 -0300 Subject: [PATCH 06/10] refactor: rollback uv --- uv.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/uv.lock b/uv.lock index 316c661e..2c98946e 100644 --- a/uv.lock +++ b/uv.lock @@ -161,9 +161,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio" }, - { name = "typing-extensions" }, - { name = "wrapt" }, + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -615,8 +615,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic" }, - { name = "typing-extensions" }, + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -665,9 +665,9 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "asgiref", marker = "python_full_version < '3.12'" }, + { name = "sqlparse", marker = "python_full_version < '3.12'" }, + { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" } wheels = [ @@ -685,9 +685,9 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "asgiref", marker = "python_full_version >= '3.12'" }, + { name = "sqlparse", marker = "python_full_version >= '3.12'" }, + { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } wheels = [ @@ -3925,7 +3925,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.45.2" +version = "0.45.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, From e1568a4570598f66633ad75840234249d2dacaf6 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Thu, 20 Aug 2026 15:43:38 -0300 Subject: [PATCH 07/10] refactor: implement pr comments --- src/sap_cloud_sdk/agentgateway/agw_client.py | 6 ++--- uv.lock | 24 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/agw_client.py b/src/sap_cloud_sdk/agentgateway/agw_client.py index 05d2ccc6..6ee67458 100644 --- a/src/sap_cloud_sdk/agentgateway/agw_client.py +++ b/src/sap_cloud_sdk/agentgateway/agw_client.py @@ -156,12 +156,12 @@ def _resolve_value( def _resolve_tenant_subdomain(self) -> str: """Resolve tenant subdomain from string or callable.""" - resolved = self._resolve_value( + resolved_tenant_subdomain = self._resolve_value( self._tenant_subdomain, "tenant_subdomain is required for LoB agent flow.", ) - _validate_tenant_subdomain(resolved) - return resolved + _validate_tenant_subdomain(resolved_tenant_subdomain) + return resolved_tenant_subdomain @record_metrics(Module.AGENTGATEWAY, Operation.AGENTGATEWAY_GET_SYSTEM_AUTH) async def get_system_auth(self) -> AuthResult: diff --git a/uv.lock b/uv.lock index 2c98946e..316c661e 100644 --- a/uv.lock +++ b/uv.lock @@ -161,9 +161,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "wrapt", marker = "python_full_version < '3.13'" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -615,8 +615,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "aiologic" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -665,9 +665,9 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "asgiref", marker = "python_full_version < '3.12'" }, - { name = "sqlparse", marker = "python_full_version < '3.12'" }, - { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" } wheels = [ @@ -685,9 +685,9 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "asgiref", marker = "python_full_version >= '3.12'" }, - { name = "sqlparse", marker = "python_full_version >= '3.12'" }, - { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } wheels = [ @@ -3925,7 +3925,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.45.0" +version = "0.45.2" source = { editable = "." } dependencies = [ { name = "cryptography" }, From 197e7ab3c75c4a4c6e1b115c8d7729d632285664 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Thu, 20 Aug 2026 15:44:41 -0300 Subject: [PATCH 08/10] refactor: rollback uv --- uv.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/uv.lock b/uv.lock index 316c661e..2c98946e 100644 --- a/uv.lock +++ b/uv.lock @@ -161,9 +161,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio" }, - { name = "typing-extensions" }, - { name = "wrapt" }, + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -615,8 +615,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic" }, - { name = "typing-extensions" }, + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -665,9 +665,9 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "asgiref", marker = "python_full_version < '3.12'" }, + { name = "sqlparse", marker = "python_full_version < '3.12'" }, + { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" } wheels = [ @@ -685,9 +685,9 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "asgiref", marker = "python_full_version >= '3.12'" }, + { name = "sqlparse", marker = "python_full_version >= '3.12'" }, + { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } wheels = [ @@ -3925,7 +3925,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.45.2" +version = "0.45.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, From 60c7d76235be7d5e19cb5a733f7a9c0752957248 Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Thu, 20 Aug 2026 16:56:35 -0300 Subject: [PATCH 09/10] refactor: implement pr comments --- src/sap_cloud_sdk/destination/_http.py | 6 +----- uv.lock | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/sap_cloud_sdk/destination/_http.py b/src/sap_cloud_sdk/destination/_http.py index 95bbfdb2..72a451f7 100644 --- a/src/sap_cloud_sdk/destination/_http.py +++ b/src/sap_cloud_sdk/destination/_http.py @@ -68,11 +68,7 @@ def get_token(self, tenant_subdomain: Optional[str] = None) -> str: _validate_tenant_subdomain(tenant_subdomain) if tenant_subdomain: - try: - token_url = token_url.replace(str(identityzone), tenant_subdomain) - except Exception: - # Fallback to base token_url if replacement fails - token_url = self._config.token_url + token_url = token_url.replace(str(identityzone), tenant_subdomain) token: Dict[str, Any] = self._session.fetch_token( token_url=token_url, diff --git a/uv.lock b/uv.lock index 2c98946e..316c661e 100644 --- a/uv.lock +++ b/uv.lock @@ -161,9 +161,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "wrapt", marker = "python_full_version < '3.13'" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -615,8 +615,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "aiologic" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -665,9 +665,9 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "asgiref", marker = "python_full_version < '3.12'" }, - { name = "sqlparse", marker = "python_full_version < '3.12'" }, - { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" } wheels = [ @@ -685,9 +685,9 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "asgiref", marker = "python_full_version >= '3.12'" }, - { name = "sqlparse", marker = "python_full_version >= '3.12'" }, - { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } wheels = [ @@ -3925,7 +3925,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.45.0" +version = "0.45.2" source = { editable = "." } dependencies = [ { name = "cryptography" }, From 2d8b2fe4bcc5ccc77b20af4c824950fbb2fd0bde Mon Sep 17 00:00:00 2001 From: Betina Benaduce Date: Thu, 20 Aug 2026 16:56:56 -0300 Subject: [PATCH 10/10] refactor: rollback uv --- uv.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/uv.lock b/uv.lock index 316c661e..2c98946e 100644 --- a/uv.lock +++ b/uv.lock @@ -161,9 +161,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio" }, - { name = "typing-extensions" }, - { name = "wrapt" }, + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -615,8 +615,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic" }, - { name = "typing-extensions" }, + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -665,9 +665,9 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "asgiref", marker = "python_full_version < '3.12'" }, + { name = "sqlparse", marker = "python_full_version < '3.12'" }, + { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" } wheels = [ @@ -685,9 +685,9 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "asgiref", marker = "python_full_version >= '3.12'" }, + { name = "sqlparse", marker = "python_full_version >= '3.12'" }, + { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } wheels = [ @@ -3925,7 +3925,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.45.2" +version = "0.45.0" source = { editable = "." } dependencies = [ { name = "cryptography" },