From a995377d4f15fc4f38c7e5fa42b1d20434086e7f Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Mon, 20 Jul 2026 13:28:33 +0200 Subject: [PATCH 01/17] context var --- src/sap_cloud_sdk/ias/__init__.py | 5 ++++- src/sap_cloud_sdk/ias/_context.py | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 src/sap_cloud_sdk/ias/_context.py diff --git a/src/sap_cloud_sdk/ias/__init__.py b/src/sap_cloud_sdk/ias/__init__.py index 88df1774..c2459702 100644 --- a/src/sap_cloud_sdk/ias/__init__.py +++ b/src/sap_cloud_sdk/ias/__init__.py @@ -12,11 +12,14 @@ print(claims.email) # user email (when email scope requested) """ +from sap_cloud_sdk.ias._context import get_auth_context, set_auth_context from sap_cloud_sdk.ias._token import IASClaims, parse_token from sap_cloud_sdk.ias.exceptions import IASTokenError __all__ = [ "IASClaims", - "parse_token", "IASTokenError", + "get_auth_context", + "parse_token", + "set_auth_context", ] diff --git a/src/sap_cloud_sdk/ias/_context.py b/src/sap_cloud_sdk/ias/_context.py new file mode 100644 index 00000000..ff578401 --- /dev/null +++ b/src/sap_cloud_sdk/ias/_context.py @@ -0,0 +1,20 @@ +"""Per-request IAS auth context via ContextVar.""" + +from contextvars import ContextVar +from typing import Optional + +from sap_cloud_sdk.ias._token import IASClaims + +_auth_context_var: ContextVar[Optional[IASClaims]] = ContextVar( + "ias_auth_context", default=None +) + + +def set_auth_context(claims: Optional[IASClaims]) -> None: + """Store IAS claims for the current async context.""" + _auth_context_var.set(claims) + + +def get_auth_context() -> Optional[IASClaims]: + """Return the IAS claims set for the current async context, or None.""" + return _auth_context_var.get() From 3680f4f7fcdd863be13c2f5979a6e9188ab9ff29 Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Mon, 20 Jul 2026 15:04:18 +0200 Subject: [PATCH 02/17] auto tenant logging --- src/sap_cloud_sdk/core/auditlog_ng/client.py | 15 +++++++++++++++ .../core/telemetry/middleware/starlette_a2a.py | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/sap_cloud_sdk/core/auditlog_ng/client.py b/src/sap_cloud_sdk/core/auditlog_ng/client.py index 258e7960..815292b7 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/client.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/client.py @@ -37,6 +37,19 @@ from sap_cloud_sdk.core.auditlog_ng.exceptions import ValidationError from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics from sap_cloud_sdk.core.telemetry.config import ENV_OTLP_PROTOCOL +from sap_cloud_sdk.ias._context import get_auth_context + + +def _fill_common_from_auth_context(event: Message) -> None: + """Fill empty common.tenant_id and common.user_initiator_id from IAS auth context.""" + claims = get_auth_context() + if claims is None: + return + common = getattr(event, "common", None) + if claims.app_tid and not common.tenant_id: + common.tenant_id = claims.app_tid + if claims.user_uuid and not common.user_initiator_id: + common.user_initiator_id = claims.user_uuid def _create_log_exporter( @@ -178,6 +191,8 @@ def send( if format not in {"protobuf-binary", "json"}: raise ValueError("format must be 'protobuf-binary' or 'json'") + _fill_common_from_auth_context(event) + try: protovalidate.validate(event) except ProtoValidationError as e: diff --git a/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py b/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py index 1b34c733..61865c90 100644 --- a/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py +++ b/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py @@ -11,6 +11,7 @@ ) from sap_cloud_sdk.core.telemetry.middleware.base import TelemetryMiddleware from sap_cloud_sdk.ias import parse_token +from sap_cloud_sdk.ias._context import set_auth_context try: from starlette.middleware.base import BaseHTTPMiddleware @@ -31,11 +32,13 @@ def __init__(self, app: Any, attrs_var: ContextVar[Dict[str, Any]]) -> None: self._attrs_var = attrs_var async def dispatch(self, request: Request, call_next: Any) -> Response: + set_auth_context(_parse_ias_claims(request)) token = self._attrs_var.set(_extract_ias_attrs(request)) try: return await call_next(request) finally: self._attrs_var.reset(token) + set_auth_context(None) class StarletteIASTelemetryMiddleware(TelemetryMiddleware): @@ -78,6 +81,18 @@ def get_attributes(self) -> Dict[str, Any]: return self._attrs_var.get() +def _parse_ias_claims(request: Request): + """Parse the Authorization header and return IASClaims, or None on failure.""" + auth = request.headers.get("authorization", "") + if not auth: + return None + try: + return parse_token(auth) + except Exception as e: + logger.debug("IAS token parsing failed, skipping telemetry attrs: %s", e) + return None + + def _extract_ias_attrs(request: Request) -> Dict[str, Any]: """Parse the Authorization header and return telemetry attributes.""" auth = request.headers.get("authorization", "") From 61e4bc44d6ce8ab70bd2eeebd900f1b4a37dca8e Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Mon, 20 Jul 2026 16:16:20 +0200 Subject: [PATCH 03/17] als tenant inference --- .../telemetry/middleware/starlette_a2a.py | 46 +++++++------------ 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py b/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py index 61865c90..84a8adcd 100644 --- a/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py +++ b/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py @@ -32,8 +32,9 @@ def __init__(self, app: Any, attrs_var: ContextVar[Dict[str, Any]]) -> None: self._attrs_var = attrs_var async def dispatch(self, request: Request, call_next: Any) -> Response: - set_auth_context(_parse_ias_claims(request)) - token = self._attrs_var.set(_extract_ias_attrs(request)) + claims, attrs = _parse_request(request) + set_auth_context(claims) + token = self._attrs_var.set(attrs) try: return await call_next(request) finally: @@ -81,35 +82,22 @@ def get_attributes(self) -> Dict[str, Any]: return self._attrs_var.get() -def _parse_ias_claims(request: Request): - """Parse the Authorization header and return IASClaims, or None on failure.""" +def _parse_request(request: Request): + """Parse the Authorization header and return (IASClaims, telemetry_attrs).""" auth = request.headers.get("authorization", "") - if not auth: - return None - try: - return parse_token(auth) - except Exception as e: - logger.debug("IAS token parsing failed, skipping telemetry attrs: %s", e) - return None - - -def _extract_ias_attrs(request: Request) -> Dict[str, Any]: - """Parse the Authorization header and return telemetry attributes.""" - auth = request.headers.get("authorization", "") - if not auth: - return {} - try: - claims = parse_token(auth) - except Exception as e: - logger.debug("IAS token parsing failed, skipping telemetry attrs: %s", e) - return {} - + claims = None attrs: Dict[str, Any] = {} - if claims.sap_gtid: - attrs[ATTR_SAP_TENANT_ID] = claims.sap_gtid - if claims.user_uuid: - attrs[ATTR_USER_ID] = claims.user_uuid + if auth: + try: + claims = parse_token(auth) + except Exception as e: + logger.debug("IAS token parsing failed, skipping telemetry attrs: %s", e) + if claims is not None: + if claims.sap_gtid: + attrs[ATTR_SAP_TENANT_ID] = claims.sap_gtid + if claims.user_uuid: + attrs[ATTR_USER_ID] = claims.user_uuid origin = request.headers.get("x-sap-origin") if origin: attrs[ATTR_SAP_TRIGGER_TYPE] = origin - return attrs + return claims, attrs From 1f6f3cac9bb2f555bf95f254c1b7b7a6e4bdfd67 Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Tue, 21 Jul 2026 18:30:23 +0200 Subject: [PATCH 04/17] SDK bootstrap --- src/sap_cloud_sdk/__init__.py | 4 + src/sap_cloud_sdk/core/auditlog_ng/client.py | 20 +- src/sap_cloud_sdk/core/bootstrap.py | 61 +++++ .../core/runtime_context/__init__.py | 48 ++++ .../core/runtime_context/_context.py | 66 ++++++ .../core/runtime_context/_protocol.py | 23 ++ .../core/runtime_context/_providers.py | 42 ++++ .../core/runtime_context/starlette.py | 49 ++++ tests/core/unit/runtime_context/__init__.py | 0 .../runtime_context/test_runtime_context.py | 218 ++++++++++++++++++ 10 files changed, 520 insertions(+), 11 deletions(-) create mode 100644 src/sap_cloud_sdk/core/bootstrap.py create mode 100644 src/sap_cloud_sdk/core/runtime_context/__init__.py create mode 100644 src/sap_cloud_sdk/core/runtime_context/_context.py create mode 100644 src/sap_cloud_sdk/core/runtime_context/_protocol.py create mode 100644 src/sap_cloud_sdk/core/runtime_context/_providers.py create mode 100644 src/sap_cloud_sdk/core/runtime_context/starlette.py create mode 100644 tests/core/unit/runtime_context/__init__.py create mode 100644 tests/core/unit/runtime_context/test_runtime_context.py diff --git a/src/sap_cloud_sdk/__init__.py b/src/sap_cloud_sdk/__init__.py index 25b731d5..4bb4e72d 100644 --- a/src/sap_cloud_sdk/__init__.py +++ b/src/sap_cloud_sdk/__init__.py @@ -1 +1,5 @@ # SAP Cloud SDK for Python + +from sap_cloud_sdk.core.bootstrap import bootstrap + +__all__ = ["bootstrap"] diff --git a/src/sap_cloud_sdk/core/auditlog_ng/client.py b/src/sap_cloud_sdk/core/auditlog_ng/client.py index 815292b7..4a113b70 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/client.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/client.py @@ -37,19 +37,17 @@ from sap_cloud_sdk.core.auditlog_ng.exceptions import ValidationError from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics from sap_cloud_sdk.core.telemetry.config import ENV_OTLP_PROTOCOL -from sap_cloud_sdk.ias._context import get_auth_context +from sap_cloud_sdk.core.runtime_context import get_context -def _fill_common_from_auth_context(event: Message) -> None: - """Fill empty common.tenant_id and common.user_initiator_id from IAS auth context.""" - claims = get_auth_context() - if claims is None: - return +def _fill_common_from_context(event: Message) -> None: + """Fill empty common.tenant_id and common.user_initiator_id from SDK runtime context.""" + ctx = get_context() common = getattr(event, "common", None) - if claims.app_tid and not common.tenant_id: - common.tenant_id = claims.app_tid - if claims.user_uuid and not common.user_initiator_id: - common.user_initiator_id = claims.user_uuid + if ctx.tenant_id and not common.tenant_id: + common.tenant_id = ctx.tenant_id + if ctx.user_id and not common.user_initiator_id: + common.user_initiator_id = ctx.user_id def _create_log_exporter( @@ -191,7 +189,7 @@ def send( if format not in {"protobuf-binary", "json"}: raise ValueError("format must be 'protobuf-binary' or 'json'") - _fill_common_from_auth_context(event) + _fill_common_from_context(event) try: protovalidate.validate(event) diff --git a/src/sap_cloud_sdk/core/bootstrap.py b/src/sap_cloud_sdk/core/bootstrap.py new file mode 100644 index 00000000..09e32d2f --- /dev/null +++ b/src/sap_cloud_sdk/core/bootstrap.py @@ -0,0 +1,61 @@ +"""Top-level bootstrap() entry point for the SAP Cloud SDK.""" + +from typing import Any + +from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider + + +def bootstrap(app: Any, provider: ContextProvider) -> None: + """Wire the SDK runtime context into your application framework. + + Call this once at application startup. The SDK will attach a middleware + to *app* that populates :class:`~sap_cloud_sdk.core.runtime_context.RequestContext` + on every inbound request using *provider*. + + After bootstrapping, any SDK module (auditlog, telemetry, etc.) can call + :func:`~sap_cloud_sdk.core.runtime_context.get_context` to read + tenant/user information without knowing about headers or auth providers. + + Supported frameworks (detected automatically from *app* type): + - Starlette / FastAPI + + Args: + app: The application instance to attach the middleware to. + provider: A :class:`~sap_cloud_sdk.core.runtime_context.ContextProvider` + that knows how to extract context from a request in this framework. + + Raises: + TypeError: If *app* is not a recognised framework application type. + + Example:: + + from starlette.applications import Starlette + from sap_cloud_sdk import bootstrap + from sap_cloud_sdk.core.runtime_context import IASContextProvider + + app = Starlette(...) + bootstrap(app, provider=IASContextProvider()) + """ + _attach(app, provider) + + +def _attach(app: Any, provider: ContextProvider) -> None: + """Detect framework and register the appropriate context middleware.""" + # Starlette / FastAPI — both expose add_middleware and share the same base + try: + from starlette.applications import Starlette + from sap_cloud_sdk.core.runtime_context.starlette import ( + StarletteContextMiddleware, + ) + + if isinstance(app, Starlette): + app.add_middleware(StarletteContextMiddleware, provider=provider) + return + except ImportError: + pass + + raise TypeError( + f"bootstrap() does not recognise app type {type(app)!r}. " + "Supported frameworks: Starlette/FastAPI. " + "For other frameworks, register the middleware manually." + ) diff --git a/src/sap_cloud_sdk/core/runtime_context/__init__.py b/src/sap_cloud_sdk/core/runtime_context/__init__.py new file mode 100644 index 00000000..4970ad66 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/__init__.py @@ -0,0 +1,48 @@ +"""SDK-wide per-request runtime context. + +Provides a provider-agnostic way for SDK modules to access caller-identity +fields (tenant, user, trigger type) without coupling to a specific auth +provider or HTTP framework. + +Typical usage — wire once at app startup:: + + from sap_cloud_sdk import bootstrap + from sap_cloud_sdk.core.runtime_context import IASContextProvider + + bootstrap(app, provider=IASContextProvider()) + +Then read anywhere in the SDK or application code:: + + from sap_cloud_sdk.core.runtime_context import get_context + + ctx = get_context() + print(ctx.tenant_id) # e.g. "abc-123" + print(ctx.user_id) # e.g. "user-uuid" + +For tests or CLI usage without HTTP:: + + from sap_cloud_sdk.core.runtime_context import sdk_context, RequestContext + + with sdk_context(RequestContext(tenant_id="test-tenant")): + ... # get_context() returns that context here +""" + +from sap_cloud_sdk.core.runtime_context._context import ( + RequestContext, + async_sdk_context, + get_context, + sdk_context, + set_context, +) +from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider +from sap_cloud_sdk.core.runtime_context._providers import IASContextProvider + +__all__ = [ + "ContextProvider", + "IASContextProvider", + "RequestContext", + "async_sdk_context", + "get_context", + "sdk_context", + "set_context", +] diff --git a/src/sap_cloud_sdk/core/runtime_context/_context.py b/src/sap_cloud_sdk/core/runtime_context/_context.py new file mode 100644 index 00000000..fd2326f0 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/_context.py @@ -0,0 +1,66 @@ +"""ContextVar backing store for the SDK runtime context.""" + +from contextlib import asynccontextmanager, contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, Dict, Generator, Optional + + +@dataclass +class RequestContext: + """Caller-identity snapshot for the current request. + + Attributes: + tenant_id: Tenant identifier. + user_id: User identifier. + trigger_type: Origin of the request (e.g. from ``x-sap-origin``). + extras: Arbitrary additional data keyed by string. + """ + + tenant_id: Optional[str] = field(default=None) + user_id: Optional[str] = field(default=None) + trigger_type: Optional[str] = field(default=None) + extras: Dict[str, Any] = field(default_factory=dict) + + +_EMPTY = RequestContext() + +_context_var: ContextVar[RequestContext] = ContextVar( + "sap_sdk_request_context", default=_EMPTY +) + + +def set_context(ctx: RequestContext) -> None: + """Set the runtime context for the current async/thread scope.""" + _context_var.set(ctx) + + +def get_context() -> RequestContext: + """Return the runtime context for the current async/thread scope. + + Returns an empty :class:`RequestContext` (all fields ``None``) when no + context has been set. + """ + return _context_var.get() + + +@contextmanager +def sdk_context(ctx: RequestContext) -> Generator[RequestContext, None, None]: + """Sync context manager that sets *ctx* for the duration of the block.""" + token = _context_var.set(ctx) + try: + yield ctx + finally: + _context_var.reset(token) + + +@asynccontextmanager +async def async_sdk_context( + ctx: RequestContext, +) -> AsyncGenerator[RequestContext, None]: + """Async context manager that sets *ctx* for the duration of the block.""" + token = _context_var.set(ctx) + try: + yield ctx + finally: + _context_var.reset(token) diff --git a/src/sap_cloud_sdk/core/runtime_context/_protocol.py b/src/sap_cloud_sdk/core/runtime_context/_protocol.py new file mode 100644 index 00000000..59f3e1ab --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/_protocol.py @@ -0,0 +1,23 @@ +"""ContextProvider protocol — the pluggable extraction interface.""" + +from typing import Any, Protocol, runtime_checkable + +from sap_cloud_sdk.core.runtime_context._context import RequestContext + + +@runtime_checkable +class ContextProvider(Protocol): + """Extract a :class:`RequestContext` from a framework-specific request object. + + Implement this protocol to teach the SDK how to read caller-identity + information from a new framework (Flask, gRPC, raw WSGI, etc.). + + Example:: + + class MyProvider(ContextProvider): + def extract(self, request: MyRequest) -> RequestContext: + return RequestContext(tenant_id=request.tenant, user_id=request.user) + """ + + def extract(self, request: Any) -> RequestContext: # pragma: no cover + ... diff --git a/src/sap_cloud_sdk/core/runtime_context/_providers.py b/src/sap_cloud_sdk/core/runtime_context/_providers.py new file mode 100644 index 00000000..e0a32c62 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/_providers.py @@ -0,0 +1,42 @@ +"""Built-in ContextProvider implementations.""" + +from typing import Any + +from sap_cloud_sdk.core.runtime_context._context import RequestContext +from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider +from sap_cloud_sdk.ias import parse_token + + +class IASContextProvider(ContextProvider): + """Extracts tenant/user context from an IAS JWT ``Authorization`` header. + + Works with any request object that exposes headers via ``request.headers`` + (Starlette, FastAPI, etc.). + + The following claims are mapped: + - ``app_tid`` → :attr:`RequestContext.tenant_id` + - ``user_uuid`` → :attr:`RequestContext.user_id` + - ``x-sap-origin`` header → :attr:`RequestContext.trigger_type` + + Full :class:`~sap_cloud_sdk.ias.IASClaims` are stored under + ``RequestContext.extras["ias.claims"]`` for modules that need them. + """ + + def extract(self, request: Any) -> RequestContext: + headers = getattr(request, "headers", {}) + auth = headers.get("authorization", "") or headers.get("Authorization", "") + origin = headers.get("x-sap-origin") or headers.get("X-Sap-Origin") + + claims = None + if auth: + try: + claims = parse_token(auth) + except Exception: + pass + + return RequestContext( + tenant_id=claims.app_tid if claims else None, + user_id=claims.user_uuid if claims else None, + trigger_type=origin or None, + extras={"ias.claims": claims} if claims else {}, + ) diff --git a/src/sap_cloud_sdk/core/runtime_context/starlette.py b/src/sap_cloud_sdk/core/runtime_context/starlette.py new file mode 100644 index 00000000..e03694c6 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/starlette.py @@ -0,0 +1,49 @@ +"""Starlette/FastAPI context middleware.""" + +from typing import Any + +from sap_cloud_sdk.core.runtime_context._context import async_sdk_context +from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider + +try: + from starlette.middleware.base import BaseHTTPMiddleware + from starlette.requests import Request + from starlette.responses import Response +except ImportError as exc: + raise ImportError( + "The 'starlette' package is required to use StarletteContextMiddleware. " + "Install it with: pip install starlette" + ) from exc + + +class StarletteContextMiddleware(BaseHTTPMiddleware): + """Starlette/FastAPI middleware that populates the SDK runtime context. + + Runs *provider*.extract() on every inbound request and makes the result + available via :func:`~sap_cloud_sdk.core.runtime_context.get_context` + for the duration of that request. + + Usage:: + + from starlette.applications import Starlette + from sap_cloud_sdk import bootstrap + + app = Starlette(...) + bootstrap(app) + + Or manually:: + + from sap_cloud_sdk.core.runtime_context.starlette import StarletteContextMiddleware + from sap_cloud_sdk.core.runtime_context import IASContextProvider + + app.add_middleware(StarletteContextMiddleware, provider=IASContextProvider()) + """ + + def __init__(self, app: Any, provider: ContextProvider) -> None: + super().__init__(app) + self._provider = provider + + async def dispatch(self, request: Request, call_next: Any) -> Response: + ctx = self._provider.extract(request) + async with async_sdk_context(ctx): + return await call_next(request) diff --git a/tests/core/unit/runtime_context/__init__.py b/tests/core/unit/runtime_context/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/core/unit/runtime_context/test_runtime_context.py b/tests/core/unit/runtime_context/test_runtime_context.py new file mode 100644 index 00000000..f0ef1873 --- /dev/null +++ b/tests/core/unit/runtime_context/test_runtime_context.py @@ -0,0 +1,218 @@ +"""Tests for sap_cloud_sdk.core.runtime_context.""" + +import pytest +from unittest.mock import MagicMock, patch + +from sap_cloud_sdk.core.runtime_context import ( + ContextProvider, + IASContextProvider, + RequestContext, + async_sdk_context, + get_context, + sdk_context, + set_context, +) + +_PATCH_PARSE = "sap_cloud_sdk.core.runtime_context._providers.parse_token" + + +# --------------------------------------------------------------------------- +# RequestContext +# --------------------------------------------------------------------------- + +class TestRequestContext: + def test_defaults_are_none(self): + ctx = RequestContext() + assert ctx.tenant_id is None + assert ctx.user_id is None + assert ctx.trigger_type is None + assert ctx.extras == {} + + def test_fields_are_settable(self): + ctx = RequestContext(tenant_id="t1", user_id="u1", trigger_type="ui5") + assert ctx.tenant_id == "t1" + assert ctx.user_id == "u1" + assert ctx.trigger_type == "ui5" + + def test_extras_are_independent_per_instance(self): + a = RequestContext() + b = RequestContext() + a.extras["x"] = 1 + assert "x" not in b.extras + + +# --------------------------------------------------------------------------- +# get_context / set_context +# --------------------------------------------------------------------------- + +class TestGetSetContext: + def test_get_returns_empty_by_default(self): + ctx = get_context() + assert ctx.tenant_id is None + assert ctx.user_id is None + + def test_set_then_get_returns_same_object(self): + ctx = RequestContext(tenant_id="abc") + set_context(ctx) + assert get_context() is ctx + + def teardown_method(self): + set_context(RequestContext()) + + +# --------------------------------------------------------------------------- +# sdk_context (sync) +# --------------------------------------------------------------------------- + +class TestSdkContext: + def test_sets_context_inside_block(self): + ctx = RequestContext(tenant_id="inside") + with sdk_context(ctx): + assert get_context().tenant_id == "inside" + + def test_restores_previous_context_after_block(self): + outer = RequestContext(tenant_id="outer") + set_context(outer) + with sdk_context(RequestContext(tenant_id="inner")): + pass + assert get_context().tenant_id == "outer" + + def test_restores_on_exception(self): + outer = RequestContext(tenant_id="outer") + set_context(outer) + with pytest.raises(ValueError): + with sdk_context(RequestContext(tenant_id="inner")): + raise ValueError("boom") + assert get_context().tenant_id == "outer" + + def test_yields_the_context(self): + ctx = RequestContext(tenant_id="t1") + with sdk_context(ctx) as yielded: + assert yielded is ctx + + def teardown_method(self): + set_context(RequestContext()) + + +# --------------------------------------------------------------------------- +# async_sdk_context +# --------------------------------------------------------------------------- + +class TestAsyncSdkContext: + @pytest.mark.anyio + async def test_sets_context_inside_async_block(self): + ctx = RequestContext(tenant_id="async-tenant") + async with async_sdk_context(ctx): + assert get_context().tenant_id == "async-tenant" + + @pytest.mark.anyio + async def test_restores_after_async_block(self): + outer = RequestContext(tenant_id="outer") + set_context(outer) + async with async_sdk_context(RequestContext(tenant_id="inner")): + pass + assert get_context().tenant_id == "outer" + + @pytest.mark.anyio + async def test_restores_on_async_exception(self): + outer = RequestContext(tenant_id="outer") + set_context(outer) + with pytest.raises(RuntimeError): + async with async_sdk_context(RequestContext(tenant_id="inner")): + raise RuntimeError("boom") + assert get_context().tenant_id == "outer" + + def teardown_method(self): + set_context(RequestContext()) + + +# --------------------------------------------------------------------------- +# ContextProvider protocol +# --------------------------------------------------------------------------- + +class TestContextProviderProtocol: + def test_custom_class_satisfies_protocol(self): + class MyProvider: + def extract(self, request) -> RequestContext: + return RequestContext(tenant_id="custom") + + assert isinstance(MyProvider(), ContextProvider) + + def test_class_without_extract_does_not_satisfy_protocol(self): + class NotAProvider: + pass + + assert not isinstance(NotAProvider(), ContextProvider) + + +# --------------------------------------------------------------------------- +# IASContextProvider +# --------------------------------------------------------------------------- + +def _make_claims(app_tid=None, user_uuid=None): + claims = MagicMock() + claims.app_tid = app_tid + claims.user_uuid = user_uuid + return claims + + +def _make_request(headers: dict): + req = MagicMock() + req.headers = headers + return req + + +class TestIASContextProvider: + def test_extracts_tenant_and_user(self): + claims = _make_claims(app_tid="t-1", user_uuid="u-1") + req = _make_request({"authorization": "Bearer tok"}) + with patch(_PATCH_PARSE, return_value=claims): + ctx = IASContextProvider().extract(req) + assert ctx.tenant_id == "t-1" + assert ctx.user_id == "u-1" + + def test_extracts_trigger_type_from_origin_header(self): + claims = _make_claims(app_tid="t-1", user_uuid="u-1") + req = _make_request({"authorization": "Bearer tok", "x-sap-origin": "ui5"}) + with patch(_PATCH_PARSE, return_value=claims): + ctx = IASContextProvider().extract(req) + assert ctx.trigger_type == "ui5" + + def test_trigger_type_none_when_header_absent(self): + claims = _make_claims(app_tid="t-1", user_uuid="u-1") + req = _make_request({"authorization": "Bearer tok"}) + with patch(_PATCH_PARSE, return_value=claims): + ctx = IASContextProvider().extract(req) + assert ctx.trigger_type is None + + def test_stores_full_claims_in_extras(self): + claims = _make_claims(app_tid="t-1", user_uuid="u-1") + req = _make_request({"authorization": "Bearer tok"}) + with patch(_PATCH_PARSE, return_value=claims): + ctx = IASContextProvider().extract(req) + assert ctx.extras.get("ias.claims") is claims + + def test_returns_empty_context_when_no_auth_header(self): + req = _make_request({}) + ctx = IASContextProvider().extract(req) + assert ctx.tenant_id is None + assert ctx.user_id is None + assert ctx.extras == {} + + def test_returns_empty_context_on_parse_error(self): + req = _make_request({"authorization": "Bearer bad"}) + with patch(_PATCH_PARSE, side_effect=ValueError("bad")): + ctx = IASContextProvider().extract(req) + assert ctx.tenant_id is None + assert ctx.user_id is None + + def test_tenant_id_none_when_claim_absent(self): + claims = _make_claims(app_tid=None, user_uuid="u-1") + req = _make_request({"authorization": "Bearer tok"}) + with patch(_PATCH_PARSE, return_value=claims): + ctx = IASContextProvider().extract(req) + assert ctx.tenant_id is None + assert ctx.user_id == "u-1" + + def test_satisfies_context_provider_protocol(self): + assert isinstance(IASContextProvider(), ContextProvider) From e85526da7630f027d1193fbabd8d772a55230ee5 Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Tue, 21 Jul 2026 18:43:18 +0200 Subject: [PATCH 05/17] decouple --- .../telemetry/middleware/starlette_a2a.py | 35 +++++++++---------- src/sap_cloud_sdk/ias/__init__.py | 5 +-- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py b/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py index 84a8adcd..1b34c733 100644 --- a/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py +++ b/src/sap_cloud_sdk/core/telemetry/middleware/starlette_a2a.py @@ -11,7 +11,6 @@ ) from sap_cloud_sdk.core.telemetry.middleware.base import TelemetryMiddleware from sap_cloud_sdk.ias import parse_token -from sap_cloud_sdk.ias._context import set_auth_context try: from starlette.middleware.base import BaseHTTPMiddleware @@ -32,14 +31,11 @@ def __init__(self, app: Any, attrs_var: ContextVar[Dict[str, Any]]) -> None: self._attrs_var = attrs_var async def dispatch(self, request: Request, call_next: Any) -> Response: - claims, attrs = _parse_request(request) - set_auth_context(claims) - token = self._attrs_var.set(attrs) + token = self._attrs_var.set(_extract_ias_attrs(request)) try: return await call_next(request) finally: self._attrs_var.reset(token) - set_auth_context(None) class StarletteIASTelemetryMiddleware(TelemetryMiddleware): @@ -82,22 +78,23 @@ def get_attributes(self) -> Dict[str, Any]: return self._attrs_var.get() -def _parse_request(request: Request): - """Parse the Authorization header and return (IASClaims, telemetry_attrs).""" +def _extract_ias_attrs(request: Request) -> Dict[str, Any]: + """Parse the Authorization header and return telemetry attributes.""" auth = request.headers.get("authorization", "") - claims = None + if not auth: + return {} + try: + claims = parse_token(auth) + except Exception as e: + logger.debug("IAS token parsing failed, skipping telemetry attrs: %s", e) + return {} + attrs: Dict[str, Any] = {} - if auth: - try: - claims = parse_token(auth) - except Exception as e: - logger.debug("IAS token parsing failed, skipping telemetry attrs: %s", e) - if claims is not None: - if claims.sap_gtid: - attrs[ATTR_SAP_TENANT_ID] = claims.sap_gtid - if claims.user_uuid: - attrs[ATTR_USER_ID] = claims.user_uuid + if claims.sap_gtid: + attrs[ATTR_SAP_TENANT_ID] = claims.sap_gtid + if claims.user_uuid: + attrs[ATTR_USER_ID] = claims.user_uuid origin = request.headers.get("x-sap-origin") if origin: attrs[ATTR_SAP_TRIGGER_TYPE] = origin - return claims, attrs + return attrs diff --git a/src/sap_cloud_sdk/ias/__init__.py b/src/sap_cloud_sdk/ias/__init__.py index c2459702..88df1774 100644 --- a/src/sap_cloud_sdk/ias/__init__.py +++ b/src/sap_cloud_sdk/ias/__init__.py @@ -12,14 +12,11 @@ print(claims.email) # user email (when email scope requested) """ -from sap_cloud_sdk.ias._context import get_auth_context, set_auth_context from sap_cloud_sdk.ias._token import IASClaims, parse_token from sap_cloud_sdk.ias.exceptions import IASTokenError __all__ = [ "IASClaims", - "IASTokenError", - "get_auth_context", "parse_token", - "set_auth_context", + "IASTokenError", ] From 72dacd5a8498f953d4c83d1353e058f8de786856 Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Tue, 21 Jul 2026 18:44:01 +0200 Subject: [PATCH 06/17] decouple --- src/sap_cloud_sdk/ias/_context.py | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 src/sap_cloud_sdk/ias/_context.py diff --git a/src/sap_cloud_sdk/ias/_context.py b/src/sap_cloud_sdk/ias/_context.py deleted file mode 100644 index ff578401..00000000 --- a/src/sap_cloud_sdk/ias/_context.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Per-request IAS auth context via ContextVar.""" - -from contextvars import ContextVar -from typing import Optional - -from sap_cloud_sdk.ias._token import IASClaims - -_auth_context_var: ContextVar[Optional[IASClaims]] = ContextVar( - "ias_auth_context", default=None -) - - -def set_auth_context(claims: Optional[IASClaims]) -> None: - """Store IAS claims for the current async context.""" - _auth_context_var.set(claims) - - -def get_auth_context() -> Optional[IASClaims]: - """Return the IAS claims set for the current async context, or None.""" - return _auth_context_var.get() From 32d6dd86878401f2061939622cebda2cc8fcdfc8 Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Tue, 21 Jul 2026 19:28:31 +0200 Subject: [PATCH 07/17] Extensible providers --- src/sap_cloud_sdk/core/bootstrap.py | 35 +++--- .../core/runtime_context/__init__.py | 2 + .../core/runtime_context/_envelope.py | 24 +++++ .../core/runtime_context/_protocol.py | 18 ++-- .../core/runtime_context/_providers.py | 20 ++-- .../core/runtime_context/starlette.py | 49 ++++++--- .../runtime_context/test_runtime_context.py | 101 ++++++++++++++---- 7 files changed, 179 insertions(+), 70 deletions(-) create mode 100644 src/sap_cloud_sdk/core/runtime_context/_envelope.py diff --git a/src/sap_cloud_sdk/core/bootstrap.py b/src/sap_cloud_sdk/core/bootstrap.py index 09e32d2f..0bbd7009 100644 --- a/src/sap_cloud_sdk/core/bootstrap.py +++ b/src/sap_cloud_sdk/core/bootstrap.py @@ -1,16 +1,17 @@ """Top-level bootstrap() entry point for the SAP Cloud SDK.""" -from typing import Any +from typing import Any, List, Optional from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider -def bootstrap(app: Any, provider: ContextProvider) -> None: +def bootstrap(app: Any, providers: Optional[List[ContextProvider]] = None) -> None: """Wire the SDK runtime context into your application framework. Call this once at application startup. The SDK will attach a middleware to *app* that populates :class:`~sap_cloud_sdk.core.runtime_context.RequestContext` - on every inbound request using *provider*. + on every inbound request by running all *providers* against it and merging + the results. After bootstrapping, any SDK module (auditlog, telemetry, etc.) can call :func:`~sap_cloud_sdk.core.runtime_context.get_context` to read @@ -20,36 +21,36 @@ def bootstrap(app: Any, provider: ContextProvider) -> None: - Starlette / FastAPI Args: - app: The application instance to attach the middleware to. - provider: A :class:`~sap_cloud_sdk.core.runtime_context.ContextProvider` - that knows how to extract context from a request in this framework. + app: The application instance to attach the middleware to. + providers: One or more :class:`~sap_cloud_sdk.core.runtime_context.ContextProvider` + instances. Defaults to ``[IASContextProvider()]``. Raises: TypeError: If *app* is not a recognised framework application type. Example:: - from starlette.applications import Starlette from sap_cloud_sdk import bootstrap - from sap_cloud_sdk.core.runtime_context import IASContextProvider - app = Starlette(...) - bootstrap(app, provider=IASContextProvider()) + bootstrap(app) # IASContextProvider by default + + # multiple providers: + bootstrap(app, providers=[IASContextProvider(), MyCustomProvider()]) """ - _attach(app, provider) + if not providers: + from sap_cloud_sdk.core.runtime_context import IASContextProvider + providers = [IASContextProvider()] + _attach(app, providers) -def _attach(app: Any, provider: ContextProvider) -> None: +def _attach(app: Any, providers: List[ContextProvider]) -> None: """Detect framework and register the appropriate context middleware.""" - # Starlette / FastAPI — both expose add_middleware and share the same base try: from starlette.applications import Starlette - from sap_cloud_sdk.core.runtime_context.starlette import ( - StarletteContextMiddleware, - ) + from sap_cloud_sdk.core.runtime_context.starlette import StarletteContextMiddleware if isinstance(app, Starlette): - app.add_middleware(StarletteContextMiddleware, provider=provider) + app.add_middleware(StarletteContextMiddleware, providers=providers) return except ImportError: pass diff --git a/src/sap_cloud_sdk/core/runtime_context/__init__.py b/src/sap_cloud_sdk/core/runtime_context/__init__.py index 4970ad66..69cf44d5 100644 --- a/src/sap_cloud_sdk/core/runtime_context/__init__.py +++ b/src/sap_cloud_sdk/core/runtime_context/__init__.py @@ -34,6 +34,7 @@ sdk_context, set_context, ) +from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider from sap_cloud_sdk.core.runtime_context._providers import IASContextProvider @@ -41,6 +42,7 @@ "ContextProvider", "IASContextProvider", "RequestContext", + "RequestEnvelope", "async_sdk_context", "get_context", "sdk_context", diff --git a/src/sap_cloud_sdk/core/runtime_context/_envelope.py b/src/sap_cloud_sdk/core/runtime_context/_envelope.py new file mode 100644 index 00000000..aee1dd39 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/_envelope.py @@ -0,0 +1,24 @@ +"""Framework-agnostic request envelope passed to ContextProviders.""" + +from dataclasses import dataclass, field +from typing import Any, Dict, Optional + + +@dataclass +class RequestEnvelope: + """Normalized view of an inbound request, independent of framework. + + Framework middlewares build this from their native request object. + :class:`~sap_cloud_sdk.core.runtime_context.ContextProvider` implementations + read from it — they never touch framework-specific types. + + Attributes: + headers: Case-insensitive HTTP headers (or equivalent for gRPC/etc.). + body: Raw request body bytes. ``None`` if not extracted. + metadata: Catch-all for framework extras (query params, gRPC metadata, + connection info, etc.). Reserved for future providers. + """ + + headers: Dict[str, str] = field(default_factory=dict) + body: Optional[bytes] = field(default=None) + metadata: Dict[str, Any] = field(default_factory=dict) diff --git a/src/sap_cloud_sdk/core/runtime_context/_protocol.py b/src/sap_cloud_sdk/core/runtime_context/_protocol.py index 59f3e1ab..37575fc3 100644 --- a/src/sap_cloud_sdk/core/runtime_context/_protocol.py +++ b/src/sap_cloud_sdk/core/runtime_context/_protocol.py @@ -1,23 +1,29 @@ """ContextProvider protocol — the pluggable extraction interface.""" -from typing import Any, Protocol, runtime_checkable +from typing import Protocol, runtime_checkable from sap_cloud_sdk.core.runtime_context._context import RequestContext +from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope @runtime_checkable class ContextProvider(Protocol): - """Extract a :class:`RequestContext` from a framework-specific request object. + """Extract a :class:`RequestContext` from a :class:`RequestEnvelope`. Implement this protocol to teach the SDK how to read caller-identity - information from a new framework (Flask, gRPC, raw WSGI, etc.). + information from a specific auth provider (IAS, XSUAA, etc.). + + The envelope is framework-agnostic — providers never touch Starlette, + Flask, or gRPC types directly. The framework middleware is responsible + for building the envelope. Example:: class MyProvider(ContextProvider): - def extract(self, request: MyRequest) -> RequestContext: - return RequestContext(tenant_id=request.tenant, user_id=request.user) + def extract(self, envelope: RequestEnvelope) -> RequestContext: + token = envelope.headers.get("x-my-token", "") + return RequestContext(tenant_id=decode(token).tenant) """ - def extract(self, request: Any) -> RequestContext: # pragma: no cover + def extract(self, envelope: RequestEnvelope) -> RequestContext: # pragma: no cover ... diff --git a/src/sap_cloud_sdk/core/runtime_context/_providers.py b/src/sap_cloud_sdk/core/runtime_context/_providers.py index e0a32c62..1f75d5aa 100644 --- a/src/sap_cloud_sdk/core/runtime_context/_providers.py +++ b/src/sap_cloud_sdk/core/runtime_context/_providers.py @@ -1,8 +1,7 @@ """Built-in ContextProvider implementations.""" -from typing import Any - from sap_cloud_sdk.core.runtime_context._context import RequestContext +from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider from sap_cloud_sdk.ias import parse_token @@ -10,22 +9,21 @@ class IASContextProvider(ContextProvider): """Extracts tenant/user context from an IAS JWT ``Authorization`` header. - Works with any request object that exposes headers via ``request.headers`` - (Starlette, FastAPI, etc.). + Reads from a :class:`RequestEnvelope` — works with any framework. The following claims are mapped: - - ``app_tid`` → :attr:`RequestContext.tenant_id` - - ``user_uuid`` → :attr:`RequestContext.user_id` - - ``x-sap-origin`` header → :attr:`RequestContext.trigger_type` + + - ``app_tid`` → :attr:`~.RequestContext.tenant_id` + - ``user_uuid`` → :attr:`~.RequestContext.user_id` + - ``x-sap-origin`` → :attr:`~.RequestContext.trigger_type` Full :class:`~sap_cloud_sdk.ias.IASClaims` are stored under ``RequestContext.extras["ias.claims"]`` for modules that need them. """ - def extract(self, request: Any) -> RequestContext: - headers = getattr(request, "headers", {}) - auth = headers.get("authorization", "") or headers.get("Authorization", "") - origin = headers.get("x-sap-origin") or headers.get("X-Sap-Origin") + def extract(self, envelope: RequestEnvelope) -> RequestContext: + auth = envelope.headers.get("authorization", "") + origin = envelope.headers.get("x-sap-origin") claims = None if auth: diff --git a/src/sap_cloud_sdk/core/runtime_context/starlette.py b/src/sap_cloud_sdk/core/runtime_context/starlette.py index e03694c6..bec2861c 100644 --- a/src/sap_cloud_sdk/core/runtime_context/starlette.py +++ b/src/sap_cloud_sdk/core/runtime_context/starlette.py @@ -1,8 +1,9 @@ """Starlette/FastAPI context middleware.""" -from typing import Any +from typing import Any, List, Optional -from sap_cloud_sdk.core.runtime_context._context import async_sdk_context +from sap_cloud_sdk.core.runtime_context._context import RequestContext, async_sdk_context +from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider try: @@ -16,34 +17,48 @@ ) from exc +def _merge(contexts: List[RequestContext]) -> RequestContext: + """Merge multiple RequestContexts — first non-None value wins per field.""" + merged = RequestContext() + for ctx in contexts: + if merged.tenant_id is None: + merged.tenant_id = ctx.tenant_id + if merged.user_id is None: + merged.user_id = ctx.user_id + if merged.trigger_type is None: + merged.trigger_type = ctx.trigger_type + merged.extras.update(ctx.extras) + return merged + + class StarletteContextMiddleware(BaseHTTPMiddleware): """Starlette/FastAPI middleware that populates the SDK runtime context. - Runs *provider*.extract() on every inbound request and makes the result - available via :func:`~sap_cloud_sdk.core.runtime_context.get_context` - for the duration of that request. + Builds a :class:`~sap_cloud_sdk.core.runtime_context.RequestEnvelope` from + each inbound request, runs all *providers* against it, and merges the results + into a single :class:`~sap_cloud_sdk.core.runtime_context.RequestContext` + available via :func:`~sap_cloud_sdk.core.runtime_context.get_context` for + the duration of that request. + + First non-None value wins per field when merging. Extras are union-merged + (later providers can add keys, but not overwrite earlier ones). Usage:: - from starlette.applications import Starlette from sap_cloud_sdk import bootstrap - app = Starlette(...) - bootstrap(app) - - Or manually:: - - from sap_cloud_sdk.core.runtime_context.starlette import StarletteContextMiddleware - from sap_cloud_sdk.core.runtime_context import IASContextProvider + bootstrap(app) # IASContextProvider by default - app.add_middleware(StarletteContextMiddleware, provider=IASContextProvider()) + # or with multiple providers: + bootstrap(app, providers=[IASContextProvider(), MyCustomProvider()]) """ - def __init__(self, app: Any, provider: ContextProvider) -> None: + def __init__(self, app: Any, providers: List[ContextProvider]) -> None: super().__init__(app) - self._provider = provider + self._providers = providers async def dispatch(self, request: Request, call_next: Any) -> Response: - ctx = self._provider.extract(request) + envelope = RequestEnvelope(headers=dict(request.headers)) + ctx = _merge([p.extract(envelope) for p in self._providers]) async with async_sdk_context(ctx): return await call_next(request) diff --git a/tests/core/unit/runtime_context/test_runtime_context.py b/tests/core/unit/runtime_context/test_runtime_context.py index f0ef1873..d7315a94 100644 --- a/tests/core/unit/runtime_context/test_runtime_context.py +++ b/tests/core/unit/runtime_context/test_runtime_context.py @@ -7,11 +7,13 @@ ContextProvider, IASContextProvider, RequestContext, + RequestEnvelope, async_sdk_context, get_context, sdk_context, set_context, ) +from sap_cloud_sdk.core.runtime_context.starlette import _merge _PATCH_PARSE = "sap_cloud_sdk.core.runtime_context._providers.parse_token" @@ -41,6 +43,24 @@ def test_extras_are_independent_per_instance(self): assert "x" not in b.extras +# --------------------------------------------------------------------------- +# RequestEnvelope +# --------------------------------------------------------------------------- + +class TestRequestEnvelope: + def test_defaults(self): + env = RequestEnvelope() + assert env.headers == {} + assert env.body is None + assert env.metadata == {} + + def test_headers_independent_per_instance(self): + a = RequestEnvelope() + b = RequestEnvelope() + a.headers["x"] = "1" + assert "x" not in b.headers + + # --------------------------------------------------------------------------- # get_context / set_context # --------------------------------------------------------------------------- @@ -133,7 +153,7 @@ def teardown_method(self): class TestContextProviderProtocol: def test_custom_class_satisfies_protocol(self): class MyProvider: - def extract(self, request) -> RequestContext: + def extract(self, envelope: RequestEnvelope) -> RequestContext: return RequestContext(tenant_id="custom") assert isinstance(MyProvider(), ContextProvider) @@ -156,63 +176,106 @@ def _make_claims(app_tid=None, user_uuid=None): return claims -def _make_request(headers: dict): - req = MagicMock() - req.headers = headers - return req +def _make_envelope(headers: dict) -> RequestEnvelope: + return RequestEnvelope(headers=headers) class TestIASContextProvider: def test_extracts_tenant_and_user(self): claims = _make_claims(app_tid="t-1", user_uuid="u-1") - req = _make_request({"authorization": "Bearer tok"}) + envelope = _make_envelope({"authorization": "Bearer tok"}) with patch(_PATCH_PARSE, return_value=claims): - ctx = IASContextProvider().extract(req) + ctx = IASContextProvider().extract(envelope) assert ctx.tenant_id == "t-1" assert ctx.user_id == "u-1" def test_extracts_trigger_type_from_origin_header(self): claims = _make_claims(app_tid="t-1", user_uuid="u-1") - req = _make_request({"authorization": "Bearer tok", "x-sap-origin": "ui5"}) + envelope = _make_envelope({"authorization": "Bearer tok", "x-sap-origin": "ui5"}) with patch(_PATCH_PARSE, return_value=claims): - ctx = IASContextProvider().extract(req) + ctx = IASContextProvider().extract(envelope) assert ctx.trigger_type == "ui5" def test_trigger_type_none_when_header_absent(self): claims = _make_claims(app_tid="t-1", user_uuid="u-1") - req = _make_request({"authorization": "Bearer tok"}) + envelope = _make_envelope({"authorization": "Bearer tok"}) with patch(_PATCH_PARSE, return_value=claims): - ctx = IASContextProvider().extract(req) + ctx = IASContextProvider().extract(envelope) assert ctx.trigger_type is None def test_stores_full_claims_in_extras(self): claims = _make_claims(app_tid="t-1", user_uuid="u-1") - req = _make_request({"authorization": "Bearer tok"}) + envelope = _make_envelope({"authorization": "Bearer tok"}) with patch(_PATCH_PARSE, return_value=claims): - ctx = IASContextProvider().extract(req) + ctx = IASContextProvider().extract(envelope) assert ctx.extras.get("ias.claims") is claims def test_returns_empty_context_when_no_auth_header(self): - req = _make_request({}) - ctx = IASContextProvider().extract(req) + envelope = _make_envelope({}) + ctx = IASContextProvider().extract(envelope) assert ctx.tenant_id is None assert ctx.user_id is None assert ctx.extras == {} def test_returns_empty_context_on_parse_error(self): - req = _make_request({"authorization": "Bearer bad"}) + envelope = _make_envelope({"authorization": "Bearer bad"}) with patch(_PATCH_PARSE, side_effect=ValueError("bad")): - ctx = IASContextProvider().extract(req) + ctx = IASContextProvider().extract(envelope) assert ctx.tenant_id is None assert ctx.user_id is None def test_tenant_id_none_when_claim_absent(self): claims = _make_claims(app_tid=None, user_uuid="u-1") - req = _make_request({"authorization": "Bearer tok"}) + envelope = _make_envelope({"authorization": "Bearer tok"}) with patch(_PATCH_PARSE, return_value=claims): - ctx = IASContextProvider().extract(req) + ctx = IASContextProvider().extract(envelope) assert ctx.tenant_id is None assert ctx.user_id == "u-1" def test_satisfies_context_provider_protocol(self): assert isinstance(IASContextProvider(), ContextProvider) + + +# --------------------------------------------------------------------------- +# _merge +# --------------------------------------------------------------------------- + +class TestMerge: + def test_first_non_none_wins_per_field(self): + a = RequestContext(tenant_id="t-a", user_id=None) + b = RequestContext(tenant_id="t-b", user_id="u-b") + merged = _merge([a, b]) + assert merged.tenant_id == "t-a" + assert merged.user_id == "u-b" + + def test_second_fills_missing_from_first(self): + a = RequestContext(tenant_id=None, user_id=None) + b = RequestContext(tenant_id="t-b", user_id="u-b") + merged = _merge([a, b]) + assert merged.tenant_id == "t-b" + assert merged.user_id == "u-b" + + def test_extras_are_union_merged(self): + a = RequestContext(extras={"key-a": 1}) + b = RequestContext(extras={"key-b": 2}) + merged = _merge([a, b]) + assert merged.extras == {"key-a": 1, "key-b": 2} + + def test_extras_first_provider_wins_on_conflict(self): + a = RequestContext(extras={"x": "from-a"}) + b = RequestContext(extras={"x": "from-b"}) + merged = _merge([a, b]) + assert merged.extras["x"] == "from-b" # dict.update — last writer wins + + def test_empty_list_returns_empty_context(self): + merged = _merge([]) + assert merged.tenant_id is None + assert merged.user_id is None + assert merged.extras == {} + + def test_single_provider_passthrough(self): + ctx = RequestContext(tenant_id="t-1", user_id="u-1", trigger_type="ui5") + merged = _merge([ctx]) + assert merged.tenant_id == "t-1" + assert merged.user_id == "u-1" + assert merged.trigger_type == "ui5" From 2d3fe10d008421bb3456cb46210440ce34228ede Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Wed, 22 Jul 2026 09:23:19 +0200 Subject: [PATCH 08/17] Runtime context --- src/sap_cloud_sdk/core/bootstrap.py | 37 ++++++----- .../core/runtime_context/__init__.py | 5 +- .../core/runtime_context/_registry.py | 62 +++++++++++++++++++ .../core/runtime_context/starlette.py | 36 +++++++---- .../runtime_context/test_runtime_context.py | 12 +++- 5 files changed, 122 insertions(+), 30 deletions(-) create mode 100644 src/sap_cloud_sdk/core/runtime_context/_registry.py diff --git a/src/sap_cloud_sdk/core/bootstrap.py b/src/sap_cloud_sdk/core/bootstrap.py index 0bbd7009..8c436a12 100644 --- a/src/sap_cloud_sdk/core/bootstrap.py +++ b/src/sap_cloud_sdk/core/bootstrap.py @@ -17,8 +17,10 @@ def bootstrap(app: Any, providers: Optional[List[ContextProvider]] = None) -> No :func:`~sap_cloud_sdk.core.runtime_context.get_context` to read tenant/user information without knowing about headers or auth providers. - Supported frameworks (detected automatically from *app* type): - - Starlette / FastAPI + The framework is detected automatically from the *app* type. Supported + frameworks are determined by registered + :class:`~sap_cloud_sdk.core.runtime_context.FrameworkAdapter` instances — + new frameworks are added by registering an adapter, not by editing this file. Args: app: The application instance to attach the middleware to. @@ -26,7 +28,7 @@ def bootstrap(app: Any, providers: Optional[List[ContextProvider]] = None) -> No instances. Defaults to ``[IASContextProvider()]``. Raises: - TypeError: If *app* is not a recognised framework application type. + TypeError: If no registered adapter recognises *app*. Example:: @@ -39,24 +41,29 @@ def bootstrap(app: Any, providers: Optional[List[ContextProvider]] = None) -> No """ if not providers: from sap_cloud_sdk.core.runtime_context import IASContextProvider + providers = [IASContextProvider()] - _attach(app, providers) + # Ensure all built-in adapters are registered before iterating. + _load_builtin_adapters() -def _attach(app: Any, providers: List[ContextProvider]) -> None: - """Detect framework and register the appropriate context middleware.""" - try: - from starlette.applications import Starlette - from sap_cloud_sdk.core.runtime_context.starlette import StarletteContextMiddleware + from sap_cloud_sdk.core.runtime_context._registry import get_registry - if isinstance(app, Starlette): - app.add_middleware(StarletteContextMiddleware, providers=providers) + for adapter in get_registry(): + if adapter.matches(app): + adapter.attach(app, providers) return - except ImportError: - pass raise TypeError( f"bootstrap() does not recognise app type {type(app)!r}. " - "Supported frameworks: Starlette/FastAPI. " - "For other frameworks, register the middleware manually." + "Supported frameworks are determined by registered FrameworkAdapters. " + "For other frameworks, register a FrameworkAdapter or attach the middleware manually." ) + + +def _load_builtin_adapters() -> None: + """Import built-in framework adapter modules to trigger their register() calls.""" + try: + import sap_cloud_sdk.core.runtime_context.starlette # noqa: F401 + except ImportError: + pass diff --git a/src/sap_cloud_sdk/core/runtime_context/__init__.py b/src/sap_cloud_sdk/core/runtime_context/__init__.py index 69cf44d5..c198df27 100644 --- a/src/sap_cloud_sdk/core/runtime_context/__init__.py +++ b/src/sap_cloud_sdk/core/runtime_context/__init__.py @@ -9,7 +9,7 @@ from sap_cloud_sdk import bootstrap from sap_cloud_sdk.core.runtime_context import IASContextProvider - bootstrap(app, provider=IASContextProvider()) + bootstrap(app, providers=[IASContextProvider()]) Then read anywhere in the SDK or application code:: @@ -37,14 +37,17 @@ from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider from sap_cloud_sdk.core.runtime_context._providers import IASContextProvider +from sap_cloud_sdk.core.runtime_context._registry import FrameworkAdapter, register __all__ = [ "ContextProvider", + "FrameworkAdapter", "IASContextProvider", "RequestContext", "RequestEnvelope", "async_sdk_context", "get_context", + "register", "sdk_context", "set_context", ] diff --git a/src/sap_cloud_sdk/core/runtime_context/_registry.py b/src/sap_cloud_sdk/core/runtime_context/_registry.py new file mode 100644 index 00000000..384f8617 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/_registry.py @@ -0,0 +1,62 @@ +"""Framework adapter base class and registry for bootstrap().""" + +import importlib.util +import logging +from abc import ABC, abstractmethod +from typing import List + +from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider + +logger = logging.getLogger(__name__) + +_registry: List["FrameworkAdapter"] = [] + + +def register(adapter: "FrameworkAdapter") -> None: + """Register a framework adapter with the bootstrap registry.""" + _registry.append(adapter) + + +def get_registry() -> List["FrameworkAdapter"]: + return list(_registry) + + +class FrameworkAdapter(ABC): + """Base class for framework-specific context middleware adapters. + + Subclasses know how to detect a framework's app object and attach the + appropriate context middleware to it. Register at module level so that + :func:`~sap_cloud_sdk.core.bootstrap.bootstrap` can discover them without + importing framework-specific code directly. + + Example:: + + class FlaskContextAdapter(FrameworkAdapter): + framework_name = "flask" + + def matches(self, app) -> bool: + from flask import Flask + return isinstance(app, Flask) + + def attach(self, app, providers) -> None: + app.before_request(lambda: ...) + + register(FlaskContextAdapter()) + """ + + #: Import name of the framework (e.g. "starlette", "flask"). + framework_name: str + + def matches(self, app) -> bool: + if not self._is_framework_installed(): + return False + return self._matches(app) + + @abstractmethod + def _matches(self, app) -> bool: ... + + @abstractmethod + def attach(self, app, providers: List[ContextProvider]) -> None: ... + + def _is_framework_installed(self) -> bool: + return importlib.util.find_spec(self.framework_name) is not None diff --git a/src/sap_cloud_sdk/core/runtime_context/starlette.py b/src/sap_cloud_sdk/core/runtime_context/starlette.py index bec2861c..9ea3db3d 100644 --- a/src/sap_cloud_sdk/core/runtime_context/starlette.py +++ b/src/sap_cloud_sdk/core/runtime_context/starlette.py @@ -1,10 +1,14 @@ -"""Starlette/FastAPI context middleware.""" +"""Starlette/FastAPI context middleware and framework adapter.""" -from typing import Any, List, Optional +from typing import Any, List -from sap_cloud_sdk.core.runtime_context._context import RequestContext, async_sdk_context +from sap_cloud_sdk.core.runtime_context._context import ( + RequestContext, + async_sdk_context, +) from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider +from sap_cloud_sdk.core.runtime_context._registry import FrameworkAdapter, register try: from starlette.middleware.base import BaseHTTPMiddleware @@ -41,16 +45,7 @@ class StarletteContextMiddleware(BaseHTTPMiddleware): the duration of that request. First non-None value wins per field when merging. Extras are union-merged - (later providers can add keys, but not overwrite earlier ones). - - Usage:: - - from sap_cloud_sdk import bootstrap - - bootstrap(app) # IASContextProvider by default - - # or with multiple providers: - bootstrap(app, providers=[IASContextProvider(), MyCustomProvider()]) + across all providers. """ def __init__(self, app: Any, providers: List[ContextProvider]) -> None: @@ -62,3 +57,18 @@ async def dispatch(self, request: Request, call_next: Any) -> Response: ctx = _merge([p.extract(envelope) for p in self._providers]) async with async_sdk_context(ctx): return await call_next(request) + + +class _StarletteContextAdapter(FrameworkAdapter): + framework_name = "starlette" + + def _matches(self, app) -> bool: + from starlette.applications import Starlette + + return isinstance(app, Starlette) + + def attach(self, app, providers: List[ContextProvider]) -> None: + app.add_middleware(StarletteContextMiddleware, providers=providers) + + +register(_StarletteContextAdapter()) diff --git a/tests/core/unit/runtime_context/test_runtime_context.py b/tests/core/unit/runtime_context/test_runtime_context.py index d7315a94..b0aa1414 100644 --- a/tests/core/unit/runtime_context/test_runtime_context.py +++ b/tests/core/unit/runtime_context/test_runtime_context.py @@ -22,6 +22,7 @@ # RequestContext # --------------------------------------------------------------------------- + class TestRequestContext: def test_defaults_are_none(self): ctx = RequestContext() @@ -47,6 +48,7 @@ def test_extras_are_independent_per_instance(self): # RequestEnvelope # --------------------------------------------------------------------------- + class TestRequestEnvelope: def test_defaults(self): env = RequestEnvelope() @@ -65,6 +67,7 @@ def test_headers_independent_per_instance(self): # get_context / set_context # --------------------------------------------------------------------------- + class TestGetSetContext: def test_get_returns_empty_by_default(self): ctx = get_context() @@ -84,6 +87,7 @@ def teardown_method(self): # sdk_context (sync) # --------------------------------------------------------------------------- + class TestSdkContext: def test_sets_context_inside_block(self): ctx = RequestContext(tenant_id="inside") @@ -118,6 +122,7 @@ def teardown_method(self): # async_sdk_context # --------------------------------------------------------------------------- + class TestAsyncSdkContext: @pytest.mark.anyio async def test_sets_context_inside_async_block(self): @@ -150,6 +155,7 @@ def teardown_method(self): # ContextProvider protocol # --------------------------------------------------------------------------- + class TestContextProviderProtocol: def test_custom_class_satisfies_protocol(self): class MyProvider: @@ -169,6 +175,7 @@ class NotAProvider: # IASContextProvider # --------------------------------------------------------------------------- + def _make_claims(app_tid=None, user_uuid=None): claims = MagicMock() claims.app_tid = app_tid @@ -191,7 +198,9 @@ def test_extracts_tenant_and_user(self): def test_extracts_trigger_type_from_origin_header(self): claims = _make_claims(app_tid="t-1", user_uuid="u-1") - envelope = _make_envelope({"authorization": "Bearer tok", "x-sap-origin": "ui5"}) + envelope = _make_envelope( + {"authorization": "Bearer tok", "x-sap-origin": "ui5"} + ) with patch(_PATCH_PARSE, return_value=claims): ctx = IASContextProvider().extract(envelope) assert ctx.trigger_type == "ui5" @@ -240,6 +249,7 @@ def test_satisfies_context_provider_protocol(self): # _merge # --------------------------------------------------------------------------- + class TestMerge: def test_first_non_none_wins_per_field(self): a = RequestContext(tenant_id="t-a", user_id=None) From ffb90fda81619391de3a795555241df55ac4655f Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Wed, 22 Jul 2026 10:25:03 +0200 Subject: [PATCH 09/17] Cleanups --- src/sap_cloud_sdk/core/bootstrap.py | 17 ++--------------- .../core/runtime_context/__init__.py | 6 ++++++ .../core/runtime_context/_registry.py | 17 +++++------------ .../core/runtime_context/starlette.py | 2 -- 4 files changed, 13 insertions(+), 29 deletions(-) diff --git a/src/sap_cloud_sdk/core/bootstrap.py b/src/sap_cloud_sdk/core/bootstrap.py index 8c436a12..8657754d 100644 --- a/src/sap_cloud_sdk/core/bootstrap.py +++ b/src/sap_cloud_sdk/core/bootstrap.py @@ -3,6 +3,8 @@ from typing import Any, List, Optional from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider +from sap_cloud_sdk.core.runtime_context._registry import get_registry +from sap_cloud_sdk.core.runtime_context import IASContextProvider def bootstrap(app: Any, providers: Optional[List[ContextProvider]] = None) -> None: @@ -40,15 +42,8 @@ def bootstrap(app: Any, providers: Optional[List[ContextProvider]] = None) -> No bootstrap(app, providers=[IASContextProvider(), MyCustomProvider()]) """ if not providers: - from sap_cloud_sdk.core.runtime_context import IASContextProvider - providers = [IASContextProvider()] - # Ensure all built-in adapters are registered before iterating. - _load_builtin_adapters() - - from sap_cloud_sdk.core.runtime_context._registry import get_registry - for adapter in get_registry(): if adapter.matches(app): adapter.attach(app, providers) @@ -59,11 +54,3 @@ def bootstrap(app: Any, providers: Optional[List[ContextProvider]] = None) -> No "Supported frameworks are determined by registered FrameworkAdapters. " "For other frameworks, register a FrameworkAdapter or attach the middleware manually." ) - - -def _load_builtin_adapters() -> None: - """Import built-in framework adapter modules to trigger their register() calls.""" - try: - import sap_cloud_sdk.core.runtime_context.starlette # noqa: F401 - except ImportError: - pass diff --git a/src/sap_cloud_sdk/core/runtime_context/__init__.py b/src/sap_cloud_sdk/core/runtime_context/__init__.py index c198df27..af0baf76 100644 --- a/src/sap_cloud_sdk/core/runtime_context/__init__.py +++ b/src/sap_cloud_sdk/core/runtime_context/__init__.py @@ -39,6 +39,12 @@ from sap_cloud_sdk.core.runtime_context._providers import IASContextProvider from sap_cloud_sdk.core.runtime_context._registry import FrameworkAdapter, register +# Register built-in framework adapters (guarded so missing extras don't break the import). +try: + import sap_cloud_sdk.core.runtime_context.starlette # noqa: F401 +except ImportError: + pass + __all__ = [ "ContextProvider", "FrameworkAdapter", diff --git a/src/sap_cloud_sdk/core/runtime_context/_registry.py b/src/sap_cloud_sdk/core/runtime_context/_registry.py index 384f8617..0f4da087 100644 --- a/src/sap_cloud_sdk/core/runtime_context/_registry.py +++ b/src/sap_cloud_sdk/core/runtime_context/_registry.py @@ -1,6 +1,5 @@ """Framework adapter base class and registry for bootstrap().""" -import importlib.util import logging from abc import ABC, abstractmethod from typing import List @@ -32,9 +31,7 @@ class FrameworkAdapter(ABC): Example:: class FlaskContextAdapter(FrameworkAdapter): - framework_name = "flask" - - def matches(self, app) -> bool: + def _matches(self, app) -> bool: from flask import Flask return isinstance(app, Flask) @@ -44,19 +41,15 @@ def attach(self, app, providers) -> None: register(FlaskContextAdapter()) """ - #: Import name of the framework (e.g. "starlette", "flask"). - framework_name: str - def matches(self, app) -> bool: - if not self._is_framework_installed(): + """Return True if this adapter handles *app*'s framework type.""" + try: + return self._matches(app) + except ImportError: return False - return self._matches(app) @abstractmethod def _matches(self, app) -> bool: ... @abstractmethod def attach(self, app, providers: List[ContextProvider]) -> None: ... - - def _is_framework_installed(self) -> bool: - return importlib.util.find_spec(self.framework_name) is not None diff --git a/src/sap_cloud_sdk/core/runtime_context/starlette.py b/src/sap_cloud_sdk/core/runtime_context/starlette.py index 9ea3db3d..8e97e56b 100644 --- a/src/sap_cloud_sdk/core/runtime_context/starlette.py +++ b/src/sap_cloud_sdk/core/runtime_context/starlette.py @@ -60,8 +60,6 @@ async def dispatch(self, request: Request, call_next: Any) -> Response: class _StarletteContextAdapter(FrameworkAdapter): - framework_name = "starlette" - def _matches(self, app) -> bool: from starlette.applications import Starlette From 91112a59de519af85030a7f8dbc6f81a9813c395 Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Wed, 22 Jul 2026 11:15:11 +0200 Subject: [PATCH 10/17] Cleanups --- src/sap_cloud_sdk/core/auditlog_ng/client.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/sap_cloud_sdk/core/auditlog_ng/client.py b/src/sap_cloud_sdk/core/auditlog_ng/client.py index 4a113b70..258e7960 100644 --- a/src/sap_cloud_sdk/core/auditlog_ng/client.py +++ b/src/sap_cloud_sdk/core/auditlog_ng/client.py @@ -37,17 +37,6 @@ from sap_cloud_sdk.core.auditlog_ng.exceptions import ValidationError from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics from sap_cloud_sdk.core.telemetry.config import ENV_OTLP_PROTOCOL -from sap_cloud_sdk.core.runtime_context import get_context - - -def _fill_common_from_context(event: Message) -> None: - """Fill empty common.tenant_id and common.user_initiator_id from SDK runtime context.""" - ctx = get_context() - common = getattr(event, "common", None) - if ctx.tenant_id and not common.tenant_id: - common.tenant_id = ctx.tenant_id - if ctx.user_id and not common.user_initiator_id: - common.user_initiator_id = ctx.user_id def _create_log_exporter( @@ -189,8 +178,6 @@ def send( if format not in {"protobuf-binary", "json"}: raise ValueError("format must be 'protobuf-binary' or 'json'") - _fill_common_from_context(event) - try: protovalidate.validate(event) except ProtoValidationError as e: From 4b0b584c5f61a9aff947885f5c989bfeaaaff249 Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Wed, 22 Jul 2026 11:40:29 +0200 Subject: [PATCH 11/17] Typed dict for extensibility --- .../core/runtime_context/__init__.py | 2 + .../core/runtime_context/_context.py | 50 +++-- .../core/runtime_context/_keys.py | 26 +++ .../core/runtime_context/_providers.py | 36 ++-- .../core/runtime_context/starlette.py | 15 +- .../runtime_context/test_runtime_context.py | 188 ++++++++++-------- 6 files changed, 200 insertions(+), 117 deletions(-) create mode 100644 src/sap_cloud_sdk/core/runtime_context/_keys.py diff --git a/src/sap_cloud_sdk/core/runtime_context/__init__.py b/src/sap_cloud_sdk/core/runtime_context/__init__.py index af0baf76..84971b13 100644 --- a/src/sap_cloud_sdk/core/runtime_context/__init__.py +++ b/src/sap_cloud_sdk/core/runtime_context/__init__.py @@ -35,6 +35,7 @@ set_context, ) from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope +from sap_cloud_sdk.core.runtime_context._keys import ContextKey from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider from sap_cloud_sdk.core.runtime_context._providers import IASContextProvider from sap_cloud_sdk.core.runtime_context._registry import FrameworkAdapter, register @@ -46,6 +47,7 @@ pass __all__ = [ + "ContextKey", "ContextProvider", "FrameworkAdapter", "IASContextProvider", diff --git a/src/sap_cloud_sdk/core/runtime_context/_context.py b/src/sap_cloud_sdk/core/runtime_context/_context.py index fd2326f0..d9fa3dbd 100644 --- a/src/sap_cloud_sdk/core/runtime_context/_context.py +++ b/src/sap_cloud_sdk/core/runtime_context/_context.py @@ -2,25 +2,46 @@ from contextlib import asynccontextmanager, contextmanager from contextvars import ContextVar -from dataclasses import dataclass, field -from typing import Any, AsyncGenerator, Dict, Generator, Optional +from typing import Any, AsyncGenerator, Dict, Generator, Optional, TypeVar + +from sap_cloud_sdk.core.runtime_context._keys import ContextKey + +T = TypeVar("T") -@dataclass class RequestContext: - """Caller-identity snapshot for the current request. + """Immutable typed bag of per-request values set by context providers. + + Values are keyed by :class:`ContextKey` instances, which carry the + expected type. Use :meth:`get` to read a value and :meth:`with_value` + to produce a new context with an additional entry. + + Example:: + + MY_KEY = ContextKey[str]("my_key") - Attributes: - tenant_id: Tenant identifier. - user_id: User identifier. - trigger_type: Origin of the request (e.g. from ``x-sap-origin``). - extras: Arbitrary additional data keyed by string. + ctx = RequestContext({MY_KEY: "hello"}) + ctx.get(MY_KEY) # -> "hello" """ - tenant_id: Optional[str] = field(default=None) - user_id: Optional[str] = field(default=None) - trigger_type: Optional[str] = field(default=None) - extras: Dict[str, Any] = field(default_factory=dict) + def __init__(self, values: Optional[Dict[ContextKey, Any]] = None) -> None: + self._values: Dict[ContextKey, Any] = dict(values) if values else {} + + def get(self, key: ContextKey[T]) -> Optional[T]: + """Return the value for *key*, or ``None`` if not set.""" + return self._values.get(key) + + def with_value(self, key: ContextKey[T], value: T) -> "RequestContext": + """Return a new RequestContext with *key* set to *value*.""" + return RequestContext({**self._values, key: value}) + + def _raw(self) -> Dict[ContextKey, Any]: + """Return a shallow copy of the internal values dict.""" + return dict(self._values) + + def __repr__(self) -> str: + pairs = ", ".join(f"{k.name}={v!r}" for k, v in self._values.items()) + return f"RequestContext({{{pairs}}})" _EMPTY = RequestContext() @@ -38,8 +59,7 @@ def set_context(ctx: RequestContext) -> None: def get_context() -> RequestContext: """Return the runtime context for the current async/thread scope. - Returns an empty :class:`RequestContext` (all fields ``None``) when no - context has been set. + Returns an empty :class:`RequestContext` when no context has been set. """ return _context_var.get() diff --git a/src/sap_cloud_sdk/core/runtime_context/_keys.py b/src/sap_cloud_sdk/core/runtime_context/_keys.py new file mode 100644 index 00000000..7d732f37 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/_keys.py @@ -0,0 +1,26 @@ +"""Typed context key for RequestContext.""" + +from typing import Generic, TypeVar + +T = TypeVar("T") + + +class ContextKey(Generic[T]): + """A typed key for reading and writing values in a :class:`RequestContext`. + + Each provider defines its own keys. The type parameter ensures consumers + get the right type back from :meth:`RequestContext.get`. + + Example:: + + MY_KEY = ContextKey[str]("my_key") + + ctx = RequestContext({MY_KEY: "value"}) + ctx.get(MY_KEY) # -> "value" + """ + + def __init__(self, name: str) -> None: + self.name = name + + def __repr__(self) -> str: + return f"ContextKey({self.name!r})" diff --git a/src/sap_cloud_sdk/core/runtime_context/_providers.py b/src/sap_cloud_sdk/core/runtime_context/_providers.py index 1f75d5aa..22131a30 100644 --- a/src/sap_cloud_sdk/core/runtime_context/_providers.py +++ b/src/sap_cloud_sdk/core/runtime_context/_providers.py @@ -2,23 +2,28 @@ from sap_cloud_sdk.core.runtime_context._context import RequestContext from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope +from sap_cloud_sdk.core.runtime_context._keys import ContextKey from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider from sap_cloud_sdk.ias import parse_token +# IAS-owned context keys +TENANT_ID = ContextKey[str]("tenant_id") +USER_ID = ContextKey[str]("user_id") +TRIGGER_TYPE = ContextKey[str]("trigger_type") +IAS_CLAIMS = ContextKey["IASClaims"]("ias.claims") # type: ignore[type-arg] + class IASContextProvider(ContextProvider): """Extracts tenant/user context from an IAS JWT ``Authorization`` header. Reads from a :class:`RequestEnvelope` — works with any framework. - The following claims are mapped: - - - ``app_tid`` → :attr:`~.RequestContext.tenant_id` - - ``user_uuid`` → :attr:`~.RequestContext.user_id` - - ``x-sap-origin`` → :attr:`~.RequestContext.trigger_type` + Defines and populates the following context keys: - Full :class:`~sap_cloud_sdk.ias.IASClaims` are stored under - ``RequestContext.extras["ias.claims"]`` for modules that need them. + - :data:`TENANT_ID` from ``app_tid`` claim + - :data:`USER_ID` from ``user_uuid`` claim + - :data:`TRIGGER_TYPE` from ``x-sap-origin`` header + - :data:`IAS_CLAIMS` full :class:`~sap_cloud_sdk.ias.IASClaims` object """ def extract(self, envelope: RequestEnvelope) -> RequestContext: @@ -32,9 +37,14 @@ def extract(self, envelope: RequestEnvelope) -> RequestContext: except Exception: pass - return RequestContext( - tenant_id=claims.app_tid if claims else None, - user_id=claims.user_uuid if claims else None, - trigger_type=origin or None, - extras={"ias.claims": claims} if claims else {}, - ) + values = {} + if claims: + if claims.app_tid: + values[TENANT_ID] = claims.app_tid + if claims.user_uuid: + values[USER_ID] = claims.user_uuid + values[IAS_CLAIMS] = claims + if origin: + values[TRIGGER_TYPE] = origin + + return RequestContext(values) diff --git a/src/sap_cloud_sdk/core/runtime_context/starlette.py b/src/sap_cloud_sdk/core/runtime_context/starlette.py index 8e97e56b..3b4fbeea 100644 --- a/src/sap_cloud_sdk/core/runtime_context/starlette.py +++ b/src/sap_cloud_sdk/core/runtime_context/starlette.py @@ -22,17 +22,12 @@ def _merge(contexts: List[RequestContext]) -> RequestContext: - """Merge multiple RequestContexts — first non-None value wins per field.""" - merged = RequestContext() + """Merge multiple RequestContexts — first writer wins per key.""" + merged: dict = {} for ctx in contexts: - if merged.tenant_id is None: - merged.tenant_id = ctx.tenant_id - if merged.user_id is None: - merged.user_id = ctx.user_id - if merged.trigger_type is None: - merged.trigger_type = ctx.trigger_type - merged.extras.update(ctx.extras) - return merged + for key, value in ctx._raw().items(): + merged.setdefault(key, value) + return RequestContext(merged) class StarletteContextMiddleware(BaseHTTPMiddleware): diff --git a/tests/core/unit/runtime_context/test_runtime_context.py b/tests/core/unit/runtime_context/test_runtime_context.py index b0aa1414..62a2f7f3 100644 --- a/tests/core/unit/runtime_context/test_runtime_context.py +++ b/tests/core/unit/runtime_context/test_runtime_context.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch from sap_cloud_sdk.core.runtime_context import ( + ContextKey, ContextProvider, IASContextProvider, RequestContext, @@ -13,35 +14,70 @@ sdk_context, set_context, ) +from sap_cloud_sdk.core.runtime_context._providers import ( + IAS_CLAIMS, + TENANT_ID, + TRIGGER_TYPE, + USER_ID, +) from sap_cloud_sdk.core.runtime_context.starlette import _merge _PATCH_PARSE = "sap_cloud_sdk.core.runtime_context._providers.parse_token" +# --------------------------------------------------------------------------- +# ContextKey +# --------------------------------------------------------------------------- + + +class TestContextKey: + def test_repr(self): + key = ContextKey[str]("my_key") + assert repr(key) == "ContextKey('my_key')" + + def test_different_instances_are_different_keys(self): + a = ContextKey[str]("x") + b = ContextKey[str]("x") + ctx = RequestContext({a: "from-a"}) + assert ctx.get(a) == "from-a" + assert ctx.get(b) is None + + # --------------------------------------------------------------------------- # RequestContext # --------------------------------------------------------------------------- class TestRequestContext: - def test_defaults_are_none(self): + def test_empty_by_default(self): ctx = RequestContext() - assert ctx.tenant_id is None - assert ctx.user_id is None - assert ctx.trigger_type is None - assert ctx.extras == {} + key = ContextKey[str]("k") + assert ctx.get(key) is None - def test_fields_are_settable(self): - ctx = RequestContext(tenant_id="t1", user_id="u1", trigger_type="ui5") - assert ctx.tenant_id == "t1" - assert ctx.user_id == "u1" - assert ctx.trigger_type == "ui5" + def test_get_returns_set_value(self): + key = ContextKey[str]("k") + ctx = RequestContext({key: "v"}) + assert ctx.get(key) == "v" - def test_extras_are_independent_per_instance(self): - a = RequestContext() - b = RequestContext() - a.extras["x"] = 1 - assert "x" not in b.extras + def test_with_value_returns_new_instance(self): + key = ContextKey[str]("k") + ctx = RequestContext() + ctx2 = ctx.with_value(key, "v") + assert ctx2 is not ctx + assert ctx.get(key) is None + assert ctx2.get(key) == "v" + + def test_immutable_original_unaffected_by_with_value(self): + key = ContextKey[str]("k") + ctx = RequestContext({key: "original"}) + ctx.with_value(key, "new") + assert ctx.get(key) == "original" + + def test_repr(self): + key = ContextKey[str]("tenant_id") + ctx = RequestContext({key: "t-1"}) + assert "tenant_id" in repr(ctx) + assert "t-1" in repr(ctx) # --------------------------------------------------------------------------- @@ -70,12 +106,12 @@ def test_headers_independent_per_instance(self): class TestGetSetContext: def test_get_returns_empty_by_default(self): - ctx = get_context() - assert ctx.tenant_id is None - assert ctx.user_id is None + key = ContextKey[str]("k") + assert get_context().get(key) is None def test_set_then_get_returns_same_object(self): - ctx = RequestContext(tenant_id="abc") + key = ContextKey[str]("k") + ctx = RequestContext({key: "v"}) set_context(ctx) assert get_context() is ctx @@ -90,27 +126,30 @@ def teardown_method(self): class TestSdkContext: def test_sets_context_inside_block(self): - ctx = RequestContext(tenant_id="inside") + key = ContextKey[str]("k") + ctx = RequestContext({key: "inside"}) with sdk_context(ctx): - assert get_context().tenant_id == "inside" + assert get_context().get(key) == "inside" def test_restores_previous_context_after_block(self): - outer = RequestContext(tenant_id="outer") + key = ContextKey[str]("k") + outer = RequestContext({key: "outer"}) set_context(outer) - with sdk_context(RequestContext(tenant_id="inner")): + with sdk_context(RequestContext({key: "inner"})): pass - assert get_context().tenant_id == "outer" + assert get_context().get(key) == "outer" def test_restores_on_exception(self): - outer = RequestContext(tenant_id="outer") + key = ContextKey[str]("k") + outer = RequestContext({key: "outer"}) set_context(outer) with pytest.raises(ValueError): - with sdk_context(RequestContext(tenant_id="inner")): + with sdk_context(RequestContext({key: "inner"})): raise ValueError("boom") - assert get_context().tenant_id == "outer" + assert get_context().get(key) == "outer" def test_yields_the_context(self): - ctx = RequestContext(tenant_id="t1") + ctx = RequestContext() with sdk_context(ctx) as yielded: assert yielded is ctx @@ -126,26 +165,29 @@ def teardown_method(self): class TestAsyncSdkContext: @pytest.mark.anyio async def test_sets_context_inside_async_block(self): - ctx = RequestContext(tenant_id="async-tenant") + key = ContextKey[str]("k") + ctx = RequestContext({key: "async-value"}) async with async_sdk_context(ctx): - assert get_context().tenant_id == "async-tenant" + assert get_context().get(key) == "async-value" @pytest.mark.anyio async def test_restores_after_async_block(self): - outer = RequestContext(tenant_id="outer") + key = ContextKey[str]("k") + outer = RequestContext({key: "outer"}) set_context(outer) - async with async_sdk_context(RequestContext(tenant_id="inner")): + async with async_sdk_context(RequestContext({key: "inner"})): pass - assert get_context().tenant_id == "outer" + assert get_context().get(key) == "outer" @pytest.mark.anyio async def test_restores_on_async_exception(self): - outer = RequestContext(tenant_id="outer") + key = ContextKey[str]("k") + outer = RequestContext({key: "outer"}) set_context(outer) with pytest.raises(RuntimeError): - async with async_sdk_context(RequestContext(tenant_id="inner")): + async with async_sdk_context(RequestContext({key: "inner"})): raise RuntimeError("boom") - assert get_context().tenant_id == "outer" + assert get_context().get(key) == "outer" def teardown_method(self): set_context(RequestContext()) @@ -160,7 +202,7 @@ class TestContextProviderProtocol: def test_custom_class_satisfies_protocol(self): class MyProvider: def extract(self, envelope: RequestEnvelope) -> RequestContext: - return RequestContext(tenant_id="custom") + return RequestContext() assert isinstance(MyProvider(), ContextProvider) @@ -193,8 +235,8 @@ def test_extracts_tenant_and_user(self): envelope = _make_envelope({"authorization": "Bearer tok"}) with patch(_PATCH_PARSE, return_value=claims): ctx = IASContextProvider().extract(envelope) - assert ctx.tenant_id == "t-1" - assert ctx.user_id == "u-1" + assert ctx.get(TENANT_ID) == "t-1" + assert ctx.get(USER_ID) == "u-1" def test_extracts_trigger_type_from_origin_header(self): claims = _make_claims(app_tid="t-1", user_uuid="u-1") @@ -203,43 +245,43 @@ def test_extracts_trigger_type_from_origin_header(self): ) with patch(_PATCH_PARSE, return_value=claims): ctx = IASContextProvider().extract(envelope) - assert ctx.trigger_type == "ui5" + assert ctx.get(TRIGGER_TYPE) == "ui5" def test_trigger_type_none_when_header_absent(self): claims = _make_claims(app_tid="t-1", user_uuid="u-1") envelope = _make_envelope({"authorization": "Bearer tok"}) with patch(_PATCH_PARSE, return_value=claims): ctx = IASContextProvider().extract(envelope) - assert ctx.trigger_type is None + assert ctx.get(TRIGGER_TYPE) is None - def test_stores_full_claims_in_extras(self): + def test_stores_full_claims(self): claims = _make_claims(app_tid="t-1", user_uuid="u-1") envelope = _make_envelope({"authorization": "Bearer tok"}) with patch(_PATCH_PARSE, return_value=claims): ctx = IASContextProvider().extract(envelope) - assert ctx.extras.get("ias.claims") is claims + assert ctx.get(IAS_CLAIMS) is claims def test_returns_empty_context_when_no_auth_header(self): envelope = _make_envelope({}) ctx = IASContextProvider().extract(envelope) - assert ctx.tenant_id is None - assert ctx.user_id is None - assert ctx.extras == {} + assert ctx.get(TENANT_ID) is None + assert ctx.get(USER_ID) is None + assert ctx.get(IAS_CLAIMS) is None def test_returns_empty_context_on_parse_error(self): envelope = _make_envelope({"authorization": "Bearer bad"}) with patch(_PATCH_PARSE, side_effect=ValueError("bad")): ctx = IASContextProvider().extract(envelope) - assert ctx.tenant_id is None - assert ctx.user_id is None + assert ctx.get(TENANT_ID) is None + assert ctx.get(USER_ID) is None def test_tenant_id_none_when_claim_absent(self): claims = _make_claims(app_tid=None, user_uuid="u-1") envelope = _make_envelope({"authorization": "Bearer tok"}) with patch(_PATCH_PARSE, return_value=claims): ctx = IASContextProvider().extract(envelope) - assert ctx.tenant_id is None - assert ctx.user_id == "u-1" + assert ctx.get(TENANT_ID) is None + assert ctx.get(USER_ID) == "u-1" def test_satisfies_context_provider_protocol(self): assert isinstance(IASContextProvider(), ContextProvider) @@ -251,41 +293,29 @@ def test_satisfies_context_provider_protocol(self): class TestMerge: - def test_first_non_none_wins_per_field(self): - a = RequestContext(tenant_id="t-a", user_id=None) - b = RequestContext(tenant_id="t-b", user_id="u-b") + def test_first_writer_wins_per_key(self): + key = ContextKey[str]("k") + a = RequestContext({key: "from-a"}) + b = RequestContext({key: "from-b"}) merged = _merge([a, b]) - assert merged.tenant_id == "t-a" - assert merged.user_id == "u-b" + assert merged.get(key) == "from-a" def test_second_fills_missing_from_first(self): - a = RequestContext(tenant_id=None, user_id=None) - b = RequestContext(tenant_id="t-b", user_id="u-b") - merged = _merge([a, b]) - assert merged.tenant_id == "t-b" - assert merged.user_id == "u-b" - - def test_extras_are_union_merged(self): - a = RequestContext(extras={"key-a": 1}) - b = RequestContext(extras={"key-b": 2}) - merged = _merge([a, b]) - assert merged.extras == {"key-a": 1, "key-b": 2} - - def test_extras_first_provider_wins_on_conflict(self): - a = RequestContext(extras={"x": "from-a"}) - b = RequestContext(extras={"x": "from-b"}) + k1 = ContextKey[str]("k1") + k2 = ContextKey[str]("k2") + a = RequestContext({k1: "v1"}) + b = RequestContext({k2: "v2"}) merged = _merge([a, b]) - assert merged.extras["x"] == "from-b" # dict.update — last writer wins + assert merged.get(k1) == "v1" + assert merged.get(k2) == "v2" def test_empty_list_returns_empty_context(self): + key = ContextKey[str]("k") merged = _merge([]) - assert merged.tenant_id is None - assert merged.user_id is None - assert merged.extras == {} + assert merged.get(key) is None - def test_single_provider_passthrough(self): - ctx = RequestContext(tenant_id="t-1", user_id="u-1", trigger_type="ui5") + def test_single_context_passthrough(self): + key = ContextKey[str]("k") + ctx = RequestContext({key: "v"}) merged = _merge([ctx]) - assert merged.tenant_id == "t-1" - assert merged.user_id == "u-1" - assert merged.trigger_type == "ui5" + assert merged.get(key) == "v" From 76453b476796355555d9711ecb42bb79b5a835fc Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Wed, 22 Jul 2026 13:40:27 +0200 Subject: [PATCH 12/17] Decoupling --- src/sap_cloud_sdk/core/bootstrap.py | 5 ++- .../core/runtime_context/__init__.py | 20 +++++++---- .../core/runtime_context/_keys.py | 4 +++ .../core/runtime_context/adapters/__init__.py | 6 ++++ .../runtime_context/adapters/_starlette.py | 23 ++++++++++++ .../runtime_context/providers/__init__.py | 17 +++++++++ .../runtime_context/providers/_headers.py | 23 ++++++++++++ .../{_providers.py => providers/_ias.py} | 17 ++++----- .../core/runtime_context/starlette.py | 19 ++-------- .../runtime_context/test_runtime_context.py | 36 +++++++++++++------ 10 files changed, 122 insertions(+), 48 deletions(-) create mode 100644 src/sap_cloud_sdk/core/runtime_context/adapters/__init__.py create mode 100644 src/sap_cloud_sdk/core/runtime_context/adapters/_starlette.py create mode 100644 src/sap_cloud_sdk/core/runtime_context/providers/__init__.py create mode 100644 src/sap_cloud_sdk/core/runtime_context/providers/_headers.py rename src/sap_cloud_sdk/core/runtime_context/{_providers.py => providers/_ias.py} (69%) diff --git a/src/sap_cloud_sdk/core/bootstrap.py b/src/sap_cloud_sdk/core/bootstrap.py index 8657754d..9bac6374 100644 --- a/src/sap_cloud_sdk/core/bootstrap.py +++ b/src/sap_cloud_sdk/core/bootstrap.py @@ -4,8 +4,7 @@ from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider from sap_cloud_sdk.core.runtime_context._registry import get_registry -from sap_cloud_sdk.core.runtime_context import IASContextProvider - +from sap_cloud_sdk.core.runtime_context import HeaderContextProvider, IASContextProvider def bootstrap(app: Any, providers: Optional[List[ContextProvider]] = None) -> None: """Wire the SDK runtime context into your application framework. @@ -42,7 +41,7 @@ def bootstrap(app: Any, providers: Optional[List[ContextProvider]] = None) -> No bootstrap(app, providers=[IASContextProvider(), MyCustomProvider()]) """ if not providers: - providers = [IASContextProvider()] + providers = [IASContextProvider(), HeaderContextProvider()] for adapter in get_registry(): if adapter.matches(app): diff --git a/src/sap_cloud_sdk/core/runtime_context/__init__.py b/src/sap_cloud_sdk/core/runtime_context/__init__.py index 84971b13..8c5453e2 100644 --- a/src/sap_cloud_sdk/core/runtime_context/__init__.py +++ b/src/sap_cloud_sdk/core/runtime_context/__init__.py @@ -35,24 +35,32 @@ set_context, ) from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope -from sap_cloud_sdk.core.runtime_context._keys import ContextKey +from sap_cloud_sdk.core.runtime_context._keys import ContextKey, TRIGGER_TYPE from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider -from sap_cloud_sdk.core.runtime_context._providers import IASContextProvider from sap_cloud_sdk.core.runtime_context._registry import FrameworkAdapter, register +from sap_cloud_sdk.core.runtime_context.providers import ( + HeaderContextProvider, + IASContextProvider, + IAS_CLAIMS, + TENANT_ID, + USER_ID, +) # Register built-in framework adapters (guarded so missing extras don't break the import). -try: - import sap_cloud_sdk.core.runtime_context.starlette # noqa: F401 -except ImportError: - pass +import sap_cloud_sdk.core.runtime_context.adapters # noqa: F401 __all__ = [ "ContextKey", "ContextProvider", "FrameworkAdapter", + "HeaderContextProvider", + "IAS_CLAIMS", "IASContextProvider", "RequestContext", "RequestEnvelope", + "TENANT_ID", + "TRIGGER_TYPE", + "USER_ID", "async_sdk_context", "get_context", "register", diff --git a/src/sap_cloud_sdk/core/runtime_context/_keys.py b/src/sap_cloud_sdk/core/runtime_context/_keys.py index 7d732f37..eb8f8dd3 100644 --- a/src/sap_cloud_sdk/core/runtime_context/_keys.py +++ b/src/sap_cloud_sdk/core/runtime_context/_keys.py @@ -24,3 +24,7 @@ def __init__(self, name: str) -> None: def __repr__(self) -> str: return f"ContextKey({self.name!r})" + + +# SDK-standard keys — not tied to any specific auth provider. +TRIGGER_TYPE = ContextKey[str]("trigger_type") diff --git a/src/sap_cloud_sdk/core/runtime_context/adapters/__init__.py b/src/sap_cloud_sdk/core/runtime_context/adapters/__init__.py new file mode 100644 index 00000000..5f1f63a2 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/adapters/__init__.py @@ -0,0 +1,6 @@ +"""Built-in framework adapters.""" + +try: + from sap_cloud_sdk.core.runtime_context.adapters import _starlette # noqa: F401 +except ImportError: + pass diff --git a/src/sap_cloud_sdk/core/runtime_context/adapters/_starlette.py b/src/sap_cloud_sdk/core/runtime_context/adapters/_starlette.py new file mode 100644 index 00000000..14df6f92 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/adapters/_starlette.py @@ -0,0 +1,23 @@ +"""Starlette/FastAPI framework adapter.""" + +from typing import List + +from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider +from sap_cloud_sdk.core.runtime_context._registry import FrameworkAdapter, register + + +class _StarletteContextAdapter(FrameworkAdapter): + def _matches(self, app) -> bool: + from starlette.applications import Starlette + + return isinstance(app, Starlette) + + def attach(self, app, providers: List[ContextProvider]) -> None: + from sap_cloud_sdk.core.runtime_context.starlette import ( + StarletteContextMiddleware, + ) + + app.add_middleware(StarletteContextMiddleware, providers=providers) + + +register(_StarletteContextAdapter()) diff --git a/src/sap_cloud_sdk/core/runtime_context/providers/__init__.py b/src/sap_cloud_sdk/core/runtime_context/providers/__init__.py new file mode 100644 index 00000000..61d44a65 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/providers/__init__.py @@ -0,0 +1,17 @@ +"""Built-in context providers.""" + +from sap_cloud_sdk.core.runtime_context.providers._headers import HeaderContextProvider +from sap_cloud_sdk.core.runtime_context.providers._ias import ( + IAS_CLAIMS, + IASContextProvider, + TENANT_ID, + USER_ID, +) + +__all__ = [ + "HeaderContextProvider", + "IAS_CLAIMS", + "IASContextProvider", + "TENANT_ID", + "USER_ID", +] diff --git a/src/sap_cloud_sdk/core/runtime_context/providers/_headers.py b/src/sap_cloud_sdk/core/runtime_context/providers/_headers.py new file mode 100644 index 00000000..6ec7c265 --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/providers/_headers.py @@ -0,0 +1,23 @@ +"""SAP standard headers context provider.""" + +from sap_cloud_sdk.core.runtime_context._context import RequestContext +from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope +from sap_cloud_sdk.core.runtime_context._keys import TRIGGER_TYPE +from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider + + +class HeaderContextProvider(ContextProvider): + """Extracts context from plain request headers. + + Defines and populates the following context keys: + + - :data:`~sap_cloud_sdk.core.runtime_context.TRIGGER_TYPE` + from ``x-sap-origin`` + """ + + def extract(self, envelope: RequestEnvelope) -> RequestContext: + values = {} + origin = envelope.headers.get("x-sap-origin") + if origin: + values[TRIGGER_TYPE] = origin + return RequestContext(values) diff --git a/src/sap_cloud_sdk/core/runtime_context/_providers.py b/src/sap_cloud_sdk/core/runtime_context/providers/_ias.py similarity index 69% rename from src/sap_cloud_sdk/core/runtime_context/_providers.py rename to src/sap_cloud_sdk/core/runtime_context/providers/_ias.py index 22131a30..84afde49 100644 --- a/src/sap_cloud_sdk/core/runtime_context/_providers.py +++ b/src/sap_cloud_sdk/core/runtime_context/providers/_ias.py @@ -1,4 +1,4 @@ -"""Built-in ContextProvider implementations.""" +"""IAS context provider and its typed keys.""" from sap_cloud_sdk.core.runtime_context._context import RequestContext from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope @@ -6,29 +6,26 @@ from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider from sap_cloud_sdk.ias import parse_token -# IAS-owned context keys TENANT_ID = ContextKey[str]("tenant_id") USER_ID = ContextKey[str]("user_id") -TRIGGER_TYPE = ContextKey[str]("trigger_type") IAS_CLAIMS = ContextKey["IASClaims"]("ias.claims") # type: ignore[type-arg] class IASContextProvider(ContextProvider): """Extracts tenant/user context from an IAS JWT ``Authorization`` header. - Reads from a :class:`RequestEnvelope` — works with any framework. + Reads from a :class:`~sap_cloud_sdk.core.runtime_context.RequestEnvelope` + — works with any framework. Defines and populates the following context keys: - - :data:`TENANT_ID` from ``app_tid`` claim - - :data:`USER_ID` from ``user_uuid`` claim - - :data:`TRIGGER_TYPE` from ``x-sap-origin`` header - - :data:`IAS_CLAIMS` full :class:`~sap_cloud_sdk.ias.IASClaims` object + - :data:`TENANT_ID` from ``app_tid`` claim + - :data:`USER_ID` from ``user_uuid`` claim + - :data:`IAS_CLAIMS` full :class:`~sap_cloud_sdk.ias.IASClaims` object """ def extract(self, envelope: RequestEnvelope) -> RequestContext: auth = envelope.headers.get("authorization", "") - origin = envelope.headers.get("x-sap-origin") claims = None if auth: @@ -44,7 +41,5 @@ def extract(self, envelope: RequestEnvelope) -> RequestContext: if claims.user_uuid: values[USER_ID] = claims.user_uuid values[IAS_CLAIMS] = claims - if origin: - values[TRIGGER_TYPE] = origin return RequestContext(values) diff --git a/src/sap_cloud_sdk/core/runtime_context/starlette.py b/src/sap_cloud_sdk/core/runtime_context/starlette.py index 3b4fbeea..240263fc 100644 --- a/src/sap_cloud_sdk/core/runtime_context/starlette.py +++ b/src/sap_cloud_sdk/core/runtime_context/starlette.py @@ -1,4 +1,4 @@ -"""Starlette/FastAPI context middleware and framework adapter.""" +"""Starlette/FastAPI context middleware.""" from typing import Any, List @@ -8,7 +8,6 @@ ) from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider -from sap_cloud_sdk.core.runtime_context._registry import FrameworkAdapter, register try: from starlette.middleware.base import BaseHTTPMiddleware @@ -39,8 +38,7 @@ class StarletteContextMiddleware(BaseHTTPMiddleware): available via :func:`~sap_cloud_sdk.core.runtime_context.get_context` for the duration of that request. - First non-None value wins per field when merging. Extras are union-merged - across all providers. + First writer wins per key when merging across providers. """ def __init__(self, app: Any, providers: List[ContextProvider]) -> None: @@ -52,16 +50,3 @@ async def dispatch(self, request: Request, call_next: Any) -> Response: ctx = _merge([p.extract(envelope) for p in self._providers]) async with async_sdk_context(ctx): return await call_next(request) - - -class _StarletteContextAdapter(FrameworkAdapter): - def _matches(self, app) -> bool: - from starlette.applications import Starlette - - return isinstance(app, Starlette) - - def attach(self, app, providers: List[ContextProvider]) -> None: - app.add_middleware(StarletteContextMiddleware, providers=providers) - - -register(_StarletteContextAdapter()) diff --git a/tests/core/unit/runtime_context/test_runtime_context.py b/tests/core/unit/runtime_context/test_runtime_context.py index 62a2f7f3..a350d381 100644 --- a/tests/core/unit/runtime_context/test_runtime_context.py +++ b/tests/core/unit/runtime_context/test_runtime_context.py @@ -6,23 +6,24 @@ from sap_cloud_sdk.core.runtime_context import ( ContextKey, ContextProvider, + HeaderContextProvider, IASContextProvider, RequestContext, RequestEnvelope, + TRIGGER_TYPE, async_sdk_context, get_context, sdk_context, set_context, ) -from sap_cloud_sdk.core.runtime_context._providers import ( +from sap_cloud_sdk.core.runtime_context.providers._ias import ( IAS_CLAIMS, TENANT_ID, - TRIGGER_TYPE, USER_ID, ) from sap_cloud_sdk.core.runtime_context.starlette import _merge -_PATCH_PARSE = "sap_cloud_sdk.core.runtime_context._providers.parse_token" +_PATCH_PARSE = "sap_cloud_sdk.core.runtime_context.providers._ias.parse_token" # --------------------------------------------------------------------------- @@ -238,18 +239,11 @@ def test_extracts_tenant_and_user(self): assert ctx.get(TENANT_ID) == "t-1" assert ctx.get(USER_ID) == "u-1" - def test_extracts_trigger_type_from_origin_header(self): + def test_does_not_set_trigger_type(self): claims = _make_claims(app_tid="t-1", user_uuid="u-1") envelope = _make_envelope( {"authorization": "Bearer tok", "x-sap-origin": "ui5"} ) - with patch(_PATCH_PARSE, return_value=claims): - ctx = IASContextProvider().extract(envelope) - assert ctx.get(TRIGGER_TYPE) == "ui5" - - def test_trigger_type_none_when_header_absent(self): - claims = _make_claims(app_tid="t-1", user_uuid="u-1") - envelope = _make_envelope({"authorization": "Bearer tok"}) with patch(_PATCH_PARSE, return_value=claims): ctx = IASContextProvider().extract(envelope) assert ctx.get(TRIGGER_TYPE) is None @@ -287,6 +281,26 @@ def test_satisfies_context_provider_protocol(self): assert isinstance(IASContextProvider(), ContextProvider) +# --------------------------------------------------------------------------- +# HeaderContextProvider +# --------------------------------------------------------------------------- + + +class TestHeaderContextProvider: + def test_extracts_trigger_type(self): + envelope = _make_envelope({"x-sap-origin": "ui5"}) + ctx = HeaderContextProvider().extract(envelope) + assert ctx.get(TRIGGER_TYPE) == "ui5" + + def test_trigger_type_none_when_header_absent(self): + envelope = _make_envelope({}) + ctx = HeaderContextProvider().extract(envelope) + assert ctx.get(TRIGGER_TYPE) is None + + def test_satisfies_context_provider_protocol(self): + assert isinstance(HeaderContextProvider(), ContextProvider) + + # --------------------------------------------------------------------------- # _merge # --------------------------------------------------------------------------- From 61793345dcf5c2b9e4334efb178cd4eec14cf34d Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Wed, 22 Jul 2026 15:33:16 +0200 Subject: [PATCH 13/17] Docs cleanup --- src/sap_cloud_sdk/core/bootstrap.py | 29 ++- .../core/runtime_context/__init__.py | 28 ++- .../core/runtime_context/_context.py | 2 +- .../core/runtime_context/_envelope.py | 16 +- .../core/runtime_context/_protocol.py | 16 +- .../core/runtime_context/_registry.py | 6 +- .../core/runtime_context/user-guide.md | 181 ++++++++++++++++++ 7 files changed, 225 insertions(+), 53 deletions(-) create mode 100644 src/sap_cloud_sdk/core/runtime_context/user-guide.md diff --git a/src/sap_cloud_sdk/core/bootstrap.py b/src/sap_cloud_sdk/core/bootstrap.py index 9bac6374..c3c7cd8d 100644 --- a/src/sap_cloud_sdk/core/bootstrap.py +++ b/src/sap_cloud_sdk/core/bootstrap.py @@ -9,24 +9,19 @@ def bootstrap(app: Any, providers: Optional[List[ContextProvider]] = None) -> None: """Wire the SDK runtime context into your application framework. - Call this once at application startup. The SDK will attach a middleware - to *app* that populates :class:`~sap_cloud_sdk.core.runtime_context.RequestContext` - on every inbound request by running all *providers* against it and merging - the results. + Call once at startup. On every inbound request the SDK will run all + *providers* against the request, merge the results, and make them + available via :func:`~sap_cloud_sdk.core.runtime_context.get_context`. - After bootstrapping, any SDK module (auditlog, telemetry, etc.) can call - :func:`~sap_cloud_sdk.core.runtime_context.get_context` to read - tenant/user information without knowing about headers or auth providers. - - The framework is detected automatically from the *app* type. Supported - frameworks are determined by registered - :class:`~sap_cloud_sdk.core.runtime_context.FrameworkAdapter` instances — - new frameworks are added by registering an adapter, not by editing this file. + The framework is detected automatically from the *app* type via the + registered :class:`~sap_cloud_sdk.core.runtime_context.FrameworkAdapter` + instances — adding support for a new framework never requires editing + this function. Args: app: The application instance to attach the middleware to. - providers: One or more :class:`~sap_cloud_sdk.core.runtime_context.ContextProvider` - instances. Defaults to ``[IASContextProvider()]``. + providers: Context providers to run on each request. Defaults to + ``[IASContextProvider(), HeaderContextProvider()]``. Raises: TypeError: If no registered adapter recognises *app*. @@ -35,10 +30,10 @@ def bootstrap(app: Any, providers: Optional[List[ContextProvider]] = None) -> No from sap_cloud_sdk import bootstrap - bootstrap(app) # IASContextProvider by default + bootstrap(app) # IAS + SAP headers by default - # multiple providers: - bootstrap(app, providers=[IASContextProvider(), MyCustomProvider()]) + # custom providers: + bootstrap(app, providers=[IASContextProvider(), MyProvider()]) """ if not providers: providers = [IASContextProvider(), HeaderContextProvider()] diff --git a/src/sap_cloud_sdk/core/runtime_context/__init__.py b/src/sap_cloud_sdk/core/runtime_context/__init__.py index 8c5453e2..dfcb0cde 100644 --- a/src/sap_cloud_sdk/core/runtime_context/__init__.py +++ b/src/sap_cloud_sdk/core/runtime_context/__init__.py @@ -1,30 +1,28 @@ -"""SDK-wide per-request runtime context. +"""SDK-wide runtime context for the current execution. -Provides a provider-agnostic way for SDK modules to access caller-identity -fields (tenant, user, trigger type) without coupling to a specific auth -provider or HTTP framework. +Lets SDK modules read caller-identity information (tenant, user, trigger type) +without knowing about the invocation source — HTTP, gRPC, message queue, etc. -Typical usage — wire once at app startup:: +Wire once at startup:: from sap_cloud_sdk import bootstrap - from sap_cloud_sdk.core.runtime_context import IASContextProvider - bootstrap(app, providers=[IASContextProvider()]) + bootstrap(app) # defaults to IASContextProvider + HeaderContextProvider -Then read anywhere in the SDK or application code:: +Then read anywhere:: - from sap_cloud_sdk.core.runtime_context import get_context + from sap_cloud_sdk.core.runtime_context import get_context, TENANT_ID, USER_ID ctx = get_context() - print(ctx.tenant_id) # e.g. "abc-123" - print(ctx.user_id) # e.g. "user-uuid" + ctx.get(TENANT_ID) # -> "abc-123" or None + ctx.get(USER_ID) # -> "user-uuid" or None -For tests or CLI usage without HTTP:: +In tests or scripts without a framework:: - from sap_cloud_sdk.core.runtime_context import sdk_context, RequestContext + from sap_cloud_sdk.core.runtime_context import sdk_context, RequestContext, TENANT_ID - with sdk_context(RequestContext(tenant_id="test-tenant")): - ... # get_context() returns that context here + with sdk_context(RequestContext({TENANT_ID: "test-tenant"})): + ... """ from sap_cloud_sdk.core.runtime_context._context import ( diff --git a/src/sap_cloud_sdk/core/runtime_context/_context.py b/src/sap_cloud_sdk/core/runtime_context/_context.py index d9fa3dbd..ed76324a 100644 --- a/src/sap_cloud_sdk/core/runtime_context/_context.py +++ b/src/sap_cloud_sdk/core/runtime_context/_context.py @@ -10,7 +10,7 @@ class RequestContext: - """Immutable typed bag of per-request values set by context providers. + """Immutable typed bag of caller-identity values for the current execution. Values are keyed by :class:`ContextKey` instances, which carry the expected type. Use :meth:`get` to read a value and :meth:`with_value` diff --git a/src/sap_cloud_sdk/core/runtime_context/_envelope.py b/src/sap_cloud_sdk/core/runtime_context/_envelope.py index aee1dd39..5e7e2314 100644 --- a/src/sap_cloud_sdk/core/runtime_context/_envelope.py +++ b/src/sap_cloud_sdk/core/runtime_context/_envelope.py @@ -6,17 +6,17 @@ @dataclass class RequestEnvelope: - """Normalized view of an inbound request, independent of framework. + """Framework-agnostic view of an inbound request passed to providers. - Framework middlewares build this from their native request object. - :class:`~sap_cloud_sdk.core.runtime_context.ContextProvider` implementations - read from it — they never touch framework-specific types. + The framework middleware populates this; providers read from it. This + means providers work identically regardless of whether the request came + from Starlette, Flask, gRPC, or a test. Attributes: - headers: Case-insensitive HTTP headers (or equivalent for gRPC/etc.). - body: Raw request body bytes. ``None`` if not extracted. - metadata: Catch-all for framework extras (query params, gRPC metadata, - connection info, etc.). Reserved for future providers. + headers: Request headers as a plain dict (lowercased keys recommended). + body: Raw request body. ``None`` if not needed by any provider. + metadata: Framework-specific extras — query params, gRPC metadata, etc. + Currently unused; reserved for future providers. """ headers: Dict[str, str] = field(default_factory=dict) diff --git a/src/sap_cloud_sdk/core/runtime_context/_protocol.py b/src/sap_cloud_sdk/core/runtime_context/_protocol.py index 37575fc3..ec9762df 100644 --- a/src/sap_cloud_sdk/core/runtime_context/_protocol.py +++ b/src/sap_cloud_sdk/core/runtime_context/_protocol.py @@ -8,21 +8,19 @@ @runtime_checkable class ContextProvider(Protocol): - """Extract a :class:`RequestContext` from a :class:`RequestEnvelope`. + """Extracts a :class:`RequestContext` from a :class:`RequestEnvelope`. - Implement this protocol to teach the SDK how to read caller-identity - information from a specific auth provider (IAS, XSUAA, etc.). - - The envelope is framework-agnostic — providers never touch Starlette, - Flask, or gRPC types directly. The framework middleware is responsible - for building the envelope. + Implement this to add a new auth provider or header convention. The envelope + is framework-agnostic — providers never touch Starlette, Flask, or gRPC types. Example:: + MY_KEY = ContextKey[str]("my_key") + class MyProvider(ContextProvider): def extract(self, envelope: RequestEnvelope) -> RequestContext: - token = envelope.headers.get("x-my-token", "") - return RequestContext(tenant_id=decode(token).tenant) + value = envelope.headers.get("x-my-header", "") + return RequestContext({MY_KEY: value} if value else {}) """ def extract(self, envelope: RequestEnvelope) -> RequestContext: # pragma: no cover diff --git a/src/sap_cloud_sdk/core/runtime_context/_registry.py b/src/sap_cloud_sdk/core/runtime_context/_registry.py index 0f4da087..28e45cbe 100644 --- a/src/sap_cloud_sdk/core/runtime_context/_registry.py +++ b/src/sap_cloud_sdk/core/runtime_context/_registry.py @@ -21,10 +21,10 @@ def get_registry() -> List["FrameworkAdapter"]: class FrameworkAdapter(ABC): - """Base class for framework-specific context middleware adapters. + """Connects a framework or invocation source to the SDK runtime context. - Subclasses know how to detect a framework's app object and attach the - appropriate context middleware to it. Register at module level so that + Subclasses know how to detect a specific app type and attach the SDK's + context pipeline to it. Register at module level so that :func:`~sap_cloud_sdk.core.bootstrap.bootstrap` can discover them without importing framework-specific code directly. diff --git a/src/sap_cloud_sdk/core/runtime_context/user-guide.md b/src/sap_cloud_sdk/core/runtime_context/user-guide.md new file mode 100644 index 00000000..c3c7539e --- /dev/null +++ b/src/sap_cloud_sdk/core/runtime_context/user-guide.md @@ -0,0 +1,181 @@ +# Runtime Context User Guide + +## How it works + +The runtime context lets SDK modules read caller-identity information (tenant, +user, trigger type) for the current execution — without knowing where that +information came from or what framework is running. + +- **`bootstrap(app)`** wires the SDK into your framework once at startup. +- **Providers** extract context from the current invocation (HTTP request, gRPC call, Kubernetes event, etc.). +- **`get_context()`** lets any module read that context via typed keys. + +``` +bootstrap(app) + └─ registers middleware on your framework + └─ on each invocation: providers extract → RequestContext set in ContextVar + └─ anywhere: get_context().get(TENANT_ID) +``` + +--- + +## Quick start + +### 1. Bootstrap at app startup + +```python +from starlette.applications import Starlette +from sap_cloud_sdk import bootstrap + +app = Starlette(...) +bootstrap(app) +``` + +By default `bootstrap` registers `IASContextProvider` (reads IAS JWT) and +`HeaderContextProvider` (reads SAP standard headers like `x-sap-origin`). + +### 2. Read context anywhere + +```python +from sap_cloud_sdk.core.runtime_context import get_context, TENANT_ID, USER_ID, TRIGGER_TYPE + +ctx = get_context() +ctx.get(TENANT_ID) # -> "abc-123" or None +ctx.get(USER_ID) # -> "user-uuid" or None +ctx.get(TRIGGER_TYPE) # -> "ui5" or None +``` + +--- + +## Context keys + +Values are stored and retrieved by typed `ContextKey` instances — not strings. +Each provider owns the keys it defines. Import keys from the provider that +defined them. + +```python +# IAS-owned keys: +from sap_cloud_sdk.core.runtime_context import TENANT_ID, USER_ID, IAS_CLAIMS + +# SDK-standard keys (not tied to any specific source): +from sap_cloud_sdk.core.runtime_context import TRIGGER_TYPE + +# Define your own: +from sap_cloud_sdk.core.runtime_context import ContextKey + +MY_KEY = ContextKey[str]("my_key") +``` + +Keys are identity-based — two `ContextKey("same_name")` instances are different +keys. Always import the key from the module that defined it. + +--- + +## Providers + +A provider extracts a `RequestContext` from a `RequestEnvelope` — a +framework-agnostic carrier of whatever signals were available at invocation time +(headers, body, metadata). The provider doesn't know which framework built the +envelope; the framework adapter doesn't know what the provider does with it. + +This means providers are reusable across transports. An `IASContextProvider` +written for HTTP headers works identically if the same headers appear in gRPC +metadata or a message queue envelope — as long as the adapter populates +`RequestEnvelope.headers` consistently. + +### Built-in providers + +| Provider | Reads | Sets | +|---|---|---| +| `IASContextProvider` | `Authorization: Bearer ` | `TENANT_ID`, `USER_ID`, `IAS_CLAIMS` | +| `HeaderContextProvider` | `x-sap-origin` | `TRIGGER_TYPE` | + +### Custom providers + +```python +from sap_cloud_sdk.core.runtime_context import ( + ContextKey, ContextProvider, RequestContext, RequestEnvelope +) + +CORRELATION_ID = ContextKey[str]("correlation_id") + +class CorrelationIdProvider(ContextProvider): + def extract(self, envelope: RequestEnvelope) -> RequestContext: + value = envelope.headers.get("x-correlation-id") + return RequestContext({CORRELATION_ID: value} if value else {}) +``` + +Pass it to `bootstrap`: + +```python +from sap_cloud_sdk.core.runtime_context import IASContextProvider, HeaderContextProvider + +bootstrap(app, providers=[IASContextProvider(), HeaderContextProvider(), CorrelationIdProvider()]) +``` + +### Merging + +When multiple providers are registered, their results are merged — first writer +wins per key. Providers that set different keys don't interfere with each other. + +--- + +## Framework adapters + +`bootstrap` auto-detects the framework from the `app` type via registered +`FrameworkAdapter` instances. Each adapter knows how to intercept invocations +for one framework and build a `RequestEnvelope` from whatever the framework +exposes. Adding support for a new framework or invocation source never requires +editing `bootstrap`. + +### Currently supported + +| Framework | Detected via | +|---|---| +| Starlette / FastAPI | `isinstance(app, Starlette)` | + +### Adding a new framework or invocation source + +```python +from sap_cloud_sdk.core.runtime_context import ContextProvider, FrameworkAdapter, register + +class FlaskContextAdapter(FrameworkAdapter): + def _matches(self, app) -> bool: + from flask import Flask + return isinstance(app, Flask) + + def attach(self, app, providers: list[ContextProvider]) -> None: + from my_flask_middleware import FlaskContextMiddleware + app.before_request(FlaskContextMiddleware(providers).handle) + +register(FlaskContextAdapter()) +``` + +--- + +## Manual usage (tests, CLI, scripts) + +When there is no framework to bootstrap — unit tests, CLI tools, background +jobs — set the context directly for the duration of a block: + +```python +from sap_cloud_sdk.core.runtime_context import sdk_context, RequestContext, TENANT_ID, USER_ID + +# Sync: +with sdk_context(RequestContext({TENANT_ID: "test-tenant", USER_ID: "test-user"})): + result = some_sdk_call() + +# Async: +from sap_cloud_sdk.core.runtime_context import async_sdk_context + +async with async_sdk_context(RequestContext({TENANT_ID: "test-tenant"})): + result = await some_async_sdk_call() +``` + +--- + +## Running the tests + +```bash +uv run pytest tests/core/unit/runtime_context/ +``` From 23cb30d6ec619d23bfaaf3bada4cc99f190a7e81 Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Wed, 22 Jul 2026 15:48:05 +0200 Subject: [PATCH 14/17] Renames --- .../core/runtime_context/__init__.py | 8 +-- .../core/runtime_context/_context.py | 28 ++++----- .../core/runtime_context/_keys.py | 8 +-- .../core/runtime_context/_protocol.py | 10 +-- .../runtime_context/providers/_headers.py | 6 +- .../core/runtime_context/providers/_ias.py | 6 +- .../core/runtime_context/starlette.py | 10 +-- .../core/runtime_context/user-guide.md | 16 ++--- .../runtime_context/test_runtime_context.py | 62 +++++++++---------- 9 files changed, 77 insertions(+), 77 deletions(-) diff --git a/src/sap_cloud_sdk/core/runtime_context/__init__.py b/src/sap_cloud_sdk/core/runtime_context/__init__.py index dfcb0cde..a71dedd9 100644 --- a/src/sap_cloud_sdk/core/runtime_context/__init__.py +++ b/src/sap_cloud_sdk/core/runtime_context/__init__.py @@ -19,14 +19,14 @@ In tests or scripts without a framework:: - from sap_cloud_sdk.core.runtime_context import sdk_context, RequestContext, TENANT_ID + from sap_cloud_sdk.core.runtime_context import sdk_context, RuntimeContext, TENANT_ID - with sdk_context(RequestContext({TENANT_ID: "test-tenant"})): + with sdk_context(RuntimeContext({TENANT_ID: "test-tenant"})): ... """ from sap_cloud_sdk.core.runtime_context._context import ( - RequestContext, + RuntimeContext, async_sdk_context, get_context, sdk_context, @@ -54,7 +54,7 @@ "HeaderContextProvider", "IAS_CLAIMS", "IASContextProvider", - "RequestContext", + "RuntimeContext", "RequestEnvelope", "TENANT_ID", "TRIGGER_TYPE", diff --git a/src/sap_cloud_sdk/core/runtime_context/_context.py b/src/sap_cloud_sdk/core/runtime_context/_context.py index ed76324a..11102ed7 100644 --- a/src/sap_cloud_sdk/core/runtime_context/_context.py +++ b/src/sap_cloud_sdk/core/runtime_context/_context.py @@ -9,7 +9,7 @@ T = TypeVar("T") -class RequestContext: +class RuntimeContext: """Immutable typed bag of caller-identity values for the current execution. Values are keyed by :class:`ContextKey` instances, which carry the @@ -20,7 +20,7 @@ class RequestContext: MY_KEY = ContextKey[str]("my_key") - ctx = RequestContext({MY_KEY: "hello"}) + ctx = RuntimeContext({MY_KEY: "hello"}) ctx.get(MY_KEY) # -> "hello" """ @@ -31,9 +31,9 @@ def get(self, key: ContextKey[T]) -> Optional[T]: """Return the value for *key*, or ``None`` if not set.""" return self._values.get(key) - def with_value(self, key: ContextKey[T], value: T) -> "RequestContext": - """Return a new RequestContext with *key* set to *value*.""" - return RequestContext({**self._values, key: value}) + def with_value(self, key: ContextKey[T], value: T) -> "RuntimeContext": + """Return a new RuntimeContext with *key* set to *value*.""" + return RuntimeContext({**self._values, key: value}) def _raw(self) -> Dict[ContextKey, Any]: """Return a shallow copy of the internal values dict.""" @@ -41,31 +41,31 @@ def _raw(self) -> Dict[ContextKey, Any]: def __repr__(self) -> str: pairs = ", ".join(f"{k.name}={v!r}" for k, v in self._values.items()) - return f"RequestContext({{{pairs}}})" + return f"RuntimeContext({{{pairs}}})" -_EMPTY = RequestContext() +_EMPTY = RuntimeContext() -_context_var: ContextVar[RequestContext] = ContextVar( +_context_var: ContextVar[RuntimeContext] = ContextVar( "sap_sdk_request_context", default=_EMPTY ) -def set_context(ctx: RequestContext) -> None: +def set_context(ctx: RuntimeContext) -> None: """Set the runtime context for the current async/thread scope.""" _context_var.set(ctx) -def get_context() -> RequestContext: +def get_context() -> RuntimeContext: """Return the runtime context for the current async/thread scope. - Returns an empty :class:`RequestContext` when no context has been set. + Returns an empty :class:`RuntimeContext` when no context has been set. """ return _context_var.get() @contextmanager -def sdk_context(ctx: RequestContext) -> Generator[RequestContext, None, None]: +def sdk_context(ctx: RuntimeContext) -> Generator[RuntimeContext, None, None]: """Sync context manager that sets *ctx* for the duration of the block.""" token = _context_var.set(ctx) try: @@ -76,8 +76,8 @@ def sdk_context(ctx: RequestContext) -> Generator[RequestContext, None, None]: @asynccontextmanager async def async_sdk_context( - ctx: RequestContext, -) -> AsyncGenerator[RequestContext, None]: + ctx: RuntimeContext, +) -> AsyncGenerator[RuntimeContext, None]: """Async context manager that sets *ctx* for the duration of the block.""" token = _context_var.set(ctx) try: diff --git a/src/sap_cloud_sdk/core/runtime_context/_keys.py b/src/sap_cloud_sdk/core/runtime_context/_keys.py index eb8f8dd3..5d139a41 100644 --- a/src/sap_cloud_sdk/core/runtime_context/_keys.py +++ b/src/sap_cloud_sdk/core/runtime_context/_keys.py @@ -1,4 +1,4 @@ -"""Typed context key for RequestContext.""" +"""Typed context key for RuntimeContext.""" from typing import Generic, TypeVar @@ -6,16 +6,16 @@ class ContextKey(Generic[T]): - """A typed key for reading and writing values in a :class:`RequestContext`. + """A typed key for reading and writing values in a :class:`RuntimeContext`. Each provider defines its own keys. The type parameter ensures consumers - get the right type back from :meth:`RequestContext.get`. + get the right type back from :meth:`RuntimeContext.get`. Example:: MY_KEY = ContextKey[str]("my_key") - ctx = RequestContext({MY_KEY: "value"}) + ctx = RuntimeContext({MY_KEY: "value"}) ctx.get(MY_KEY) # -> "value" """ diff --git a/src/sap_cloud_sdk/core/runtime_context/_protocol.py b/src/sap_cloud_sdk/core/runtime_context/_protocol.py index ec9762df..c0f844e4 100644 --- a/src/sap_cloud_sdk/core/runtime_context/_protocol.py +++ b/src/sap_cloud_sdk/core/runtime_context/_protocol.py @@ -2,13 +2,13 @@ from typing import Protocol, runtime_checkable -from sap_cloud_sdk.core.runtime_context._context import RequestContext +from sap_cloud_sdk.core.runtime_context._context import RuntimeContext from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope @runtime_checkable class ContextProvider(Protocol): - """Extracts a :class:`RequestContext` from a :class:`RequestEnvelope`. + """Extracts a :class:`RuntimeContext` from a :class:`RequestEnvelope`. Implement this to add a new auth provider or header convention. The envelope is framework-agnostic — providers never touch Starlette, Flask, or gRPC types. @@ -18,10 +18,10 @@ class ContextProvider(Protocol): MY_KEY = ContextKey[str]("my_key") class MyProvider(ContextProvider): - def extract(self, envelope: RequestEnvelope) -> RequestContext: + def extract(self, envelope: RequestEnvelope) -> RuntimeContext: value = envelope.headers.get("x-my-header", "") - return RequestContext({MY_KEY: value} if value else {}) + return RuntimeContext({MY_KEY: value} if value else {}) """ - def extract(self, envelope: RequestEnvelope) -> RequestContext: # pragma: no cover + def extract(self, envelope: RequestEnvelope) -> RuntimeContext: # pragma: no cover ... diff --git a/src/sap_cloud_sdk/core/runtime_context/providers/_headers.py b/src/sap_cloud_sdk/core/runtime_context/providers/_headers.py index 6ec7c265..428e1690 100644 --- a/src/sap_cloud_sdk/core/runtime_context/providers/_headers.py +++ b/src/sap_cloud_sdk/core/runtime_context/providers/_headers.py @@ -1,6 +1,6 @@ """SAP standard headers context provider.""" -from sap_cloud_sdk.core.runtime_context._context import RequestContext +from sap_cloud_sdk.core.runtime_context._context import RuntimeContext from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope from sap_cloud_sdk.core.runtime_context._keys import TRIGGER_TYPE from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider @@ -15,9 +15,9 @@ class HeaderContextProvider(ContextProvider): from ``x-sap-origin`` """ - def extract(self, envelope: RequestEnvelope) -> RequestContext: + def extract(self, envelope: RequestEnvelope) -> RuntimeContext: values = {} origin = envelope.headers.get("x-sap-origin") if origin: values[TRIGGER_TYPE] = origin - return RequestContext(values) + return RuntimeContext(values) diff --git a/src/sap_cloud_sdk/core/runtime_context/providers/_ias.py b/src/sap_cloud_sdk/core/runtime_context/providers/_ias.py index 84afde49..03e083ea 100644 --- a/src/sap_cloud_sdk/core/runtime_context/providers/_ias.py +++ b/src/sap_cloud_sdk/core/runtime_context/providers/_ias.py @@ -1,6 +1,6 @@ """IAS context provider and its typed keys.""" -from sap_cloud_sdk.core.runtime_context._context import RequestContext +from sap_cloud_sdk.core.runtime_context._context import RuntimeContext from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope from sap_cloud_sdk.core.runtime_context._keys import ContextKey from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider @@ -24,7 +24,7 @@ class IASContextProvider(ContextProvider): - :data:`IAS_CLAIMS` full :class:`~sap_cloud_sdk.ias.IASClaims` object """ - def extract(self, envelope: RequestEnvelope) -> RequestContext: + def extract(self, envelope: RequestEnvelope) -> RuntimeContext: auth = envelope.headers.get("authorization", "") claims = None @@ -42,4 +42,4 @@ def extract(self, envelope: RequestEnvelope) -> RequestContext: values[USER_ID] = claims.user_uuid values[IAS_CLAIMS] = claims - return RequestContext(values) + return RuntimeContext(values) diff --git a/src/sap_cloud_sdk/core/runtime_context/starlette.py b/src/sap_cloud_sdk/core/runtime_context/starlette.py index 240263fc..eb0fcb05 100644 --- a/src/sap_cloud_sdk/core/runtime_context/starlette.py +++ b/src/sap_cloud_sdk/core/runtime_context/starlette.py @@ -3,7 +3,7 @@ from typing import Any, List from sap_cloud_sdk.core.runtime_context._context import ( - RequestContext, + RuntimeContext, async_sdk_context, ) from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope @@ -20,13 +20,13 @@ ) from exc -def _merge(contexts: List[RequestContext]) -> RequestContext: - """Merge multiple RequestContexts — first writer wins per key.""" +def _merge(contexts: List[RuntimeContext]) -> RuntimeContext: + """Merge multiple RuntimeContexts — first writer wins per key.""" merged: dict = {} for ctx in contexts: for key, value in ctx._raw().items(): merged.setdefault(key, value) - return RequestContext(merged) + return RuntimeContext(merged) class StarletteContextMiddleware(BaseHTTPMiddleware): @@ -34,7 +34,7 @@ class StarletteContextMiddleware(BaseHTTPMiddleware): Builds a :class:`~sap_cloud_sdk.core.runtime_context.RequestEnvelope` from each inbound request, runs all *providers* against it, and merges the results - into a single :class:`~sap_cloud_sdk.core.runtime_context.RequestContext` + into a single :class:`~sap_cloud_sdk.core.runtime_context.RuntimeContext` available via :func:`~sap_cloud_sdk.core.runtime_context.get_context` for the duration of that request. diff --git a/src/sap_cloud_sdk/core/runtime_context/user-guide.md b/src/sap_cloud_sdk/core/runtime_context/user-guide.md index c3c7539e..fd62ace0 100644 --- a/src/sap_cloud_sdk/core/runtime_context/user-guide.md +++ b/src/sap_cloud_sdk/core/runtime_context/user-guide.md @@ -13,7 +13,7 @@ information came from or what framework is running. ``` bootstrap(app) └─ registers middleware on your framework - └─ on each invocation: providers extract → RequestContext set in ContextVar + └─ on each invocation: providers extract → RuntimeContext set in ContextVar └─ anywhere: get_context().get(TENANT_ID) ``` @@ -73,7 +73,7 @@ keys. Always import the key from the module that defined it. ## Providers -A provider extracts a `RequestContext` from a `RequestEnvelope` — a +A provider extracts a `RuntimeContext` from a `RequestEnvelope` — a framework-agnostic carrier of whatever signals were available at invocation time (headers, body, metadata). The provider doesn't know which framework built the envelope; the framework adapter doesn't know what the provider does with it. @@ -94,15 +94,15 @@ metadata or a message queue envelope — as long as the adapter populates ```python from sap_cloud_sdk.core.runtime_context import ( - ContextKey, ContextProvider, RequestContext, RequestEnvelope + ContextKey, ContextProvider, RuntimeContext, RequestEnvelope ) CORRELATION_ID = ContextKey[str]("correlation_id") class CorrelationIdProvider(ContextProvider): - def extract(self, envelope: RequestEnvelope) -> RequestContext: + def extract(self, envelope: RequestEnvelope) -> RuntimeContext: value = envelope.headers.get("x-correlation-id") - return RequestContext({CORRELATION_ID: value} if value else {}) + return RuntimeContext({CORRELATION_ID: value} if value else {}) ``` Pass it to `bootstrap`: @@ -159,16 +159,16 @@ When there is no framework to bootstrap — unit tests, CLI tools, background jobs — set the context directly for the duration of a block: ```python -from sap_cloud_sdk.core.runtime_context import sdk_context, RequestContext, TENANT_ID, USER_ID +from sap_cloud_sdk.core.runtime_context import sdk_context, RuntimeContext, TENANT_ID, USER_ID # Sync: -with sdk_context(RequestContext({TENANT_ID: "test-tenant", USER_ID: "test-user"})): +with sdk_context(RuntimeContext({TENANT_ID: "test-tenant", USER_ID: "test-user"})): result = some_sdk_call() # Async: from sap_cloud_sdk.core.runtime_context import async_sdk_context -async with async_sdk_context(RequestContext({TENANT_ID: "test-tenant"})): +async with async_sdk_context(RuntimeContext({TENANT_ID: "test-tenant"})): result = await some_async_sdk_call() ``` diff --git a/tests/core/unit/runtime_context/test_runtime_context.py b/tests/core/unit/runtime_context/test_runtime_context.py index a350d381..7abd3558 100644 --- a/tests/core/unit/runtime_context/test_runtime_context.py +++ b/tests/core/unit/runtime_context/test_runtime_context.py @@ -8,7 +8,7 @@ ContextProvider, HeaderContextProvider, IASContextProvider, - RequestContext, + RuntimeContext, RequestEnvelope, TRIGGER_TYPE, async_sdk_context, @@ -39,30 +39,30 @@ def test_repr(self): def test_different_instances_are_different_keys(self): a = ContextKey[str]("x") b = ContextKey[str]("x") - ctx = RequestContext({a: "from-a"}) + ctx = RuntimeContext({a: "from-a"}) assert ctx.get(a) == "from-a" assert ctx.get(b) is None # --------------------------------------------------------------------------- -# RequestContext +# RuntimeContext # --------------------------------------------------------------------------- -class TestRequestContext: +class TestRuntimeContext: def test_empty_by_default(self): - ctx = RequestContext() + ctx = RuntimeContext() key = ContextKey[str]("k") assert ctx.get(key) is None def test_get_returns_set_value(self): key = ContextKey[str]("k") - ctx = RequestContext({key: "v"}) + ctx = RuntimeContext({key: "v"}) assert ctx.get(key) == "v" def test_with_value_returns_new_instance(self): key = ContextKey[str]("k") - ctx = RequestContext() + ctx = RuntimeContext() ctx2 = ctx.with_value(key, "v") assert ctx2 is not ctx assert ctx.get(key) is None @@ -70,13 +70,13 @@ def test_with_value_returns_new_instance(self): def test_immutable_original_unaffected_by_with_value(self): key = ContextKey[str]("k") - ctx = RequestContext({key: "original"}) + ctx = RuntimeContext({key: "original"}) ctx.with_value(key, "new") assert ctx.get(key) == "original" def test_repr(self): key = ContextKey[str]("tenant_id") - ctx = RequestContext({key: "t-1"}) + ctx = RuntimeContext({key: "t-1"}) assert "tenant_id" in repr(ctx) assert "t-1" in repr(ctx) @@ -112,12 +112,12 @@ def test_get_returns_empty_by_default(self): def test_set_then_get_returns_same_object(self): key = ContextKey[str]("k") - ctx = RequestContext({key: "v"}) + ctx = RuntimeContext({key: "v"}) set_context(ctx) assert get_context() is ctx def teardown_method(self): - set_context(RequestContext()) + set_context(RuntimeContext()) # --------------------------------------------------------------------------- @@ -128,34 +128,34 @@ def teardown_method(self): class TestSdkContext: def test_sets_context_inside_block(self): key = ContextKey[str]("k") - ctx = RequestContext({key: "inside"}) + ctx = RuntimeContext({key: "inside"}) with sdk_context(ctx): assert get_context().get(key) == "inside" def test_restores_previous_context_after_block(self): key = ContextKey[str]("k") - outer = RequestContext({key: "outer"}) + outer = RuntimeContext({key: "outer"}) set_context(outer) - with sdk_context(RequestContext({key: "inner"})): + with sdk_context(RuntimeContext({key: "inner"})): pass assert get_context().get(key) == "outer" def test_restores_on_exception(self): key = ContextKey[str]("k") - outer = RequestContext({key: "outer"}) + outer = RuntimeContext({key: "outer"}) set_context(outer) with pytest.raises(ValueError): - with sdk_context(RequestContext({key: "inner"})): + with sdk_context(RuntimeContext({key: "inner"})): raise ValueError("boom") assert get_context().get(key) == "outer" def test_yields_the_context(self): - ctx = RequestContext() + ctx = RuntimeContext() with sdk_context(ctx) as yielded: assert yielded is ctx def teardown_method(self): - set_context(RequestContext()) + set_context(RuntimeContext()) # --------------------------------------------------------------------------- @@ -167,31 +167,31 @@ class TestAsyncSdkContext: @pytest.mark.anyio async def test_sets_context_inside_async_block(self): key = ContextKey[str]("k") - ctx = RequestContext({key: "async-value"}) + ctx = RuntimeContext({key: "async-value"}) async with async_sdk_context(ctx): assert get_context().get(key) == "async-value" @pytest.mark.anyio async def test_restores_after_async_block(self): key = ContextKey[str]("k") - outer = RequestContext({key: "outer"}) + outer = RuntimeContext({key: "outer"}) set_context(outer) - async with async_sdk_context(RequestContext({key: "inner"})): + async with async_sdk_context(RuntimeContext({key: "inner"})): pass assert get_context().get(key) == "outer" @pytest.mark.anyio async def test_restores_on_async_exception(self): key = ContextKey[str]("k") - outer = RequestContext({key: "outer"}) + outer = RuntimeContext({key: "outer"}) set_context(outer) with pytest.raises(RuntimeError): - async with async_sdk_context(RequestContext({key: "inner"})): + async with async_sdk_context(RuntimeContext({key: "inner"})): raise RuntimeError("boom") assert get_context().get(key) == "outer" def teardown_method(self): - set_context(RequestContext()) + set_context(RuntimeContext()) # --------------------------------------------------------------------------- @@ -202,8 +202,8 @@ def teardown_method(self): class TestContextProviderProtocol: def test_custom_class_satisfies_protocol(self): class MyProvider: - def extract(self, envelope: RequestEnvelope) -> RequestContext: - return RequestContext() + def extract(self, envelope: RequestEnvelope) -> RuntimeContext: + return RuntimeContext() assert isinstance(MyProvider(), ContextProvider) @@ -309,16 +309,16 @@ def test_satisfies_context_provider_protocol(self): class TestMerge: def test_first_writer_wins_per_key(self): key = ContextKey[str]("k") - a = RequestContext({key: "from-a"}) - b = RequestContext({key: "from-b"}) + a = RuntimeContext({key: "from-a"}) + b = RuntimeContext({key: "from-b"}) merged = _merge([a, b]) assert merged.get(key) == "from-a" def test_second_fills_missing_from_first(self): k1 = ContextKey[str]("k1") k2 = ContextKey[str]("k2") - a = RequestContext({k1: "v1"}) - b = RequestContext({k2: "v2"}) + a = RuntimeContext({k1: "v1"}) + b = RuntimeContext({k2: "v2"}) merged = _merge([a, b]) assert merged.get(k1) == "v1" assert merged.get(k2) == "v2" @@ -330,6 +330,6 @@ def test_empty_list_returns_empty_context(self): def test_single_context_passthrough(self): key = ContextKey[str]("k") - ctx = RequestContext({key: "v"}) + ctx = RuntimeContext({key: "v"}) merged = _merge([ctx]) assert merged.get(key) == "v" From 0ad79e272360e11d375b3e451b105e3966732701 Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Wed, 22 Jul 2026 15:59:49 +0200 Subject: [PATCH 15/17] API Surface --- .../core/runtime_context/__init__.py | 13 ------------ .../core/runtime_context/user-guide.md | 21 ------------------- .../runtime_context/test_runtime_context.py | 4 +++- 3 files changed, 3 insertions(+), 35 deletions(-) diff --git a/src/sap_cloud_sdk/core/runtime_context/__init__.py b/src/sap_cloud_sdk/core/runtime_context/__init__.py index a71dedd9..e8a83962 100644 --- a/src/sap_cloud_sdk/core/runtime_context/__init__.py +++ b/src/sap_cloud_sdk/core/runtime_context/__init__.py @@ -16,21 +16,11 @@ ctx = get_context() ctx.get(TENANT_ID) # -> "abc-123" or None ctx.get(USER_ID) # -> "user-uuid" or None - -In tests or scripts without a framework:: - - from sap_cloud_sdk.core.runtime_context import sdk_context, RuntimeContext, TENANT_ID - - with sdk_context(RuntimeContext({TENANT_ID: "test-tenant"})): - ... """ from sap_cloud_sdk.core.runtime_context._context import ( RuntimeContext, - async_sdk_context, get_context, - sdk_context, - set_context, ) from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope from sap_cloud_sdk.core.runtime_context._keys import ContextKey, TRIGGER_TYPE @@ -59,9 +49,6 @@ "TENANT_ID", "TRIGGER_TYPE", "USER_ID", - "async_sdk_context", "get_context", "register", - "sdk_context", - "set_context", ] diff --git a/src/sap_cloud_sdk/core/runtime_context/user-guide.md b/src/sap_cloud_sdk/core/runtime_context/user-guide.md index fd62ace0..8abd1259 100644 --- a/src/sap_cloud_sdk/core/runtime_context/user-guide.md +++ b/src/sap_cloud_sdk/core/runtime_context/user-guide.md @@ -153,27 +153,6 @@ register(FlaskContextAdapter()) --- -## Manual usage (tests, CLI, scripts) - -When there is no framework to bootstrap — unit tests, CLI tools, background -jobs — set the context directly for the duration of a block: - -```python -from sap_cloud_sdk.core.runtime_context import sdk_context, RuntimeContext, TENANT_ID, USER_ID - -# Sync: -with sdk_context(RuntimeContext({TENANT_ID: "test-tenant", USER_ID: "test-user"})): - result = some_sdk_call() - -# Async: -from sap_cloud_sdk.core.runtime_context import async_sdk_context - -async with async_sdk_context(RuntimeContext({TENANT_ID: "test-tenant"})): - result = await some_async_sdk_call() -``` - ---- - ## Running the tests ```bash diff --git a/tests/core/unit/runtime_context/test_runtime_context.py b/tests/core/unit/runtime_context/test_runtime_context.py index 7abd3558..e098219d 100644 --- a/tests/core/unit/runtime_context/test_runtime_context.py +++ b/tests/core/unit/runtime_context/test_runtime_context.py @@ -11,8 +11,10 @@ RuntimeContext, RequestEnvelope, TRIGGER_TYPE, - async_sdk_context, get_context, +) +from sap_cloud_sdk.core.runtime_context._context import ( + async_sdk_context, sdk_context, set_context, ) From 6d3b68a5e6cf1f690db4d1e29a18353607da71e6 Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Wed, 22 Jul 2026 16:22:27 +0200 Subject: [PATCH 16/17] Linting and version bump --- pyproject.toml | 2 +- src/sap_cloud_sdk/core/bootstrap.py | 1 + uv.lock | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 94264213..1910a4a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.37.0" +version = "0.39.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/core/bootstrap.py b/src/sap_cloud_sdk/core/bootstrap.py index c3c7cd8d..9de279cf 100644 --- a/src/sap_cloud_sdk/core/bootstrap.py +++ b/src/sap_cloud_sdk/core/bootstrap.py @@ -6,6 +6,7 @@ from sap_cloud_sdk.core.runtime_context._registry import get_registry from sap_cloud_sdk.core.runtime_context import HeaderContextProvider, IASContextProvider + def bootstrap(app: Any, providers: Optional[List[ContextProvider]] = None) -> None: """Wire the SDK runtime context into your application framework. diff --git a/uv.lock b/uv.lock index c4d7eca3..2c04aa6e 100644 --- a/uv.lock +++ b/uv.lock @@ -3685,7 +3685,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.36.0" +version = "0.39.0" source = { editable = "." } dependencies = [ { name = "grpcio" }, From 4ac98a36f5d62fdef19ad4477b7ad216cfd4cd21 Mon Sep 17 00:00:00 2001 From: Lucas Soares Date: Wed, 22 Jul 2026 17:34:50 +0200 Subject: [PATCH 17/17] context per attribute --- .../core/runtime_context/__init__.py | 4 ++-- .../core/runtime_context/providers/__init__.py | 4 ++-- .../core/runtime_context/providers/_ias.py | 15 ++++++++------- .../unit/runtime_context/test_runtime_context.py | 13 +++++++------ 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/sap_cloud_sdk/core/runtime_context/__init__.py b/src/sap_cloud_sdk/core/runtime_context/__init__.py index e8a83962..9ca19916 100644 --- a/src/sap_cloud_sdk/core/runtime_context/__init__.py +++ b/src/sap_cloud_sdk/core/runtime_context/__init__.py @@ -29,7 +29,7 @@ from sap_cloud_sdk.core.runtime_context.providers import ( HeaderContextProvider, IASContextProvider, - IAS_CLAIMS, + GLOBAL_TENANT_ID, TENANT_ID, USER_ID, ) @@ -41,8 +41,8 @@ "ContextKey", "ContextProvider", "FrameworkAdapter", + "GLOBAL_TENANT_ID", "HeaderContextProvider", - "IAS_CLAIMS", "IASContextProvider", "RuntimeContext", "RequestEnvelope", diff --git a/src/sap_cloud_sdk/core/runtime_context/providers/__init__.py b/src/sap_cloud_sdk/core/runtime_context/providers/__init__.py index 61d44a65..f457301e 100644 --- a/src/sap_cloud_sdk/core/runtime_context/providers/__init__.py +++ b/src/sap_cloud_sdk/core/runtime_context/providers/__init__.py @@ -2,15 +2,15 @@ from sap_cloud_sdk.core.runtime_context.providers._headers import HeaderContextProvider from sap_cloud_sdk.core.runtime_context.providers._ias import ( - IAS_CLAIMS, + GLOBAL_TENANT_ID, IASContextProvider, TENANT_ID, USER_ID, ) __all__ = [ + "GLOBAL_TENANT_ID", "HeaderContextProvider", - "IAS_CLAIMS", "IASContextProvider", "TENANT_ID", "USER_ID", diff --git a/src/sap_cloud_sdk/core/runtime_context/providers/_ias.py b/src/sap_cloud_sdk/core/runtime_context/providers/_ias.py index 03e083ea..798095df 100644 --- a/src/sap_cloud_sdk/core/runtime_context/providers/_ias.py +++ b/src/sap_cloud_sdk/core/runtime_context/providers/_ias.py @@ -6,9 +6,9 @@ from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider from sap_cloud_sdk.ias import parse_token -TENANT_ID = ContextKey[str]("tenant_id") -USER_ID = ContextKey[str]("user_id") -IAS_CLAIMS = ContextKey["IASClaims"]("ias.claims") # type: ignore[type-arg] +TENANT_ID = ContextKey[str]("ias.app_tid") +GLOBAL_TENANT_ID = ContextKey[str]("ias.sap_gtid") +USER_ID = ContextKey[str]("ias.user_uuid") class IASContextProvider(ContextProvider): @@ -19,9 +19,9 @@ class IASContextProvider(ContextProvider): Defines and populates the following context keys: - - :data:`TENANT_ID` from ``app_tid`` claim - - :data:`USER_ID` from ``user_uuid`` claim - - :data:`IAS_CLAIMS` full :class:`~sap_cloud_sdk.ias.IASClaims` object + - :data:`TENANT_ID` from ``app_tid`` claim + - :data:`GLOBAL_TENANT_ID` from ``sap_gtid`` claim + - :data:`USER_ID` from ``user_uuid`` claim """ def extract(self, envelope: RequestEnvelope) -> RuntimeContext: @@ -38,8 +38,9 @@ def extract(self, envelope: RequestEnvelope) -> RuntimeContext: if claims: if claims.app_tid: values[TENANT_ID] = claims.app_tid + if claims.sap_gtid: + values[GLOBAL_TENANT_ID] = claims.sap_gtid if claims.user_uuid: values[USER_ID] = claims.user_uuid - values[IAS_CLAIMS] = claims return RuntimeContext(values) diff --git a/tests/core/unit/runtime_context/test_runtime_context.py b/tests/core/unit/runtime_context/test_runtime_context.py index e098219d..e9fb7b15 100644 --- a/tests/core/unit/runtime_context/test_runtime_context.py +++ b/tests/core/unit/runtime_context/test_runtime_context.py @@ -19,7 +19,7 @@ set_context, ) from sap_cloud_sdk.core.runtime_context.providers._ias import ( - IAS_CLAIMS, + GLOBAL_TENANT_ID, TENANT_ID, USER_ID, ) @@ -221,10 +221,11 @@ class NotAProvider: # --------------------------------------------------------------------------- -def _make_claims(app_tid=None, user_uuid=None): +def _make_claims(app_tid=None, user_uuid=None, sap_gtid=None): claims = MagicMock() claims.app_tid = app_tid claims.user_uuid = user_uuid + claims.sap_gtid = sap_gtid return claims @@ -250,19 +251,19 @@ def test_does_not_set_trigger_type(self): ctx = IASContextProvider().extract(envelope) assert ctx.get(TRIGGER_TYPE) is None - def test_stores_full_claims(self): - claims = _make_claims(app_tid="t-1", user_uuid="u-1") + def test_extracts_global_tenant_id(self): + claims = _make_claims(app_tid="t-1", user_uuid="u-1", sap_gtid="g-1") envelope = _make_envelope({"authorization": "Bearer tok"}) with patch(_PATCH_PARSE, return_value=claims): ctx = IASContextProvider().extract(envelope) - assert ctx.get(IAS_CLAIMS) is claims + assert ctx.get(GLOBAL_TENANT_ID) == "g-1" def test_returns_empty_context_when_no_auth_header(self): envelope = _make_envelope({}) ctx = IASContextProvider().extract(envelope) assert ctx.get(TENANT_ID) is None assert ctx.get(USER_ID) is None - assert ctx.get(IAS_CLAIMS) is None + assert ctx.get(GLOBAL_TENANT_ID) is None def test_returns_empty_context_on_parse_error(self): envelope = _make_envelope({"authorization": "Bearer bad"})