Skip to content

Commit 41395d1

Browse files
committed
feat: add consumption destination and fragment level
1 parent 3f8d49e commit 41395d1

8 files changed

Lines changed: 267 additions & 33 deletions

File tree

.claude/commands/release-prep.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
Prepare a release for the changes on the current branch. Do the following steps in order:
2+
3+
## Step 1 — Understand the changes
4+
5+
Run `git diff main...HEAD` and `git log main...HEAD --oneline` to understand what was changed on this branch. Read all modified source files to understand the nature of the changes (new feature, bug fix, breaking change, docs, etc.).
6+
7+
## Step 2 — Determine the new version
8+
9+
Read `pyproject.toml` to get the current version. Apply SemVer rules based on the changes:
10+
- PATCH bump: bug fixes, docs, refactors, non-breaking improvements
11+
- MINOR bump: new non-breaking features or new public API surface
12+
- MAJOR bump: breaking changes (changes to existing public API signatures or behavior)
13+
14+
Compute the new version string (no leading `v`).
15+
16+
## Step 3 — Bump version in pyproject.toml
17+
18+
Edit `pyproject.toml` and update `version = "..."` to the new version.
19+
20+
## Step 4 — Create PULL_REQUEST.md
21+
22+
Create `PULL_REQUEST.md` at the repo root following the structure of `.github/pull_request_template.md`. Fill it in based on what you learned from the diff:
23+
24+
- **Description**: clear summary of what changed and why
25+
- **Related Issue**: leave as `Closes #` with a note to fill in the issue number
26+
- **Type of Change**: check the relevant boxes (use `[x]`)
27+
- **How to Test**: concrete steps to verify the change works
28+
- **Checklist**: check all boxes that apply given the changes made
29+
- **Breaking Changes**: fill in if applicable, otherwise remove the section
30+
- **Additional Notes**: any relevant context for reviewers
31+
32+
> **Disclaimer:** Do not include SAP-internal or customer-specific information in this PR (e.g. internal system URLs, customer names, tenant IDs, or confidential configurations). This is a public repository.
33+
34+
## Step 5 — Create RELEASE.md
35+
36+
Create `RELEASE.md` at the repo root using this exact template structure:
37+
38+
```
39+
## [vX.Y.Z] - YYYY-MM-DD
40+
41+
### What's New
42+
- ...
43+
44+
### Improvements
45+
- ...
46+
47+
### Bug Fixes
48+
- ...
49+
50+
### Breaking Changes
51+
> ⚠️ **Important**: This section is critical for users upgrading from previous versions
52+
- **[Breaking Change]**: ...
53+
54+
### Contributors
55+
...
56+
```
57+
58+
Fill in the version and today's date. Remove sections that don't apply. Infer the content from the diff — be specific about what changed (class names, method names, parameter names) so that users upgrading know exactly what to update.
59+
60+
## Step 6 — Summarize
61+
62+
Tell the user:
63+
- The old and new version
64+
- Which files were created/updated
65+
- Any sections in PULL_REQUEST.md or RELEASE.md that still need manual input (e.g. issue number, contributors)

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,7 @@ test-results.*
3535

3636
# Local Mode
3737
mocks/
38+
39+
# Generated files
40+
PULL_REQUEST.md
41+
RELEASE.md

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "sap-cloud-sdk"
3-
version = "0.13.0"
3+
version = "0.14.0"
44
description = "SAP Cloud SDK for Python"
55
readme = "README.md"
66
license = "Apache-2.0"

src/sap_cloud_sdk/destination/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from sap_cloud_sdk.destination._models import (
3030
Destination,
3131
AuthToken,
32+
ConsumptionLevel,
3233
ConsumptionOptions,
3334
Fragment,
3435
Certificate,
@@ -208,6 +209,7 @@ def create_certificate_client(
208209
# Public types
209210
"Destination",
210211
"AuthToken",
212+
"ConsumptionLevel",
211213
"ConsumptionOptions",
212214
"Fragment",
213215
"Certificate",

src/sap_cloud_sdk/destination/_models.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,25 @@ class AccessStrategy(Enum):
8484
PROVIDER_FIRST = "PROVIDER_FIRST"
8585

8686

87+
class ConsumptionLevel(Enum):
88+
"""Level hint for the v2 consumption API (get_destination).
89+
90+
Appended as @level to the destination name or fragment name in the API request,
91+
allowing the caller to hint which scope to search.
92+
93+
Attributes:
94+
PROVIDER_SUBACCOUNT: Provider subaccount scope
95+
PROVIDER_INSTANCE: Provider service instance scope
96+
SUBACCOUNT: Subscriber subaccount scope
97+
INSTANCE: Subscriber service instance scope
98+
"""
99+
100+
PROVIDER_SUBACCOUNT = "provider_subaccount"
101+
PROVIDER_INSTANCE = "provider_instance"
102+
SUBACCOUNT = "subaccount"
103+
INSTANCE = "instance"
104+
105+
87106
class DestinationType(Enum):
88107
"""Destination type (subset of v1)."""
89108

@@ -412,6 +431,9 @@ class ConsumptionOptions:
412431
fragment_name: Name of the destination fragment used to override/extend destination
413432
properties (X-fragment-name). In case of overlapping properties, fragment values
414433
take priority.
434+
fragment_level: Level hint for the fragment lookup. When set, appended to the fragment
435+
name as @level (e.g., "my-fragment@provider_subaccount"). Only effective when
436+
fragment_name is also provided.
415437
fragment_optional: When True, if the fragment specified by fragment_name does not
416438
exist the destination is returned without it. When False (default), a missing
417439
fragment causes an error (X-fragment-optional).
@@ -488,6 +510,7 @@ class ConsumptionOptions:
488510
"""
489511

490512
fragment_name: Optional[str] = None
513+
fragment_level: Optional[ConsumptionLevel] = None
491514
fragment_optional: Optional[bool] = None
492515
tenant: Optional[str] = None
493516
user_token: Optional[str] = None

src/sap_cloud_sdk/destination/client.py

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from sap_cloud_sdk.destination._http import DestinationHttp, API_V1, API_V2
99
from sap_cloud_sdk.destination._models import (
1010
AccessStrategy,
11+
ConsumptionLevel,
1112
ConsumptionOptions,
1213
Destination,
1314
Label,
@@ -32,9 +33,6 @@
3233
_SUBACCOUNT_COLLECTION = "subaccountDestinations"
3334
_INSTANCE_COLLECTION = "instanceDestinations"
3435

35-
_SUBACCOUNT_LEVEL = "subaccount"
36-
_INSTANCE_LEVEL = "instance"
37-
3836

3937
class DestinationClient:
4038
"""Client for SAP Destination Service operations.
@@ -279,7 +277,7 @@ def get_subaccount_destination(
279277
def get_destination(
280278
self,
281279
name: str,
282-
level: Optional[Level] = None,
280+
level: Optional[ConsumptionLevel] = None,
283281
options: Optional[ConsumptionOptions] = None,
284282
proxy_enabled: Optional[bool] = None,
285283
tenant: Optional[str] = None,
@@ -296,8 +294,9 @@ def get_destination(
296294
297295
Args:
298296
name: Destination name.
299-
level: Optional level hint (subaccount or instance) to optimize lookup. If not
300-
provided, the API will search on instance level.
297+
level: Optional level hint to narrow the lookup scope. When provided, appended to
298+
the destination name as @level (e.g., "my-dest@provider_subaccount"). Supported
299+
values: PROVIDER_SUBACCOUNT, PROVIDER_INSTANCE, SUBACCOUNT, INSTANCE.
301300
options: Optional ConsumptionOptions controlling request headers sent to the
302301
Destination Service. See ConsumptionOptions for the full list of supported
303302
headers (fragment merging, token exchange, SAML, OAuth2 flows, chains, etc.).
@@ -324,7 +323,7 @@ def get_destination(
324323
dest = client.get_destination("my-api")
325324
326325
# With level hint
327-
dest = client.get_destination("my-api", level=Level.SERVICE_INSTANCE)
326+
dest = client.get_destination("my-api", level=ConsumptionLevel.PROVIDER_SUBACCOUNT)
328327
329328
# Fragment merging
330329
dest = client.get_destination("my-api", options=ConsumptionOptions(fragment_name="prod"))
@@ -359,7 +358,10 @@ def get_destination(
359358

360359
if options:
361360
if options.fragment_name:
362-
headers["X-fragment-name"] = options.fragment_name
361+
frag = options.fragment_name
362+
if options.fragment_level:
363+
frag = f"{frag}@{options.fragment_level.value}"
364+
headers["X-fragment-name"] = frag
363365
if options.fragment_optional is not None:
364366
headers["X-fragment-optional"] = str(
365367
options.fragment_optional
@@ -393,11 +395,11 @@ def get_destination(
393395
headers[f"X-chain-var-{var_name}"] = var_value
394396

395397
# Build path with optional level hint
396-
if level:
397-
level_str = self._map_level_to_api(level)
398-
path = f"{API_V2}/destinations/{name}@{level_str}"
399-
else:
400-
path = f"{API_V2}/destinations/{name}"
398+
path = (
399+
f"{API_V2}/destinations/{name}@{level.value}"
400+
if level
401+
else f"{API_V2}/destinations/{name}"
402+
)
401403

402404
resp = self._http.get(path, headers=headers, tenant_subdomain=tenant)
403405
data = resp.json()
@@ -821,8 +823,3 @@ def _sub_path_for_level(level: Optional[Level] = Level.SUB_ACCOUNT) -> str:
821823
if level == Level.SERVICE_INSTANCE
822824
else _SUBACCOUNT_COLLECTION
823825
)
824-
825-
@staticmethod
826-
def _map_level_to_api(level: Level) -> str:
827-
"""Return the v2 API level string for the given level."""
828-
return _INSTANCE_LEVEL if level == Level.SERVICE_INSTANCE else _SUBACCOUNT_LEVEL

src/sap_cloud_sdk/destination/user-guide.md

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ from sap_cloud_sdk.destination import (
1414
create_fragment_client,
1515
create_certificate_client,
1616
Level,
17-
AccessStrategy
17+
AccessStrategy,
18+
ConsumptionLevel,
19+
ConsumptionOptions,
1820
)
1921

2022
# Auto-detection based on environment; in cloud mode it will load credentials
@@ -100,10 +102,16 @@ client.patch_destination_labels("my-dest", PatchLabels(action="DELETE", labels=l
100102

101103
## Concepts
102104

103-
- Level:
105+
- Level (v1 admin API — write operations, label operations):
104106
- SERVICE_INSTANCE: Operates on instance destinations
105107
- SUB_ACCOUNT: Operates on subaccount destinations
106108

109+
- ConsumptionLevel (v2 consumption API — `get_destination` only):
110+
- PROVIDER_SUBACCOUNT: Provider subaccount scope
111+
- PROVIDER_INSTANCE: Provider service instance scope
112+
- SUBACCOUNT: Subscriber subaccount scope
113+
- INSTANCE: Subscriber service instance scope
114+
107115
- AccessStrategy (applies to subaccount reads):
108116
- SUBSCRIBER_ONLY: Only subscriber (tenant required)
109117
- PROVIDER_ONLY: Only provider (no tenant)
@@ -133,7 +141,7 @@ class DestinationClient:
133141
def patch_destination_labels(self, name: str, patch: PatchLabels, level: Optional[Level] = Level.SUB_ACCOUNT, tenant: Optional[str] = None) -> None: ...
134142

135143
# V2 Runtime API - Destination consumption with automatic token retrieval
136-
def get_destination(self, name: str, level: Optional[Level] = None, options: Optional[ConsumptionOptions] = None, proxy_enabled: Optional[bool] = None, tenant: Optional[str] = None) -> Optional[Destination | TransparentProxyDestination]: ...
144+
def get_destination(self, name: str, level: Optional[ConsumptionLevel] = None, options: Optional[ConsumptionOptions] = None, proxy_enabled: Optional[bool] = None, tenant: Optional[str] = None) -> Optional[Destination | TransparentProxyDestination]: ...
137145
```
138146

139147
### Fragment Client
@@ -178,6 +186,7 @@ class CertificateClient:
178186
- `auth_tokens` and `certificates` are populated by the v2 consumption API
179187
- `ConsumptionOptions` - Options for v2 destination consumption, controls HTTP headers sent to the Destination Service:
180188
- `fragment_name?: str` - Fragment to merge into the destination (`X-fragment-name`)
189+
- `fragment_level?: ConsumptionLevel` - Level hint for the fragment lookup; appended to `fragment_name` as `@level` (e.g., `"my-frag@provider_subaccount"`). Only effective when `fragment_name` is also provided.
181190
- `fragment_optional?: bool` - If `True`, a missing fragment does not cause an error (`X-fragment-optional`)
182191
- `tenant?: str` - Tenant subdomain for token retrieval (`X-tenant`)
183192
- `user_token?: str` - User JWT for OAuth2UserTokenExchange / OAuth2JWTBearer / OAuth2SAMLBearerAssertion (`X-user-token`)
@@ -323,16 +332,25 @@ dest = client.get_destination("my-api", options=options, proxy_enabled=False)
323332

324333
# Example 8: V2 API with level parameter for optimized lookup
325334
client = create_client(instance="default")
326-
# Search only at instance level
327-
dest = client.get_destination("my-api", level=Level.SERVICE_INSTANCE)
328-
# Search only at subaccount level
329-
dest = client.get_destination("my-api", level=Level.SUB_ACCOUNT)
330-
# No level specified - searches at instance level as default
335+
# Search only at provider subaccount level
336+
dest = client.get_destination("my-api", level=ConsumptionLevel.PROVIDER_SUBACCOUNT)
337+
# Search only at subscriber subaccount level
338+
dest = client.get_destination("my-api", level=ConsumptionLevel.SUBACCOUNT)
339+
# Search only at service instance level
340+
dest = client.get_destination("my-api", level=ConsumptionLevel.INSTANCE)
341+
# No level specified - API resolves automatically
331342
dest = client.get_destination("my-api")
332343

333344
# Example 9: Combine level with options
334345
options = ConsumptionOptions(fragment_name="production", tenant="tenant-1")
335-
dest = client.get_destination("my-api", level=Level.SUB_ACCOUNT, options=options)
346+
dest = client.get_destination("my-api", level=ConsumptionLevel.SUBACCOUNT, options=options)
347+
348+
# Example 9b: Fragment with level hint
349+
options = ConsumptionOptions(
350+
fragment_name="my-fragment",
351+
fragment_level=ConsumptionLevel.PROVIDER_SUBACCOUNT,
352+
)
353+
dest = client.get_destination("my-api", options=options)
336354

337355
# Example 10: Optional fragment (no error if fragment does not exist)
338356
options = ConsumptionOptions(fragment_name="maybe-exists", fragment_optional=True)

0 commit comments

Comments
 (0)