Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a995377
context var
LucasAlvesSoares Jul 20, 2026
3680f4f
auto tenant logging
LucasAlvesSoares Jul 20, 2026
61e4bc4
als tenant inference
LucasAlvesSoares Jul 20, 2026
1f6f3ca
SDK bootstrap
LucasAlvesSoares Jul 21, 2026
e85526d
decouple
LucasAlvesSoares Jul 21, 2026
72dacd5
decouple
LucasAlvesSoares Jul 21, 2026
32d6dd8
Extensible providers
LucasAlvesSoares Jul 21, 2026
2d3fe10
Runtime context
LucasAlvesSoares Jul 22, 2026
ffb90fd
Cleanups
LucasAlvesSoares Jul 22, 2026
91112a5
Cleanups
LucasAlvesSoares Jul 22, 2026
4b0b584
Typed dict for extensibility
LucasAlvesSoares Jul 22, 2026
76453b4
Decoupling
LucasAlvesSoares Jul 22, 2026
6179334
Docs cleanup
LucasAlvesSoares Jul 22, 2026
23cb30d
Renames
LucasAlvesSoares Jul 22, 2026
0ad79e2
API Surface
LucasAlvesSoares Jul 22, 2026
6d3b68a
Linting and version bump
LucasAlvesSoares Jul 22, 2026
4ac98a3
context per attribute
LucasAlvesSoares Jul 22, 2026
1b9f699
Merge branch 'main' into auto-tenant-auditlog
LucasAlvesSoares Jul 22, 2026
3daa9a1
Static map for header keys
LucasAlvesSoares Jul 23, 2026
93d3c51
Adoption metrics
LucasAlvesSoares Jul 23, 2026
93ac80c
Merge branch 'main' into auto-tenant-auditlog
LucasAlvesSoares Jul 23, 2026
c971b7d
Auditlog to read from runtime context
LucasAlvesSoares Jul 23, 2026
298aacf
Unit tests
LucasAlvesSoares Jul 23, 2026
0452436
Replaced literal with type and added debug logging
LucasAlvesSoares Jul 23, 2026
321ba29
Linting
LucasAlvesSoares Jul 23, 2026
263a35e
Decoupled header providers
LucasAlvesSoares Jul 24, 2026
3157334
Linting and docs
LucasAlvesSoares Jul 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "sap-cloud-sdk"
version = "0.38.0"
version = "0.39.0"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
4 changes: 4 additions & 0 deletions src/sap_cloud_sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
# SAP Cloud SDK for Python

from sap_cloud_sdk.core.bootstrap import bootstrap

__all__ = ["bootstrap"]
20 changes: 15 additions & 5 deletions src/sap_cloud_sdk/core/auditlog_ng/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
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
from sap_cloud_sdk.core.runtime_context.providers import TENANT_ID, USER_ID
from sap_cloud_sdk.ias._context import get_auth_context


Expand All @@ -48,16 +50,24 @@ def _fill_common_from_auth_context(event: Message) -> None:
mutation, so callers that never touched common still get it populated.
Sets tenant_id and user_initiator_id from IAS claims (if present and not
already set), and sets timestamp to now if the caller left it at zero.

Reads from the SDK runtime context first (populated by bootstrap()), then
falls back to the legacy IAS auth context for apps not yet using bootstrap().
"""
if not hasattr(event, "common"):
return
common = cast(Any, event.common)

ctx = get_context()
claims = get_auth_context()
if claims is not 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

tenant_id = ctx.get(TENANT_ID) or (claims and claims.app_tid)
user_id = ctx.get(USER_ID) or (claims and claims.user_uuid)

if tenant_id and not common.tenant_id:
common.tenant_id = tenant_id
if user_id and not common.user_initiator_id:
common.user_initiator_id = user_id
if common.timestamp.seconds == 0:
common.timestamp.FromDatetime(datetime.now(timezone.utc))

Expand Down
62 changes: 62 additions & 0 deletions src/sap_cloud_sdk/core/bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Top-level bootstrap() entry point for the SAP Cloud SDK."""

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 (
DWCContextProvider,
IASContextProvider,
SAPTriggerContextProvider,
)
from sap_cloud_sdk.core.telemetry import Module, Operation
from sap_cloud_sdk.core.telemetry.metrics_decorator import record_metrics


@record_metrics(Module.BOOTSTRAP, Operation.BOOTSTRAP)
def bootstrap(app: Any, providers: Optional[List[ContextProvider]] = None) -> None:
"""Wire the SDK runtime context into your application framework.

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`.

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: Context providers to run on each request. Defaults to
``[IASContextProvider(), SAPTriggerContextProvider(), DWCContextProvider()]``.

Raises:
TypeError: If no registered adapter recognises *app*.

Example::

from sap_cloud_sdk import bootstrap

bootstrap(app) # IAS + SAP trigger + DWC by default

# custom providers:
bootstrap(app, providers=[IASContextProvider(), MyProvider()])
"""
if not providers:
providers = [
IASContextProvider(),
SAPTriggerContextProvider(),
DWCContextProvider(),
]

for adapter in get_registry():
if adapter.matches(app):
adapter.attach(app, providers)
return

raise TypeError(
f"bootstrap() does not recognise app type {type(app)!r}. "
"Supported frameworks are determined by registered FrameworkAdapters. "
"For other frameworks, register a FrameworkAdapter or attach the middleware manually."
)
63 changes: 63 additions & 0 deletions src/sap_cloud_sdk/core/runtime_context/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""SDK-wide runtime context for the current execution.

Lets SDK modules read caller-identity information (tenant, user, trigger type)
without knowing about the invocation source — HTTP, gRPC, message queue, etc.

Wire once at startup::

from sap_cloud_sdk import bootstrap

bootstrap(app) # defaults to IASContextProvider + SAPTriggerContextProvider + DWCContextProvider

Then read anywhere::

from sap_cloud_sdk.core.runtime_context import get_context, TENANT_ID, USER_ID

ctx = get_context()
ctx.get(TENANT_ID) # -> "abc-123" or None
ctx.get(USER_ID) # -> "user-uuid" or None
"""

from sap_cloud_sdk.core.runtime_context._context import (
RuntimeContext,
get_context,
)
from sap_cloud_sdk.core.runtime_context._envelope import RequestEnvelope
from sap_cloud_sdk.core.runtime_context._keys import (
ContextKey,
DWC_SUBDOMAIN,
DWC_TENANT,
TRIGGER_TYPE,
)
from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider
from sap_cloud_sdk.core.runtime_context._registry import FrameworkAdapter, register
from sap_cloud_sdk.core.runtime_context.providers import (
DWCContextProvider,
IASContextProvider,
SAPTriggerContextProvider,
GLOBAL_TENANT_ID,
TENANT_ID,
USER_ID,
)

# Register built-in framework adapters (guarded so missing extras don't break the import).
import sap_cloud_sdk.core.runtime_context.adapters # noqa: F401

__all__ = [
"ContextKey",
"ContextProvider",
"DWC_SUBDOMAIN",
"DWC_TENANT",
"DWCContextProvider",
"FrameworkAdapter",
"GLOBAL_TENANT_ID",
"IASContextProvider",
"RuntimeContext",
"RequestEnvelope",
"SAPTriggerContextProvider",
"TENANT_ID",
"TRIGGER_TYPE",
"USER_ID",
"get_context",
"register",
]
86 changes: 86 additions & 0 deletions src/sap_cloud_sdk/core/runtime_context/_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""ContextVar backing store for the SDK runtime context."""

from contextlib import asynccontextmanager, contextmanager
from contextvars import ContextVar
from typing import Any, AsyncGenerator, Dict, Generator, Optional, TypeVar

from sap_cloud_sdk.core.runtime_context._keys import ContextKey

T = TypeVar("T")


class RuntimeContext:
"""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`
to produce a new context with an additional entry.

Example::

MY_KEY = ContextKey[str]("my_key")

ctx = RuntimeContext({MY_KEY: "hello"})
ctx.get(MY_KEY) # -> "hello"
"""

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) -> "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."""
return dict(self._values)

def __repr__(self) -> str:
pairs = ", ".join(f"{k.name}={v!r}" for k, v in self._values.items())
return f"RuntimeContext({{{pairs}}})"


_EMPTY = RuntimeContext()

_context_var: ContextVar[RuntimeContext] = ContextVar(
"sap_sdk_request_context", default=_EMPTY
)


def set_context(ctx: RuntimeContext) -> None:
"""Set the runtime context for the current async/thread scope."""
_context_var.set(ctx)


def get_context() -> RuntimeContext:
"""Return the runtime context for the current async/thread scope.

Returns an empty :class:`RuntimeContext` when no context has been set.
"""
return _context_var.get()


@contextmanager
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:
yield ctx
finally:
_context_var.reset(token)


@asynccontextmanager
async def async_sdk_context(
ctx: RuntimeContext,
) -> AsyncGenerator[RuntimeContext, 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)
24 changes: 24 additions & 0 deletions src/sap_cloud_sdk/core/runtime_context/_envelope.py
Original file line number Diff line number Diff line change
@@ -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:
"""Framework-agnostic view of an inbound request passed to providers.

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: 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)
body: Optional[bytes] = field(default=None)
metadata: Dict[str, Any] = field(default_factory=dict)
39 changes: 39 additions & 0 deletions src/sap_cloud_sdk/core/runtime_context/_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Typed context key for RuntimeContext."""

from typing import Generic, TypeVar

T = TypeVar("T")


class ContextKey(Generic[T]):
"""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:`RuntimeContext.get`.

Keys use object identity for lookup — two ``ContextKey`` instances with the
same name are **different keys**. Always import the canonical key from the
module that defined it; never create a second instance with the same name.

Example::

MY_KEY = ContextKey[str]("my_key")

ctx = RuntimeContext({MY_KEY: "value"})
ctx.get(MY_KEY) # -> "value"

other = ContextKey[str]("my_key")
ctx.get(other) # -> None (different key object)
"""

def __init__(self, name: str) -> None:
self.name = name

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")
DWC_SUBDOMAIN = ContextKey[str]("dwc_subdomain")
DWC_TENANT = ContextKey[str]("dwc_tenant")
27 changes: 27 additions & 0 deletions src/sap_cloud_sdk/core/runtime_context/_protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""ContextProvider protocol — the pluggable extraction interface."""

from typing import Protocol, runtime_checkable

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:`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.

Example::

MY_KEY = ContextKey[str]("my_key")

class MyProvider(ContextProvider):
def extract(self, envelope: RequestEnvelope) -> RuntimeContext:
value = envelope.headers.get("x-my-header", "")
return RuntimeContext({MY_KEY: value} if value else {})
"""

def extract(self, envelope: RequestEnvelope) -> RuntimeContext: # pragma: no cover
...
Loading
Loading