Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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"]
51 changes: 51 additions & 0 deletions src/sap_cloud_sdk/core/bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""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 HeaderContextProvider, IASContextProvider


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(), HeaderContextProvider()]``.

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

Example::

from sap_cloud_sdk import bootstrap

bootstrap(app) # IAS + SAP headers by default

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

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."
)
54 changes: 54 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,54 @@
"""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 + HeaderContextProvider

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, 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 (
HeaderContextProvider,
IASContextProvider,
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",
"FrameworkAdapter",
"GLOBAL_TENANT_ID",
"HeaderContextProvider",
"IASContextProvider",
"RuntimeContext",
"RequestEnvelope",
"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)
30 changes: 30 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,30 @@
"""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`.

Example::

MY_KEY = ContextKey[str]("my_key")

ctx = RuntimeContext({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})"


# SDK-standard keys — not tied to any specific auth provider.
TRIGGER_TYPE = ContextKey[str]("trigger_type")
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
...
55 changes: 55 additions & 0 deletions src/sap_cloud_sdk/core/runtime_context/_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Framework adapter base class and registry for bootstrap()."""

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):
"""Connects a framework or invocation source to the SDK runtime context.

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.

Example::

class FlaskContextAdapter(FrameworkAdapter):
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())
"""

def matches(self, app) -> bool:
"""Return True if this adapter handles *app*'s framework type."""
try:
return self._matches(app)
except ImportError:
return False

@abstractmethod
def _matches(self, app) -> bool: ...

@abstractmethod
def attach(self, app, providers: List[ContextProvider]) -> None: ...
6 changes: 6 additions & 0 deletions src/sap_cloud_sdk/core/runtime_context/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Built-in framework adapters."""

try:
from sap_cloud_sdk.core.runtime_context.adapters import _starlette # noqa: F401
except ImportError:
pass
23 changes: 23 additions & 0 deletions src/sap_cloud_sdk/core/runtime_context/adapters/_starlette.py
Original file line number Diff line number Diff line change
@@ -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())
17 changes: 17 additions & 0 deletions src/sap_cloud_sdk/core/runtime_context/providers/__init__.py
Original file line number Diff line number Diff line change
@@ -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 (
GLOBAL_TENANT_ID,
IASContextProvider,
TENANT_ID,
USER_ID,
)

__all__ = [
"GLOBAL_TENANT_ID",
"HeaderContextProvider",
"IASContextProvider",
"TENANT_ID",
"USER_ID",
]
Loading
Loading