Repository navigation
Conversation
c3dc18a to
c62dfa8
Compare
afaa6d7 to
07e74c2
Compare
Reshape the public surface to match the agentgateway module, per PR SAP#333 review feedback: - single create_client(config: CBCConfig | None = None) -> CBCClient; with no config it infers everything from provisioning, a CBCConfig overrides individual pieces - one concrete CBCClient (drop the CBCClient Protocol / DefaultClient split) - remove create_agent_client and the client_adapter.py layer - read tenant identity from the shared sap_cloud_sdk.core.runtime_context (APP_TENANT_ID, DWC_SUBDOMAIN) instead of module-local ContextVars - CBC URL + mTLS cert resolved via sap_cloud_sdk.destination (tenant-mapping Fragment + Destination Service), same primitives agentgateway uses - default mTLS loader applies only in pure provisioning mode; overriding base_url or app_tenant_id leaves ssl_context unset => plain HTTPS uv.lock is regenerated to sync sap-cloud-sdk to 0.59.0 (matching pyproject.toml) and realign the lockfile revision to origin/main (3).
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.
…h params Replace the cert tuple parameter with symmetric cert_path/key_path params. Add CLOUD_SDK_CBC_CERT / CLOUD_SDK_CBC_KEY env vars so PEM values can be supplied directly (e.g. from K8s secrets) without writing to disk first — create_client() handles the temp-file lifecycle automatically.
…nts, version bump - Bump version to 0.54.0 (required by CI for src/ changes) - Fix ruff format violations in _models.py and client.py - Fix ty errors: conftest fixture return type CBCClient, test_models assert-not-None before .version - Update test_module (15→16) and test_operation (161→163) counts for CBC module/operations
- Soften "do not instantiate" to "prefer create_client" - Replace contradictory direct-instantiation examples with create_client usage - Reference BTP Destination Service and env vars as credential sources - Add tmp/ to .gitignore
`_is_local_url` auto-disabled subdomain replacement for loopback URLs. Replace with an explicit opt-out: `replace_subdomain` now defaults to `True`; consumers set `CLOUD_SDK_CBC_REPLACE_SUBDOMAIN=false` when pointing at a local mock server. - Remove `_is_local_url` from `_http.py` - Default `replace_subdomain` to `True` in `DefaultClient.__init__` - Update `config.py` and `user-guide.md` to document the env-var escape hatch - Remove `TestDefaultClientLocalMode` and related tests
Binds `TenantContext | Callable[[], TenantContext]` at construction time instead of per-call. The callable form supports multi-tenant agents where the tenant varies per request (e.g. read from a request-scoped context var). - `DefaultClient.__init__` and `create_client` gain `tenant_context` param - `get_consumption_versions` and `get_configuration` drop the param - `CBCClient` Protocol updated to match - Integration conftest bakes tenant into the client fixture - Tests cover callable invocation count and missing-tenant error
Redesign the CBC client around a generic core and a platform adapter layered strictly on top of it. Core (client.py): base_url and app_tenant_id are per-request callables; the only credential input is an ssl.SSLContext. create_client is a thin factory. Drops TenantContext, config.py, _http.py, and all env/cert-file machinery. Platform adapter (client_adapter.py): ships the SAP application-platform provisioning defaults. The SDK owns two ContextVars (app_tenant_id_var, tenant_subdomain_var) that the app populates; create_agent_client resolves base_url from the tenant-mapping Destination Fragment (listing the subaccount and matching on appTenantId, pre-PR-SAP#79 shape) and loads the provider mTLS cert from the Destination Service. Every default is overridable via args or CLOUD_SDK_CBC_* env vars. CBC unit coverage 98% (adapter 100%).
get_configuration previously re-invoked the base_url callable on every HTTP request (consumption versions + config objects + one per entity), so a single call triggered 2+N tenant-mapping fragment lookups in the platform adapter. Resolve base_url once at the top of the public call and thread the resolved string into the internal methods (_get_configuration_objects, _fetch_entity_data, _configurations_url), mirroring how app_tenant_id is already threaded. One operation now does one base_url resolution. Also fix incoherent unit-test data: agent-config no longer holds restaurant/contact/hours entities; replaced with tax-config / tax-category / tax-rate to match the finance-domain examples used elsewhere.
The adapter's _resolve_base_url and _load_ssl_context call Destination Service APIs that can raise DestinationError, which leaked past the documented CBCConfigError contract. Wrap both in try/except, re-raising as CBCConfigError with a descriptive message and chained cause.
The adapter loaded the mTLS certificate once in create_agent_client and baked it into the httpx.Client at construction, so a rotated or expired certificate killed a long-lived client until the process restarted — violating the "Credential Binding Rotation" guideline that every module reading credentials must recover from rotation. Make the core reload reactively. ssl_context becomes a Callable[[], ssl.SSLContext] | None, resolved once at construction and re-invoked only when a request fails the TLS handshake. On such a failure the client rebuilds its httpx.Client from a fresh context under a lock (guarding against a thundering herd) and retries the one request once; a second failure, a non-TLS transport error, or a failing rebuild (the cert loader raises CBCConfigError) propagates cleanly, leaving the previous working client in place. TLS failures are detected by walking the exception's __cause__/__context__ chain for ssl.SSLError, since httpx surfaces an expired client cert as ReadError wrapping ssl.SSLError two levels down. Mirrors objectstore's _execute_with_retry rotation pattern. create_agent_client drops its ssl_context parameter and now owns cert resolution end-to-end, passing a lambda: load_ssl_context(...) factory to the core. Expose the three platform resolvers as public composition helpers (resolve_base_url, resolve_app_tenant_id, load_ssl_context) at the top level, so a caller can keep most of the platform preset but override a single axis via create_client instead of reimplementing the resolvers. Also: - resolve base_url + app_tenant_id once per get_configuration on the auto-version path (extract _get_consumption_versions taking resolved values), avoiding a double resolve that could also disagree if the request context changed between the two. - rename _build_client/_rebuild_client/self._client to the _http_client forms, so names disambiguate the httpx client from the CBC client. - build the client with verify=ctx if ctx is not None else True, making it explicit that TLS verification is never disabled.
The pre-commit ty hook checks tests/ (unlike an src-only ty run), which surfaced 10 diagnostics in the CBC test suite. test_client_adapter.py: create_agent_client() returns the CBCClient Protocol, which has no private members, so accessing _ssl_factory / _base_url / _resolve_app_tenant_id failed. Narrow to DefaultClient with an isinstance assert before touching privates, and guard the optional _ssl_factory before calling it. test_client.py: orig_build = client._build_http_client is typed () -> httpx.Client, so orig_build() was Client, not MagicMock, breaking the .request wiring and the -> MagicMock return annotations. Align the build helpers' return types with the method they replace (httpx.Client), cast() the mock where .request is wired, and replace the ineffective mypy # type: ignore[method-assign] with the repo's ty idiom # ty: ignore[invalid-assignment].
Replace the three loose keyword args on create_agent_client (destination_instance, cbc_cert_name, p12_password) with a single CBCDestinationConfig settings object, addressing the PR review request for a config object. The object lives in a new cbc/config.py, matching the dedicated-config.py convention of the agw, adms, print, and destination modules. Per-field env overrides are unchanged: a value set on the config wins over its CLOUD_SDK_CBC_* env var, which in turn falls back to the platform default. Scoped to the adapter layer only — the core create_client and its hardcoded timeout are untouched; a core config object is deferred. Also rename the private _ClientConfig (API-path holder) to _ApiPaths so it no longer reads as a near-homonym of the new public config class.
EntityContent (the raw payload wrapper) → EntityData: names the thing it actually holds. EntityData (the entity container with entity_id + data) → ConfigEntity: scoped to the config layer, mirrors get_config_object naming, and removes ambiguity with the new EntityData. Also rename the two accessor methods for consistency: ConfigObject.get_entity → get_config_entity ConfigData.get_entity_data → get_config_entity Updated across _models.py, client.py, __init__.py, user-guide.md, and test_models.py. No behaviour change.
REST API team confirmed: - metadata.entityId is the correct field name (was entityName) - content.item / content.items struct is correct (already was) - ?appTenantId= query param is correct (already was) Since metadata.entityId equals the entity_id already known from the URL path, drop the metadata read entirely — entity_id is used directly. Remove the now-redundant resolved_id fallback and the deleted test_falls_back_to_path_entity_id_without_metadata test. Strip stale metadata fields from remaining mock response bodies.
… to 0.59.0 Fix stale EntityData return annotation on TestConfigData._entity_data — missed during the EntityData → ConfigEntity rename. Also bump version to 0.59.0 (upstream released 0.58.0).
Exposes _fetch_entity_data as a public client method get_entity_data(), matching the Java SDK's getEntityData() for fetching one entity without pulling all configuration objects. Also renames ConfigObject/ConfigData .get_config_entity() → .get_entity_data() returning EntityData directly, removing the ConfigEntity wrapper the caller had to unwrap every time.
- Add Args/Returns/Raises to all three CBCClient Protocol methods (get_consumption_versions, get_configuration, get_entity_data) - Rename all test methods across test_client.py, test_models.py, and test_client_adapter.py to the documented test_<func>_<condition>_<result> pattern (pre-existing violations + 3 new from the prior commit) - Strengthen ConfigData.get_entity_data test: fixture now gives each entity distinct data so a wrong-entity selection bug would fail the assertion - Add BDD integration scenario for get_entity_data (CONTRIBUTING.md requires integration tests for new capabilities)
…AP#82 Fragment naming in the platform changed from CBC_TenantMapping_<cbcTenantId> to CBC_TenantMapping_<tenantSubdomain> (sap-internal-sdk-python#82), enabling a direct get_subaccount_fragment lookup by subdomain instead of listing all fragments and filtering by appTenantId. Update resolve_base_url to use the single direct lookup, and update all five TestResolveBaseUrl unit tests to mock get_subaccount_fragment accordingly.
Reshape the public surface to match the agentgateway module, per PR SAP#333 review feedback: - single create_client(config: CBCConfig | None = None) -> CBCClient; with no config it infers everything from provisioning, a CBCConfig overrides individual pieces - one concrete CBCClient (drop the CBCClient Protocol / DefaultClient split) - remove create_agent_client and the client_adapter.py layer - read tenant identity from the shared sap_cloud_sdk.core.runtime_context (APP_TENANT_ID, DWC_SUBDOMAIN) instead of module-local ContextVars - CBC URL + mTLS cert resolved via sap_cloud_sdk.destination (tenant-mapping Fragment + Destination Service), same primitives agentgateway uses - default mTLS loader applies only in pure provisioning mode; overriding base_url or app_tenant_id leaves ssl_context unset => plain HTTPS uv.lock is regenerated to sync sap-cloud-sdk to 0.59.0 (matching pyproject.toml) and realign the lockfile revision to origin/main (3).
056c750 to
d072f10
Compare
Rework the CBC integration harness to match agentgateway: instead of overriding base_url + app_tenant_id and loading the mTLS cert from local PEM files, supply the Destination Service binding and let cbc.create_client() resolve the CBC URL (tenant-mapping Fragment) and the mTLS certificate (Destination Service) itself. This exercises the real default provisioning path rather than the override path. The conftest seeds the SDK runtime context with the subscriber tenant id and subdomain (what bootstrap(app) populates from a request), then calls create_client() with no overrides. Harness env vars follow agw's CLOUD_SDK_CFG_<MODULE>_DEFAULT_<FIELD> convention: CLOUD_SDK_CFG_DESTINATION_DEFAULT_* (binding, shared SDK vars) CLOUD_SDK_CFG_CBC_DEFAULT_APP_TENANT_ID CLOUD_SDK_CFG_CBC_DEFAULT_TENANT_SUBDOMAIN CLOUD_SDK_CFG_CBC_DEFAULT_LANDSCAPE Tests skip cleanly when the binding or tenant values are absent.
| config = cbc_client.get_configuration() | ||
|
|
||
| # Access entity data | ||
| payment = config.get_config_object("payment-config") |
There was a problem hiding this comment.
| payment = config.get_config_object("payment-config") | |
| payment = config.get_configuration_object("payment-config") |
There was a problem hiding this comment.
Rename cbc_client.get_configuration() to be config or the other config to configuration.
There was a problem hiding this comment.
Done, and applied the same rename across the whole surface for consistency. All "config" abbreviations are expanded to "configuration".
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| class NNV(_FrozenModel): |
There was a problem hiding this comment.
Is this indeed the name of this on CBC?
There was a problem hiding this comment.
Dropped it instead - it was included in CBC API response, but not relevant for the SDK.
NNV stands for Namespace-Name-Version BTW. But anyway, it's gone from the SDK now.
| entities: Entities belonging to this config object. | ||
| """ | ||
|
|
||
| config_object_id: str = Field(alias="configurationObjectId") |
There was a problem hiding this comment.
Consider this as optional
| from sap_cloud_sdk.destination import AccessStrategy, create_fragment_client | ||
| from sap_cloud_sdk.destination.exceptions import DestinationError | ||
|
|
||
| subdomain = get_context().get(DWC_SUBDOMAIN) |
There was a problem hiding this comment.
We should't replicate that since this is already set in the runtime context. Can we somehow reuse?
There was a problem hiding this comment.
This line already is the runtime context - get_context().get(DWC_SUBDOMAIN) reads the shared sap_cloud_sdk.core.runtime_context, as documented in sap_cloud_sdk/core/runtime_context/user-guide.md.
… NNV Address review feedback on PR SAP#333: - Rename the business-config vocabulary to match the CBC API's own `configuration` wording: ConfigObject→ConfigurationObject, ConfigData→ConfigurationData, ConfigEntity→ConfigurationEntity (and the internal ConfigurationObjectEntity/Entry/List), get_config_object→ get_configuration_object, and the config_object_id / config_objects fields and parameters. Test identifiers renamed to match. - CBCConfig and CBCConfigError keep the short form — they are SDK client configuration (create_client overrides / setup errors), not CBC's managed business configuration. - Drop the NNV model and the ConsumptionVersion.ref_content field it backed: parsed from the API response but never read anywhere. 63 unit tests pass; integration tests verified against a live CBC tenant.
Description
Adds
sap_cloud_sdk.cbc— a typed Python client for reading tenant-specific business configuration from SAP Central Business Configuration (CBC). The public surface follows the same shape as theagentgatewaymodule: a singlecreate_client(config=None)factory returning a concreteCBCClient. Called with no arguments it infers everything from the application-platform provisioning conventions — the app tenant id and subscriber subdomain from the SDK-wide runtime context (sap_cloud_sdk.core.runtime_context, populated bybootstrap(app)), the CBC URL from the tenant-mapping Destination Fragment, and the mTLS certificate from the Destination Service. An optionalCBCConfigoverrides any individual piece (e.g. local development or tests). The mTLS certificate is reloaded automatically when a request fails the TLS handshake (certificate rotation/expiry), so a long-lived client recovers without being recreated. Includes a full exception hierarchy and plain-dataclass models.Related Issue
Closes #280
Type of Change
How to Test
Unit tests (no external service required):
Integration tests (requires a provisioned CBC tenant, reachable via the Destination Service):
They exercise the default provisioning path —
cbc.create_client()with no overrides — the same way theagentgatewayintegration tests drivecreate_client. Supply the Destination Service binding (standardCLOUD_SDK_CFG_DESTINATION_DEFAULT_*vars) plus the target tenant, then run:The conftest also loads these from a
.env_integration_testsfile at the repo root if present (same convention as theagentgatewaytests). All of the above are integration-harness env vars (read only by the test conftest), not part of the SDK's public API. TheCLOUD_SDK_CFG_CBC_DEFAULT_*values seed the SDK runtime context and derive the mTLS certificate name; the CBC URL and the certificate itself are resolved from the Destination Service at run time.Expected result: 63 unit tests pass; integration tests skip automatically when the binding or tenant env vars are absent (CI-safe).
Checklist
Breaking Changes
None. This is a new module with no existing public API.
Additional Notes
Module structure follows the repo convention (
client.py,config.py,exceptions.py,_models.py,py.typed,user-guide.md).Key design decisions:
agentgateway:create_client(config: CBCConfig | None = None) -> CBCClient. Called with no arguments it infers everything from provisioning; an optionalCBCConfigoverrides individual pieces. There is one concreteCBCClient— noProtocol, noDefaultClient/adapter split, no secondcreate_agent_clientfactory. (Earlier revisions had all three; they are removed.)CBCConfig(incbc/config.py, matching the dedicated-config.pyconvention of agw, adms, print, and destination) carries six optional fields. Three replace a whole default resolver —base_url,app_tenant_id(eachstr | Callable[[], str]), andssl_context(Callable[[], ssl.SSLContext]); three steer the default Destination lookup —destination_instance,cbc_cert_name,p12_password. A value set on the config wins over itsCLOUD_SDK_CBC_*env var, which in turn falls back to the platform default.sap_cloud_sdk.destinationthe same way agw does, and tenant identity via the SDK-widesap_cloud_sdk.core.runtime_context(no module-localContextVars): the subscriber subdomain (DWC_SUBDOMAIN) selects the tenant-mapping Destination Fragment (CBC_TenantMapping_<subdomain>, fetched by name) whosecbcUrlis the base URL; the application tenant id (APP_TENANT_ID, the IASapp_tidclaim) is sent as theappTenantIdquery parameter; the provider-level mTLS certificate is loaded from the Destination Service. Nothing CBC-specific is set per request —bootstrap(app)populates the runtime context once.base_urlorapp_tenant_id(e.g. pointing at a local mock), an unsetssl_contextmeans plain HTTPS (public CA bundle, no client cert), so a pro-code / local-dev caller is not forced to set the platform landscape env or carry a certificate; they setssl_contextexplicitly if they still want mTLS.ssl_contextfactory is resolved once at construction and re-invoked only when a request fails the TLS handshake (an expired/rotated client cert surfaces asReadErrorwrappingssl.SSLError, detected by walking the exception cause chain). On such a failure the client rebuilds itshttpx.Clientfrom a fresh context under a lock and retries the request once; a second failure, a non-TLS transport error, or a failing rebuild propagates cleanly, leaving the previous working client in place. A long-livedcreate_client()singleton therefore recovers from rotation on its own. Mirrors the objectstore_execute_with_retryrotation pattern.get_configurationreads the config objects from the CBCconfigurationObjectsAPI — which already returns each config object with its child entities — so consumers work with the authored config-object vocabulary without any client-side grouping. The per-requestbase_url/app_tenant_idresolvers are each invoked once per public call and threaded into the internal requests, so a singleget_configurationtriggers onebase_urlresolution (one tenant-mapping fragment lookup), not one per entity. Entity data is read from the confirmed API shape:contentShapedrives whethercontent.item(OBJECT) orcontent.items(ARRAY) is used;entity_idcomes from the URL path directly (confirmed equal tometadata.entityId).get_entity_data(config_object_id, entity_id, consumption_version=None)is a targeted single-entity HTTP call, matching the Java SDK'sgetEntityData(). It avoids the N+1 HTTP callsget_configurationmakes (one per entity across all config objects) when only one entity is needed. Whenconsumption_versionisNone, the latest version is resolved automatically (2 HTTP calls total); when pinned, only 1.ConfigObject.get_entity_data(entity_id)andConfigData.get_entity_data(config_object_id, entity_id)provide the same lookup in-memory on an already-fetched result, returningEntityData | Nonedirectly (no.dataunwrap needed).ConfigEntity,EntityData,ConfigObject,ConfigDataare plain@dataclass(not Pydantic) — they are constructed in client code, never parsed from JSON.@record_metrics; internal helpers do not, to avoid double-counting a single user operation.Test evidence:
63 passed, 1 warning
Integration: 6 passed in 33.96s (real CBC server, default provisioning path)
Run via the default provisioning path —
cbc.create_client()with no overrides, resolving the CBC URL from the tenant-mapping Destination Fragment and the mTLS certificate from the Destination Service (binding supplied through the standardCLOUD_SDK_CFG_DESTINATION_DEFAULT_*env vars, same as the agentgateway integration tests).Sample ConfigData response (real CBC server)
{ "consumption_version": "a0392d4f-...", "app_tenant_id": "<app-tenant-id>", "config_objects": [ { "config_object_id": "payment-config", "entities": [ { "entity_id": "payment-mode", "data": [ { "paymentModeCode": "CASH", "name": "Cash", "isOnline": false }, { "paymentModeCode": "CARD", "name": "Credit / Debit Card", "isOnline": false }, { "paymentModeCode": "DIGITAL_WALLET","name": "Digital Wallet", "isOnline": true } ] } ] }, { "config_object_id": "tax-config", "entities": [ { "entity_id": "tax-category", "data": [ { "code": "STD", "ratePercent": 8.5, "isDefault": true }, { "code": "REDUCED", "ratePercent": 5, "isDefault": false }, { "code": "ZERO", "ratePercent": 0, "isDefault": false } ] } ] } ] }