Skip to content

Commit 97334a1

Browse files
feat: add label management to destination, fragment, and certificate clients (#57)
1 parent b1763fe commit 97334a1

24 files changed

+1907
-33
lines changed

src/sap_cloud_sdk/core/telemetry/operation.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ class Operation(str, Enum):
2121
DESTINATION_DELETE_DESTINATION = "delete_destination"
2222
DESTINATION_GET_DESTINATION = "get_destination"
2323

24+
# Destination Label Operations
25+
DESTINATION_GET_LABELS = "get_destination_labels"
26+
DESTINATION_UPDATE_LABELS = "update_destination_labels"
27+
DESTINATION_PATCH_LABELS = "patch_destination_labels"
28+
2429
# Certificate Operations
2530
CERTIFICATE_GET_INSTANCE_CERTIFICATE = "get_instance_certificate"
2631
CERTIFICATE_GET_SUBACCOUNT_CERTIFICATE = "get_subaccount_certificate"
@@ -30,6 +35,11 @@ class Operation(str, Enum):
3035
CERTIFICATE_UPDATE_CERTIFICATE = "update_certificate"
3136
CERTIFICATE_DELETE_CERTIFICATE = "delete_certificate"
3237

38+
# Certificate Label Operations
39+
CERTIFICATE_GET_LABELS = "get_certificate_labels"
40+
CERTIFICATE_UPDATE_LABELS = "update_certificate_labels"
41+
CERTIFICATE_PATCH_LABELS = "patch_certificate_labels"
42+
3343
# Fragment Operations
3444
FRAGMENT_GET_INSTANCE_FRAGMENT = "get_instance_fragment"
3545
FRAGMENT_GET_SUBACCOUNT_FRAGMENT = "get_subaccount_fragment"
@@ -39,6 +49,11 @@ class Operation(str, Enum):
3949
FRAGMENT_UPDATE_FRAGMENT = "update_fragment"
4050
FRAGMENT_DELETE_FRAGMENT = "delete_fragment"
4151

52+
# Fragment Label Operations
53+
FRAGMENT_GET_LABELS = "get_fragment_labels"
54+
FRAGMENT_UPDATE_LABELS = "update_fragment_labels"
55+
FRAGMENT_PATCH_LABELS = "patch_fragment_labels"
56+
4257
# Object Store Operations
4358
OBJECTSTORE_PUT_OBJECT = "put_object"
4459
OBJECTSTORE_PUT_OBJECT_FROM_FILE = "put_object_from_file"

src/sap_cloud_sdk/destination/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
ConsumptionOptions,
3333
Fragment,
3434
Certificate,
35+
Label,
36+
PatchLabels,
3537
Level,
3638
AccessStrategy,
3739
ListOptions,
@@ -209,6 +211,8 @@ def create_certificate_client(
209211
"ConsumptionOptions",
210212
"Fragment",
211213
"Certificate",
214+
"Label",
215+
"PatchLabels",
212216
"DestinationConfig",
213217
"Level",
214218
"AccessStrategy",

src/sap_cloud_sdk/destination/_http.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class HttpMethod(Enum):
3030
GET = "GET"
3131
POST = "POST"
3232
PUT = "PUT"
33+
PATCH = "PATCH"
3334
DELETE = "DELETE"
3435

3536

@@ -253,6 +254,36 @@ def put(
253254
tenant_subdomain=tenant_subdomain,
254255
)
255256

257+
def patch(
258+
self,
259+
path: str,
260+
*,
261+
body: Any,
262+
headers: Optional[Dict[str, str]] = None,
263+
tenant_subdomain: Optional[str] = None,
264+
) -> Response:
265+
"""Send a PATCH request.
266+
267+
Args:
268+
path: Relative API path under destination-configuration/v1.
269+
body: JSON-serializable request body.
270+
headers: Optional additional request headers.
271+
tenant_subdomain: Optional subscriber tenant subdomain for token acquisition.
272+
273+
Returns:
274+
requests.Response if the status code is 2xx.
275+
276+
Raises:
277+
HttpError: If the request fails or returns a non-2xx status.
278+
"""
279+
return self._request(
280+
HttpMethod.PATCH,
281+
path,
282+
json=body,
283+
extra_headers=headers,
284+
tenant_subdomain=tenant_subdomain,
285+
)
286+
256287
def delete(
257288
self,
258289
path: str,

src/sap_cloud_sdk/destination/_local_client_base.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,3 +351,123 @@ def _delete_entity(self, collection: str, entity_name: str) -> None:
351351
raise DestinationOperationError(
352352
f"failed to delete entity '{entity_name}': {e}"
353353
)
354+
355+
# ---------- Label operations ----------
356+
357+
def _get_labels(self, collection: str, name: str) -> List[Dict[str, Any]]:
358+
"""Return labels for an entity in a collection.
359+
360+
Args:
361+
collection: Collection name ("instance" or "subaccount").
362+
name: Entity name.
363+
364+
Returns:
365+
List of raw label dicts. Returns empty list if entity has no labels.
366+
367+
Raises:
368+
HttpError: If entity is not found (404).
369+
DestinationOperationError: On file read errors.
370+
"""
371+
try:
372+
data = self._read()
373+
entry = self._find_by_name(data.get(collection, []), name)
374+
if entry is None:
375+
raise HttpError(
376+
f"entity '{name}' not found",
377+
status_code=404,
378+
response_text="Not Found",
379+
)
380+
return list(entry.get("labels", []))
381+
except HttpError:
382+
raise
383+
except Exception as e:
384+
raise DestinationOperationError(f"failed to get labels for '{name}': {e}")
385+
386+
def _set_labels(
387+
self, collection: str, name: str, labels: List[Dict[str, Any]]
388+
) -> None:
389+
"""Replace all labels for an entity in a collection (PUT semantics).
390+
391+
Args:
392+
collection: Collection name ("instance" or "subaccount").
393+
name: Entity name.
394+
labels: List of raw label dicts to store.
395+
396+
Raises:
397+
HttpError: If entity is not found (404).
398+
DestinationOperationError: On file read/write errors.
399+
"""
400+
try:
401+
with self._lock:
402+
data = self._read()
403+
lst = data.setdefault(collection, [])
404+
idx = self._index_by_name(lst, name)
405+
if idx < 0:
406+
raise HttpError(
407+
f"entity '{name}' not found",
408+
status_code=404,
409+
response_text="Not Found",
410+
)
411+
lst[idx]["labels"] = labels
412+
self._write(data)
413+
except HttpError:
414+
raise
415+
except Exception as e:
416+
raise DestinationOperationError(f"failed to set labels for '{name}': {e}")
417+
418+
def _patch_labels_in_store(
419+
self,
420+
collection: str,
421+
name: str,
422+
action: str,
423+
patch_labels: List[Dict[str, Any]],
424+
) -> None:
425+
"""Add or remove labels for an entity in a collection (PATCH semantics).
426+
427+
ADD: upsert by key — if the key exists update its values, otherwise append.
428+
DELETE: remove entries whose key matches any incoming label key.
429+
430+
Args:
431+
collection: Collection name ("instance" or "subaccount").
432+
name: Entity name.
433+
action: "ADD" or "DELETE".
434+
patch_labels: List of raw label dicts to apply.
435+
436+
Raises:
437+
HttpError: If entity is not found (404).
438+
DestinationOperationError: On unknown action or file read/write errors.
439+
"""
440+
try:
441+
with self._lock:
442+
data = self._read()
443+
lst = data.setdefault(collection, [])
444+
idx = self._index_by_name(lst, name)
445+
if idx < 0:
446+
raise HttpError(
447+
f"entity '{name}' not found",
448+
status_code=404,
449+
response_text="Not Found",
450+
)
451+
current: List[Dict[str, Any]] = list(lst[idx].get("labels", []))
452+
453+
if action == "ADD":
454+
key_map = {lbl["key"]: lbl for lbl in current}
455+
for incoming in patch_labels:
456+
key_map[incoming["key"]] = incoming
457+
current = list(key_map.values())
458+
elif action == "DELETE":
459+
keys_to_remove = {lbl["key"] for lbl in patch_labels}
460+
current = [
461+
lbl for lbl in current if lbl["key"] not in keys_to_remove
462+
]
463+
else:
464+
raise DestinationOperationError(
465+
f"unknown patch action: '{action}' — must be 'ADD' or 'DELETE'"
466+
)
467+
468+
lst[idx]["labels"] = current
469+
self._write(data)
470+
except (HttpError, DestinationOperationError):
471+
raise
472+
except Exception as e:
473+
raise DestinationOperationError(f"failed to patch labels for '{name}': {e}")

src/sap_cloud_sdk/destination/_models.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
Params,
4747
build_pagination_params,
4848
build_filter_param,
49+
build_label_filter_param,
4950
)
5051
from sap_cloud_sdk.destination.exceptions import DestinationOperationError
5152

@@ -537,6 +538,7 @@ class ListOptions:
537538

538539
# Filter options
539540
filter_names: Optional[List[str]] = None
541+
filter_labels: Optional[List["Label"]] = None
540542

541543
# Pagination options
542544
page: Optional[int] = None
@@ -555,11 +557,19 @@ def to_query_params(self) -> Dict[str, str]:
555557
"""
556558
params: Dict[str, str] = {}
557559

560+
if self.filter_names and self.filter_labels:
561+
raise DestinationOperationError(
562+
"filter_names and filter_labels cannot be used together"
563+
)
564+
558565
# Build $filter parameter
559566
if self.filter_names:
560567
params[Params.FILTER.value] = build_filter_param("Name", self.filter_names)
561568

562-
has_filter = self.filter_names is not None
569+
if self.filter_labels:
570+
params[Params.FILTER.value] = build_label_filter_param(self.filter_labels)
571+
572+
has_filter = bool(self.filter_names) or bool(self.filter_labels)
563573

564574
# Build pagination parameters using shared utility
565575
pagination_params = build_pagination_params(
@@ -575,6 +585,92 @@ def to_query_params(self) -> Dict[str, str]:
575585
return params
576586

577587

588+
@dataclass
589+
class Label:
590+
"""Label entity for resource tagging.
591+
592+
Labels allow attaching key-value metadata to destinations, fragments,
593+
and certificates for filtering and organization.
594+
595+
Fields:
596+
key: Label key string (e.g., "env").
597+
values: List of string values for this key (e.g., ["prod", "eu"]).
598+
599+
The class provides:
600+
- from_dict: Parses a raw dict into Label
601+
- to_dict: Serializes the dataclass back into a payload compatible with the API
602+
"""
603+
604+
key: str
605+
values: List[str]
606+
607+
@classmethod
608+
def from_dict(cls, obj: Dict[str, Any]) -> "Label":
609+
"""Parse a raw label dict into a Label dataclass.
610+
611+
Args:
612+
obj: Raw dict returned by the Destination Service.
613+
614+
Returns:
615+
Label: Parsed label dataclass.
616+
617+
Raises:
618+
DestinationOperationError: If required field (key) is missing or values is not a list.
619+
"""
620+
key = obj.get("key") or ""
621+
values = obj.get("values") or []
622+
623+
if not key.strip():
624+
raise DestinationOperationError("label is missing required field (key)")
625+
if not isinstance(values, list):
626+
raise DestinationOperationError("label 'values' must be a list")
627+
628+
return cls(key=key, values=list(values))
629+
630+
def to_dict(self) -> Dict[str, Any]:
631+
"""Serialize Label to API payload.
632+
633+
Returns:
634+
Dict[str, Any]: API payload dictionary representing this label.
635+
"""
636+
return {"key": self.key, "values": list(self.values)}
637+
638+
639+
@dataclass
640+
class PatchLabels:
641+
"""Payload for PATCH label operations (add or remove labels).
642+
643+
Fields:
644+
action: The action to perform — either "ADD" or "DELETE".
645+
labels: List of Label objects to apply the action to.
646+
647+
Example:
648+
```python
649+
from sap_cloud_sdk.destination import Label, PatchLabels
650+
651+
# Add labels
652+
patch = PatchLabels(action="ADD", labels=[Label(key="env", values=["prod"])])
653+
654+
# Remove labels
655+
patch = PatchLabels(action="DELETE", labels=[Label(key="env", values=["prod"])])
656+
```
657+
"""
658+
659+
action: str
660+
labels: List[Label]
661+
662+
def to_dict(self) -> Dict[str, Any]:
663+
"""Serialize PatchLabels to API payload.
664+
665+
Returns:
666+
Dict[str, Any]: API payload dictionary for the PATCH request.
667+
"""
668+
return {
669+
"action": self.action,
670+
"labels": [lbl.to_dict() for lbl in self.labels],
671+
}
672+
673+
578674
@dataclass
579675
class Certificate:
580676
"""Certificate entity (subset of v1 schema).

0 commit comments

Comments
 (0)