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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ env.bak/
venv.bak/
piperBuild-env/

# Local scratch
tmp/

# IDEs
.vscode/
.idea/
Expand Down
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.53.1"
version = "0.54.0"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
94 changes: 94 additions & 0 deletions src/sap_cloud_sdk/cbc/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""SAP Cloud SDK for Python — CBC (Central Business Configuration) module.

Provides a typed Python client for reading tenant-specific business configuration
from SAP Central Business Configuration (CBC).

CBC is an SAP service that manages tenant-specific business configuration for
SAP cloud applications and AI agents.

Quick start::

from sap_cloud_sdk.cbc import create_client, TenantContext

client = create_client()
config = client.get_configuration(
TenantContext(cbcTenantId="my-cbc-tenant", appTenantId="my-app-tenant")
)

# Access entity data
payment = config.get_config_object("payment-config")
if payment:
for row in payment.get_entity("payment-mode").data.as_list():
print(row)

Local / mock server — no credentials needed::

from sap_cloud_sdk.cbc import DefaultClient, TenantContext

client = DefaultClient(base_url="http://localhost:8001")
config = client.get_configuration(
TenantContext(cbcTenantId="t1", appTenantId="app-t1")
)
"""

from __future__ import annotations

from sap_cloud_sdk.cbc.client import (
CBCClient,
DefaultClient,
create_client,
)
from sap_cloud_sdk.cbc.config import CBCConfig
from sap_cloud_sdk.cbc.exceptions import (
CBCError,
CBCClientError,
CBCConfigError,
CBCHttpError,
CBCNetworkError,
CBCServerError,
HttpContext,
)
from sap_cloud_sdk.cbc._models import (
ApiError,
ConfigData,
ConfigObject,
ConsumptionVersion,
ConsumptionVersions,
EntityContent,
EntityData,
NNV,
TenantContext,
)


__all__ = [
# factories
"create_client",
# clients
"CBCClient",
"DefaultClient",
# config
"CBCConfig",
# exceptions
"CBCError",
"CBCClientError",
"CBCConfigError",
"CBCHttpError",
"CBCNetworkError",
"CBCServerError",
"HttpContext",
# models — context
"TenantContext",
# models — consumption versions
"ConsumptionVersion",
"ConsumptionVersions",
"NNV",
# models — entities
"EntityContent",
"EntityData",
"ConfigObject",
# models — configuration
"ConfigData",
# models — api error
"ApiError",
]
87 changes: 87 additions & 0 deletions src/sap_cloud_sdk/cbc/_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Low-level HTTP transport for the CBC (Central Business Configuration) module.

Provides:
- :func:`_is_local_url` — detects loopback URLs that skip mTLS and subdomain routing.
- :class:`_LazyCertTransport` — httpx transport that defers mTLS cert loading
until the first real connection, so clients can be constructed with cert data
that has not yet been written to disk.
"""

from __future__ import annotations

import contextlib
import os
import ssl

import httpx


def _is_local_url(url: str) -> bool:
"""Return ``True`` when *url* targets a loopback address.

Loopback addresses (``http://localhost``, ``http://127.0.0.1``,
``http://[::1]``) bypass mTLS and subdomain-per-tenant routing — they
point directly at a mock or local dev server.

Args:
url: Base URL to test.

Returns:
``True`` if the URL targets a loopback address, ``False`` otherwise.
"""
lower = url.lower()
return (
lower.startswith("http://localhost")
or lower.startswith("http://127.0.0.1")
or lower.startswith("http://[::1]")
)


class _LazyCertTransport(httpx.BaseTransport):
"""httpx transport that defers ``ssl.SSLContext.load_cert_chain`` until first use.

Cert files are not validated at construction time — the chain is loaded once,
lazily, before the first real HTTP connection. This allows :class:`DefaultClient`
to be instantiated with cert paths that are written after construction (e.g. in
tests), and avoids I/O at import time.

Args:
cert_file: Path to the PEM-encoded client certificate file.
key_file: Path to the PEM-encoded private key file.
delete_after_load: When ``True``, both files are deleted from disk after
the cert chain is loaded. Use for temporary files written from
in-memory PEM strings.
"""

def __init__(
self, cert_file: str, key_file: str, *, delete_after_load: bool = False
) -> None:
self._cert_file = cert_file
self._key_file = key_file
self._delete_after_load = delete_after_load
self._inner: httpx.HTTPTransport | None = None
self._files_deleted = False

def _ensure_inner(self) -> httpx.HTTPTransport:
if self._inner is None:
ctx = ssl.create_default_context()
ctx.load_cert_chain(certfile=self._cert_file, keyfile=self._key_file)
if self._delete_after_load:
os.unlink(self._cert_file)
os.unlink(self._key_file)
self._files_deleted = True
self._inner = httpx.HTTPTransport(verify=ctx)
return self._inner

def handle_request(self, request: httpx.Request) -> httpx.Response:
return self._ensure_inner().handle_request(request)

def close(self) -> None:
if self._delete_after_load and not self._files_deleted:
with contextlib.suppress(OSError):
os.unlink(self._cert_file)
with contextlib.suppress(OSError):
os.unlink(self._key_file)
self._files_deleted = True
if self._inner is not None:
self._inner.close()
Loading
Loading