Skip to content
Merged
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
15 changes: 15 additions & 0 deletions src/sap_cloud_sdk/core/telemetry/operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ class Operation(str, Enum):
DESTINATION_DELETE_DESTINATION = "delete_destination"
DESTINATION_GET_DESTINATION = "get_destination"

# Destination Label Operations
DESTINATION_GET_LABELS = "get_destination_labels"
DESTINATION_UPDATE_LABELS = "update_destination_labels"
DESTINATION_PATCH_LABELS = "patch_destination_labels"

# Certificate Operations
CERTIFICATE_GET_INSTANCE_CERTIFICATE = "get_instance_certificate"
CERTIFICATE_GET_SUBACCOUNT_CERTIFICATE = "get_subaccount_certificate"
Expand All @@ -30,6 +35,11 @@ class Operation(str, Enum):
CERTIFICATE_UPDATE_CERTIFICATE = "update_certificate"
CERTIFICATE_DELETE_CERTIFICATE = "delete_certificate"

# Certificate Label Operations
CERTIFICATE_GET_LABELS = "get_certificate_labels"
CERTIFICATE_UPDATE_LABELS = "update_certificate_labels"
CERTIFICATE_PATCH_LABELS = "patch_certificate_labels"

# Fragment Operations
FRAGMENT_GET_INSTANCE_FRAGMENT = "get_instance_fragment"
FRAGMENT_GET_SUBACCOUNT_FRAGMENT = "get_subaccount_fragment"
Expand All @@ -39,6 +49,11 @@ class Operation(str, Enum):
FRAGMENT_UPDATE_FRAGMENT = "update_fragment"
FRAGMENT_DELETE_FRAGMENT = "delete_fragment"

# Fragment Label Operations
FRAGMENT_GET_LABELS = "get_fragment_labels"
FRAGMENT_UPDATE_LABELS = "update_fragment_labels"
FRAGMENT_PATCH_LABELS = "patch_fragment_labels"

# Object Store Operations
OBJECTSTORE_PUT_OBJECT = "put_object"
OBJECTSTORE_PUT_OBJECT_FROM_FILE = "put_object_from_file"
Expand Down
4 changes: 4 additions & 0 deletions src/sap_cloud_sdk/destination/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
ConsumptionOptions,
Fragment,
Certificate,
Label,
PatchLabels,
Level,
AccessStrategy,
ListOptions,
Expand Down Expand Up @@ -209,6 +211,8 @@ def create_certificate_client(
"ConsumptionOptions",
"Fragment",
"Certificate",
"Label",
"PatchLabels",
"DestinationConfig",
"Level",
"AccessStrategy",
Expand Down
31 changes: 31 additions & 0 deletions src/sap_cloud_sdk/destination/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class HttpMethod(Enum):
GET = "GET"
POST = "POST"
PUT = "PUT"
PATCH = "PATCH"
DELETE = "DELETE"


Expand Down Expand Up @@ -253,6 +254,36 @@ def put(
tenant_subdomain=tenant_subdomain,
)

def patch(
self,
path: str,
*,
body: Any,
headers: Optional[Dict[str, str]] = None,
tenant_subdomain: Optional[str] = None,
) -> Response:
"""Send a PATCH request.

Args:
path: Relative API path under destination-configuration/v1.
body: JSON-serializable request body.
headers: Optional additional request headers.
tenant_subdomain: Optional subscriber tenant subdomain for token acquisition.

Returns:
requests.Response if the status code is 2xx.

Raises:
HttpError: If the request fails or returns a non-2xx status.
"""
return self._request(
HttpMethod.PATCH,
path,
json=body,
extra_headers=headers,
tenant_subdomain=tenant_subdomain,
)

def delete(
self,
path: str,
Expand Down
120 changes: 120 additions & 0 deletions src/sap_cloud_sdk/destination/_local_client_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,3 +351,123 @@ def _delete_entity(self, collection: str, entity_name: str) -> None:
raise DestinationOperationError(
f"failed to delete entity '{entity_name}': {e}"
)

# ---------- Label operations ----------

def _get_labels(self, collection: str, name: str) -> List[Dict[str, Any]]:
"""Return labels for an entity in a collection.

Args:
collection: Collection name ("instance" or "subaccount").
name: Entity name.

Returns:
List of raw label dicts. Returns empty list if entity has no labels.

Raises:
HttpError: If entity is not found (404).
DestinationOperationError: On file read errors.
"""
try:
data = self._read()
entry = self._find_by_name(data.get(collection, []), name)
if entry is None:
raise HttpError(
f"entity '{name}' not found",
status_code=404,
response_text="Not Found",
)
return list(entry.get("labels", []))
except HttpError:
raise
except Exception as e:
raise DestinationOperationError(f"failed to get labels for '{name}': {e}")

def _set_labels(
self, collection: str, name: str, labels: List[Dict[str, Any]]
) -> None:
"""Replace all labels for an entity in a collection (PUT semantics).

Args:
collection: Collection name ("instance" or "subaccount").
name: Entity name.
labels: List of raw label dicts to store.

Raises:
HttpError: If entity is not found (404).
DestinationOperationError: On file read/write errors.
"""
try:
with self._lock:
data = self._read()
lst = data.setdefault(collection, [])
idx = self._index_by_name(lst, name)
if idx < 0:
raise HttpError(
f"entity '{name}' not found",
status_code=404,
response_text="Not Found",
)
lst[idx]["labels"] = labels
self._write(data)
except HttpError:
raise
except Exception as e:
raise DestinationOperationError(f"failed to set labels for '{name}': {e}")

def _patch_labels_in_store(
self,
collection: str,
name: str,
action: str,
patch_labels: List[Dict[str, Any]],
) -> None:
"""Add or remove labels for an entity in a collection (PATCH semantics).

ADD: upsert by key — if the key exists update its values, otherwise append.
DELETE: remove entries whose key matches any incoming label key.

Args:
collection: Collection name ("instance" or "subaccount").
name: Entity name.
action: "ADD" or "DELETE".
patch_labels: List of raw label dicts to apply.

Raises:
HttpError: If entity is not found (404).
DestinationOperationError: On unknown action or file read/write errors.
"""
try:
with self._lock:
data = self._read()
lst = data.setdefault(collection, [])
idx = self._index_by_name(lst, name)
if idx < 0:
raise HttpError(
f"entity '{name}' not found",
status_code=404,
response_text="Not Found",
)
current: List[Dict[str, Any]] = list(lst[idx].get("labels", []))

if action == "ADD":
key_map = {lbl["key"]: lbl for lbl in current}
for incoming in patch_labels:
key_map[incoming["key"]] = incoming
current = list(key_map.values())
elif action == "DELETE":
keys_to_remove = {lbl["key"] for lbl in patch_labels}
current = [
lbl for lbl in current if lbl["key"] not in keys_to_remove
]
else:
raise DestinationOperationError(
f"unknown patch action: '{action}' — must be 'ADD' or 'DELETE'"
)

lst[idx]["labels"] = current
self._write(data)
except (HttpError, DestinationOperationError):
raise
except Exception as e:
raise DestinationOperationError(f"failed to patch labels for '{name}': {e}")
98 changes: 97 additions & 1 deletion src/sap_cloud_sdk/destination/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
Params,
build_pagination_params,
build_filter_param,
build_label_filter_param,
)
from sap_cloud_sdk.destination.exceptions import DestinationOperationError

Expand Down Expand Up @@ -537,6 +538,7 @@ class ListOptions:

# Filter options
filter_names: Optional[List[str]] = None
filter_labels: Optional[List["Label"]] = None

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

if self.filter_names and self.filter_labels:
raise DestinationOperationError(
"filter_names and filter_labels cannot be used together"
)

# Build $filter parameter
if self.filter_names:
params[Params.FILTER.value] = build_filter_param("Name", self.filter_names)

has_filter = self.filter_names is not None
if self.filter_labels:
params[Params.FILTER.value] = build_label_filter_param(self.filter_labels)

has_filter = bool(self.filter_names) or bool(self.filter_labels)

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


@dataclass
class Label:
"""Label entity for resource tagging.

Labels allow attaching key-value metadata to destinations, fragments,
and certificates for filtering and organization.

Fields:
key: Label key string (e.g., "env").
values: List of string values for this key (e.g., ["prod", "eu"]).

The class provides:
- from_dict: Parses a raw dict into Label
- to_dict: Serializes the dataclass back into a payload compatible with the API
"""

key: str
values: List[str]

@classmethod
def from_dict(cls, obj: Dict[str, Any]) -> "Label":
"""Parse a raw label dict into a Label dataclass.

Args:
obj: Raw dict returned by the Destination Service.

Returns:
Label: Parsed label dataclass.

Raises:
DestinationOperationError: If required field (key) is missing or values is not a list.
"""
key = obj.get("key") or ""
values = obj.get("values") or []

if not key.strip():
raise DestinationOperationError("label is missing required field (key)")
if not isinstance(values, list):
raise DestinationOperationError("label 'values' must be a list")

return cls(key=key, values=list(values))

def to_dict(self) -> Dict[str, Any]:
"""Serialize Label to API payload.

Returns:
Dict[str, Any]: API payload dictionary representing this label.
"""
return {"key": self.key, "values": list(self.values)}


@dataclass
class PatchLabels:
"""Payload for PATCH label operations (add or remove labels).

Fields:
action: The action to perform — either "ADD" or "DELETE".
labels: List of Label objects to apply the action to.

Example:
```python
from sap_cloud_sdk.destination import Label, PatchLabels

# Add labels
patch = PatchLabels(action="ADD", labels=[Label(key="env", values=["prod"])])

# Remove labels
patch = PatchLabels(action="DELETE", labels=[Label(key="env", values=["prod"])])
```
"""

action: str
labels: List[Label]

def to_dict(self) -> Dict[str, Any]:
"""Serialize PatchLabels to API payload.

Returns:
Dict[str, Any]: API payload dictionary for the PATCH request.
"""
return {
"action": self.action,
"labels": [lbl.to_dict() for lbl in self.labels],
}


@dataclass
class Certificate:
"""Certificate entity (subset of v1 schema).
Expand Down
Loading
Loading