Skip to content

Commit fe8134d

Browse files
committed
feat(cbc): add CBC (Central Business Configuration) client module
Typed Python client for reading tenant-specific business configuration from SAP Central Business Configuration. Supports mTLS (production), local/mock (loopback auto-detection), and HTTPS mock servers via the CLOUD_SDK_CBC_REPLACE_SUBDOMAIN env var override. Public API: create_client(), CBCClient protocol, DefaultClient, CBCConfig, ConfigData / ConfigObject / EntityData / EntityContent, ConsumptionVersions, and a full CBC exception hierarchy.
1 parent c85b580 commit fe8134d

19 files changed

Lines changed: 2083 additions & 0 deletions

‎src/sap_cloud_sdk/cbc/__init__.py‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""SAP Cloud SDK for Python — CBC (Central Business Configuration) module.
2+
3+
Provides a typed Python client for reading tenant-specific business configuration
4+
from SAP Central Business Configuration (CBC).
5+
6+
CBC is an SAP service that manages tenant-specific business configuration for
7+
SAP cloud applications and AI agents.
8+
9+
Quick start::
10+
11+
from sap_cloud_sdk.cbc import create_client, TenantContext
12+
13+
client = create_client()
14+
config = client.get_configuration(
15+
TenantContext(cbcTenantId="my-cbc-tenant", appTenantId="my-app-tenant")
16+
)
17+
18+
# Access entity data
19+
payment = config.get_config_object("payment-config")
20+
if payment:
21+
for row in payment.get_entity("payment-mode").data.as_list():
22+
print(row)
23+
24+
Local / mock server — no credentials needed::
25+
26+
from sap_cloud_sdk.cbc import DefaultClient, TenantContext
27+
28+
client = DefaultClient(base_url="http://localhost:8001")
29+
config = client.get_configuration(
30+
TenantContext(cbcTenantId="t1", appTenantId="app-t1")
31+
)
32+
"""
33+
34+
from __future__ import annotations
35+
36+
from sap_cloud_sdk.cbc.client import (
37+
CBCClient,
38+
DefaultClient,
39+
create_client,
40+
)
41+
from sap_cloud_sdk.cbc.config import CBCConfig
42+
from sap_cloud_sdk.cbc.exceptions import (
43+
CBCError,
44+
CBCClientError,
45+
CBCConfigError,
46+
CBCHttpError,
47+
CBCNetworkError,
48+
CBCServerError,
49+
HttpContext,
50+
)
51+
from sap_cloud_sdk.cbc._models import (
52+
ApiError,
53+
ConfigData,
54+
ConfigObject,
55+
ConsumptionVersion,
56+
ConsumptionVersions,
57+
EntityContent,
58+
EntityData,
59+
NNV,
60+
TenantContext,
61+
)
62+
63+
64+
__all__ = [
65+
# factories
66+
"create_client",
67+
# clients
68+
"CBCClient",
69+
"DefaultClient",
70+
# config
71+
"CBCConfig",
72+
# exceptions
73+
"CBCError",
74+
"CBCClientError",
75+
"CBCConfigError",
76+
"CBCHttpError",
77+
"CBCNetworkError",
78+
"CBCServerError",
79+
"HttpContext",
80+
# models — context
81+
"TenantContext",
82+
# models — consumption versions
83+
"ConsumptionVersion",
84+
"ConsumptionVersions",
85+
"NNV",
86+
# models — entities
87+
"EntityContent",
88+
"EntityData",
89+
"ConfigObject",
90+
# models — configuration
91+
"ConfigData",
92+
# models — api error
93+
"ApiError",
94+
]

‎src/sap_cloud_sdk/cbc/_http.py‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Low-level HTTP transport for the CBC (Central Business Configuration) module.
2+
3+
Provides:
4+
- :func:`_is_local_url` — detects loopback URLs that skip mTLS and subdomain routing.
5+
- :class:`_LazyCertTransport` — httpx transport that defers mTLS cert loading
6+
until the first real connection, so clients can be constructed with cert data
7+
that has not yet been written to disk.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import contextlib
13+
import os
14+
import ssl
15+
16+
import httpx
17+
18+
19+
def _is_local_url(url: str) -> bool:
20+
"""Return ``True`` when *url* targets a loopback address.
21+
22+
Loopback addresses (``http://localhost``, ``http://127.0.0.1``,
23+
``http://[::1]``) bypass mTLS and subdomain-per-tenant routing — they
24+
point directly at a mock or local dev server.
25+
26+
Args:
27+
url: Base URL to test.
28+
29+
Returns:
30+
``True`` if the URL targets a loopback address, ``False`` otherwise.
31+
"""
32+
lower = url.lower()
33+
return (
34+
lower.startswith("http://localhost")
35+
or lower.startswith("http://127.0.0.1")
36+
or lower.startswith("http://[::1]")
37+
)
38+
39+
40+
class _LazyCertTransport(httpx.BaseTransport):
41+
"""httpx transport that defers ``ssl.SSLContext.load_cert_chain`` until first use.
42+
43+
Cert files are not validated at construction time — the chain is loaded once,
44+
lazily, before the first real HTTP connection. This allows :class:`DefaultClient`
45+
to be instantiated with cert paths that are written after construction (e.g. in
46+
tests), and avoids I/O at import time.
47+
48+
Args:
49+
cert_file: Path to the PEM-encoded client certificate file.
50+
key_file: Path to the PEM-encoded private key file.
51+
delete_after_load: When ``True``, both files are deleted from disk after
52+
the cert chain is loaded. Use for temporary files written from
53+
in-memory PEM strings.
54+
"""
55+
56+
def __init__(
57+
self, cert_file: str, key_file: str, *, delete_after_load: bool = False
58+
) -> None:
59+
self._cert_file = cert_file
60+
self._key_file = key_file
61+
self._delete_after_load = delete_after_load
62+
self._inner: httpx.HTTPTransport | None = None
63+
self._files_deleted = False
64+
65+
def _ensure_inner(self) -> httpx.HTTPTransport:
66+
if self._inner is None:
67+
ctx = ssl.create_default_context()
68+
ctx.load_cert_chain(certfile=self._cert_file, keyfile=self._key_file)
69+
if self._delete_after_load:
70+
os.unlink(self._cert_file)
71+
os.unlink(self._key_file)
72+
self._files_deleted = True
73+
self._inner = httpx.HTTPTransport(verify=ctx)
74+
return self._inner
75+
76+
def handle_request(self, request: httpx.Request) -> httpx.Response:
77+
return self._ensure_inner().handle_request(request)
78+
79+
def close(self) -> None:
80+
if self._delete_after_load and not self._files_deleted:
81+
with contextlib.suppress(OSError):
82+
os.unlink(self._cert_file)
83+
with contextlib.suppress(OSError):
84+
os.unlink(self._key_file)
85+
self._files_deleted = True
86+
if self._inner is not None:
87+
self._inner.close()

0 commit comments

Comments
 (0)