From 8b951466bc7abac8339e9ad734cd12ee842cec0f Mon Sep 17 00:00:00 2001 From: Jagnath Reddy Date: Tue, 17 Mar 2026 10:14:50 +0530 Subject: [PATCH 01/24] init --- src/sap_cloud_sdk/dms/__init__.py | 24 ++++++ src/sap_cloud_sdk/dms/_models.py | 62 ++++++++++++++ src/sap_cloud_sdk/dms/client.py | 19 +++++ src/sap_cloud_sdk/dms/config.py | 135 ++++++++++++++++++++++++++++++ 4 files changed, 240 insertions(+) create mode 100644 src/sap_cloud_sdk/dms/__init__.py create mode 100644 src/sap_cloud_sdk/dms/_models.py create mode 100644 src/sap_cloud_sdk/dms/client.py create mode 100644 src/sap_cloud_sdk/dms/config.py diff --git a/src/sap_cloud_sdk/dms/__init__.py b/src/sap_cloud_sdk/dms/__init__.py new file mode 100644 index 0000000..230b58d --- /dev/null +++ b/src/sap_cloud_sdk/dms/__init__.py @@ -0,0 +1,24 @@ +from typing import Optional +from sap_cloud_sdk.dms._models import DMSCredentials +from sap_cloud_sdk.dms.client import DMSClient +from sap_cloud_sdk.dms.config import BindingData, load_sdm_config_from_env_or_mount + + +def create_client( + *, + instance: Optional[str] = None, + config: Optional[BindingData] = None, + dms_cred: Optional[DMSCredentials] = None +): + + if config is not None and dms_cred is not None: + raise ValueError("Cannot provide both config and dms_cred. Please choose one.") + if config is not None: + config.validate() + return DMSClient(config.to_credentials()) + if dms_cred is not None: + return DMSClient(dms_cred) + if instance is not None: + return DMSClient(load_sdm_config_from_env_or_mount(instance)) + + raise ValueError("No configuration provided. Please provide either instance name, config, or dms_cred.") \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/_models.py b/src/sap_cloud_sdk/dms/_models.py new file mode 100644 index 0000000..41e199c --- /dev/null +++ b/src/sap_cloud_sdk/dms/_models.py @@ -0,0 +1,62 @@ +"""Data models for DMS service.""" + +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +import requests + + +@dataclass +class DMSCredentials: + """Credentials for DMS service access. + + Contains the necessary information to authenticate and connect to the DMS service, + including the service URI and UAA credentials for OAuth2 authentication. + + Token lifecycle is managed manually because the service uses OAuth2 client credentials + grant, which does not issue refresh tokens. Libraries like requests_oauthlib assume + refresh token flow for token renewal and are not suitable here. + """ + instance_name: str + uri: str + client_id: str + client_secret: str + token_url: str + _access_token: str = field(default="", repr=False) + _token_expiry: datetime = field( + default_factory=lambda: datetime.now(tz=timezone.utc), + repr=False + ) + + @property + def access_token(self) -> str: + if not self._access_token or datetime.now(tz=timezone.utc) >= self._token_expiry: + self._access_token, self._token_expiry = self._retrieve_access_token() + return self._access_token + + def _retrieve_access_token(self) -> tuple[str, datetime]: + """Fetch a new OAuth2 token using client credentials grant. + + Raises: + RuntimeError: If the token response is missing access_token. + requests.HTTPError: If the token endpoint returns a non-2xx response. + """ + response = requests.post( + self.token_url, + data={ + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + response.raise_for_status() + + token_response = response.json() + if "access_token" not in token_response: + raise RuntimeError("access_token missing in response") + + expires_in = token_response.get("expires_in", 3600) # fallback 1hr + expiry = datetime.now(tz=timezone.utc) + timedelta(seconds=expires_in) - timedelta(minutes=5) + + return token_response["access_token"], expiry + \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/client.py b/src/sap_cloud_sdk/dms/client.py new file mode 100644 index 0000000..c43afcb --- /dev/null +++ b/src/sap_cloud_sdk/dms/client.py @@ -0,0 +1,19 @@ +from sap_cloud_sdk.dms._models import DMSCredentials + + +class DMSClient: + """Client for interacting with the DMS service.""" + + def __init__(self, credentials: DMSCredentials): + """Initialize the DMS client with provided credentials. + + Args: + credentials: DMSCredentials constructed from either BindingData or directly with required fields. + """ + #fetch access token + try: + credentials.access_token + except Exception as e: + raise ValueError(f"Failed to fetch access token: {e}") + + credentials = credentials \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/config.py b/src/sap_cloud_sdk/dms/config.py new file mode 100644 index 0000000..1035dcb --- /dev/null +++ b/src/sap_cloud_sdk/dms/config.py @@ -0,0 +1,135 @@ +import json +from dataclasses import dataclass +from typing import Any, Dict, Optional +from urllib.parse import urlparse + +from sap_cloud_sdk.core.secret_resolver.resolver import read_from_mount_and_fallback_to_env_var +from sap_cloud_sdk.destination.exceptions import ConfigError +from sap_cloud_sdk.dms._models import DMSCredentials + +@dataclass +class BindingData: + """Dataclass for DMS binding data with URI and UAA credentials. + + Attributes: + uri: The URI endpoint for the DMS service + uaa: JSON string containing XSUAA authentication credentials + """ + instance_name: str + uri: str + uaa: str + + def validate(self) -> None: + """Validate the binding data. + + Validates that: + - uri is a valid URI + - uaa is valid JSON and contains required credential fields + + Raises: + ValueError: If uri is not a valid URI + json.JSONDecodeError: If uaa is not valid JSON + ValueError: If uaa JSON is missing required fields + """ + self._validate_uri() + self._validate_uaa() + + def _validate_uri(self) -> None: + """Validate that uri is a valid URI. + + Raises: + ValueError: If uri is not a valid URI + """ + try: + result = urlparse(self.uri) + if not result.scheme or not result.netloc: + raise ValueError( + f"Invalid URI format: '{self.uri}'. " + "URI must have a scheme (e.g., https://) and network location." + ) + except Exception as e: + raise ValueError(f"Failed to parse URI: {self.uri}") from e + + def _validate_uaa(self) -> None: + """Validate that uaa is valid JSON with required credential fields. + + Raises: + json.JSONDecodeError: If uaa is not valid JSON + ValueError: If required fields are missing from UAA credentials + """ + required_fields = { + "clientid", + "clientsecret", + "url" + } + + try: + uaa_data: Dict[str, Any] = json.loads(self.uaa) + except json.JSONDecodeError as e: + raise json.JSONDecodeError( + f"UAA credentials must be valid JSON. Error: {e.msg}", + e.doc, + e.pos, + ) from e + + missing_fields = required_fields - set(uaa_data.keys()) + if missing_fields: + raise ValueError( + f"UAA credentials missing required fields: {', '.join(sorted(missing_fields))}" + ) + + def to_credentials(self) -> DMSCredentials: + """Convert the binding data to DMSCredentials. + + Parses the UAA JSON and constructs a DMSCredentials object with the necessary information + for authenticating and connecting to the DMS service. + + Returns: + DMSCredentials: The credentials extracted from the binding data + """ + uaa_data: Dict[str, Any] = json.loads(self.uaa) + token_url = uaa_data["url"].rstrip("/") + "/oauth/token" + + return DMSCredentials( + instance_name=self.instance_name, + uri=self.uri, + client_id=uaa_data["clientid"], + client_secret=uaa_data["clientsecret"], + token_url=token_url + ) + + +def load_sdm_config_from_env_or_mount(instance: Optional[str] = None) -> DMSCredentials: + """Load Destination configuration from mount with env fallback and normalize. + + Args: + instance: Logical instance name; defaults to "default" if not provided. + + Returns: + DMSCredentials + + Raises: + ConfigError: If loading or validation fails. + """ + inst = instance or "default" + binding = BindingData(uri="", uaa="", instance_name="") # Initialize with empty values; will be populated by resolver + + try: + # 1) Try mount at /etc/secrets/appfnd/destination/{instance}/... + # 2) Fallback to env: CLOUD_SDK_CFG_SDM_{INSTANCE}_{FIELD_KEY} + read_from_mount_and_fallback_to_env_var( + base_volume_mount="/etc/secrets/appfnd", + base_var_name="CLOUD_SDK_CFG", + module="sdm", #TODO check if this should be "dms" or "sdm" + instance=inst, + target=binding, + ) + + binding.validate() + return binding.to_credentials() + + except Exception as e: + # Rely on the central secret resolver to provide aggregated, generic guidance + raise ConfigError( + f"failed to load sdm configuration for instance='{inst}': {e}" + ) From 33d1e3e33a0b6578a01e77214a0db2c8d7ee2d5e Mon Sep 17 00:00:00 2001 From: Jagnath Reddy Date: Tue, 17 Mar 2026 14:37:00 +0530 Subject: [PATCH 02/24] added admin service --- src/sap_cloud_sdk/dms/client.py | 9 +- src/sap_cloud_sdk/dms/exceptions.py | 20 +++ src/sap_cloud_sdk/dms/model/config.py | 8 ++ src/sap_cloud_sdk/dms/model/repository.py | 114 ++++++++++++++++ .../dms/model/repository_request.py | 129 ++++++++++++++++++ .../dms/services/AdminService.py | 73 ++++++++++ src/sap_cloud_sdk/dms/services/BaseService.py | 85 ++++++++++++ src/sap_cloud_sdk/dms/services/__init__.py | 0 8 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 src/sap_cloud_sdk/dms/exceptions.py create mode 100644 src/sap_cloud_sdk/dms/model/config.py create mode 100644 src/sap_cloud_sdk/dms/model/repository.py create mode 100644 src/sap_cloud_sdk/dms/model/repository_request.py create mode 100644 src/sap_cloud_sdk/dms/services/AdminService.py create mode 100644 src/sap_cloud_sdk/dms/services/BaseService.py create mode 100644 src/sap_cloud_sdk/dms/services/__init__.py diff --git a/src/sap_cloud_sdk/dms/client.py b/src/sap_cloud_sdk/dms/client.py index c43afcb..1f4a0e0 100644 --- a/src/sap_cloud_sdk/dms/client.py +++ b/src/sap_cloud_sdk/dms/client.py @@ -16,4 +16,11 @@ def __init__(self, credentials: DMSCredentials): except Exception as e: raise ValueError(f"Failed to fetch access token: {e}") - credentials = credentials \ No newline at end of file + self.credentials = credentials + self._admin = None # Lazy initialization of AdminService + + @property + def admin(self) -> AdminService: + if self._admin is None: + self._admin = AdminService(self._base_url, self._token_manager) + return self._admin \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/exceptions.py b/src/sap_cloud_sdk/dms/exceptions.py new file mode 100644 index 0000000..ba3080e --- /dev/null +++ b/src/sap_cloud_sdk/dms/exceptions.py @@ -0,0 +1,20 @@ +from typing import Optional + +class DmsException(Exception): + """Base exception for all DMS SDK errors.""" + + def __init__( + self, + message: Optional[str] = None, + status_code: Optional[int] = None, + error_content: Optional[str] = None, + cause: Optional[Exception] = None, + ) -> None: + super().__init__(message if message is not None else "") + self.status_code = status_code + self.error_content = error_content + if cause is not None: + self.__cause__ = cause + + def __repr__(self) -> str: + return f"DmsException(status_code={self.status_code}, message={str(self)!r})" \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/model/config.py b/src/sap_cloud_sdk/dms/model/config.py new file mode 100644 index 0000000..313bc1a --- /dev/null +++ b/src/sap_cloud_sdk/dms/model/config.py @@ -0,0 +1,8 @@ +from typing import Optional, List + +class Config: + def __init__(self, id: str, blocked_file_extensions: Optional[List[str]] = None, tempspace_max_content_size: Optional[int] = None, is_cross_domain_mapping_allowed: Optional[bool] = None): + self.id = id + self.blocked_file_extensions = blocked_file_extensions or [] + self.tempspace_max_content_size = tempspace_max_content_size + self.is_cross_domain_mapping_allowed = is_cross_domain_mapping_allowed diff --git a/src/sap_cloud_sdk/dms/model/repository.py b/src/sap_cloud_sdk/dms/model/repository.py new file mode 100644 index 0000000..8352465 --- /dev/null +++ b/src/sap_cloud_sdk/dms/model/repository.py @@ -0,0 +1,114 @@ +from __future__ import annotations +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + + +@dataclass +class RepositoryParam: + param_name: str + param_value: Any + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> RepositoryParam: + return cls( + param_name=data.get("paramName", ""), + param_value=data.get("paramValue") + ) + + +@dataclass +class Repository: + id: Optional[str] = None + cmis_repository_id: Optional[str] = None + name: Optional[str] = None + repository_type: Optional[str] = None + repository_sub_type: Optional[str] = None + repository_category: Optional[str] = None + created_time: Optional[datetime] = None + last_updated_time: Optional[datetime] = None + repository_params: List[RepositoryParam] = field(default_factory=list) + _params_lookup: Dict[str, Any] = field(default_factory=dict, init=False, repr=False) + + def __post_init__(self) -> None: + params: List[RepositoryParam] = self.repository_params + self._params_lookup: Dict[str, Any] = { + p.param_name: p.param_value for p in params + } + + def _get_param(self, name: str) -> Any: + return self._params_lookup.get(name) + + + # values taken from params list + @property + def is_version_enabled(self) -> Optional[bool]: + return self._get_param("isVersionEnabled") + + @property + def is_virus_scan_enabled(self) -> Optional[bool]: + return self._get_param("isVirusScanEnabled") + + @property + def is_thumbnail_enabled(self) -> Optional[bool]: + return self._get_param("isThumbnailEnabled") + + @property + def is_encryption_enabled(self) -> Optional[bool]: + return self._get_param("isEncryptionEnabled") + + @property + def is_client_cache_enabled(self) -> Optional[bool]: + return self._get_param("isClientCacheEnabled") + + @property + def is_ai_enabled(self) -> Optional[bool]: + return self._get_param("isAIEnabled") + + @property + def is_async_virus_scan_enabled(self) -> Optional[bool]: + return self._get_param("isAsyncVirusScanEnabled") + + @property + def skip_virus_scan_for_large_file(self) -> Optional[bool]: + return self._get_param("skipVirusScanForLargeFile") + + @property + def hash_algorithms(self) -> Optional[str]: + return self._get_param("hashAlgorithms") + + @property + def change_log_duration(self) -> Optional[int]: + return self._get_param("changeLogDuration") + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> Repository: + # ✅ typed variable — pins type of p in the comprehension + params_raw: List[Dict[str, Any]] = data.get("repositoryParams") or [] + + return cls( + id=data.get("id"), + cmis_repository_id=data.get("cmisRepositoryId"), + name=data.get("name"), + repository_type=data.get("repositoryType"), + repository_sub_type=data.get("repositorySubType"), + repository_category=data.get("repositoryCategory"), + created_time=_parse_datetime(data.get("createdTime")), + last_updated_time=_parse_datetime(data.get("lastUpdatedTime")), + repository_params=[RepositoryParam.from_dict(p) for p in params_raw], + ) + + def __repr__(self) -> str: + return ( + f"Repository(id={self.id!r}, name={self.name!r}, " + f"type={self.repository_type!r}, " + f"category={self.repository_category!r})" + ) + + +def _parse_datetime(val: Any) -> Optional[datetime]: + if val is None: + return None + if isinstance(val, int): + return datetime.fromtimestamp(val / 1000, tz=timezone.utc) # assuming milliseconds + return datetime.fromisoformat(str(val).replace("Z", "+00:00")) \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/model/repository_request.py b/src/sap_cloud_sdk/dms/model/repository_request.py new file mode 100644 index 0000000..1676998 --- /dev/null +++ b/src/sap_cloud_sdk/dms/model/repository_request.py @@ -0,0 +1,129 @@ +# sap_cloud_sdk/dms/model/repository_request.py + +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Any, Dict, List, Literal, Optional + +# ── Constrained types ───────────────────────────────────────────────────────── + +RepositoryCategory = Literal["Collaboration", "Instant", "Favorites"] +RepositoryType = Literal["internal", "external"] + + +@dataclass +class RepositoryParam: + param_name: str + param_value: Any + + def to_dict(self) -> Dict[str, Any]: + return { + "paramName": self.param_name, + "paramValue": self.param_value, + } + + +# ── Internal Repository ─────────────────────────────────────────────────────── + +@dataclass +class InternalRepoRequest: + display_name: str + repository_type: RepositoryType = "internal" + description: Optional[str] = None + repository_category: Optional[RepositoryCategory] = None + external_id: Optional[str] = None + is_version_enabled: Optional[bool] = None + is_virus_scan_enabled: Optional[bool] = None + skip_virus_scan_for_large_file: Optional[bool] = None + hash_algorithms: Optional[str] = None + is_thumbnail_enabled: Optional[bool] = None + is_encryption_enabled: Optional[bool] = None + is_client_cache_enabled: Optional[bool] = None + is_content_bridge_enabled: Optional[bool] = None + is_ai_enabled: Optional[bool] = None + repository_params: List[RepositoryParam] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "displayName": self.display_name, + "repositoryType": self.repository_type, + } + optional_fields: Dict[str, Any] = { + "description": self.description, + "repositoryCategory": self.repository_category, + "externalId": self.external_id, + "isVersionEnabled": self.is_version_enabled, + "isVirusScanEnabled": self.is_virus_scan_enabled, + "skipVirusScanForLargeFile": self.skip_virus_scan_for_large_file, + "hashAlgorithms": self.hash_algorithms, + "isThumbnailEnabled": self.is_thumbnail_enabled, + "isEncryptionEnabled": self.is_encryption_enabled, + "isClientCacheEnabled": self.is_client_cache_enabled, + "isContentBridgeEnabled": self.is_content_bridge_enabled, + "isAIEnabled": self.is_ai_enabled, + } + for key, value in optional_fields.items(): + if value is not None: + payload[key] = value + + if self.repository_params: + payload["repositoryParams"] = [p.to_dict() for p in self.repository_params] + + return payload + + +# ── External Repository ─────────────────────────────────────────────────────── + +@dataclass +class ExternalRepoDetails: + display_name: str + repository_id: str + repository_type: RepositoryType = "external" + description: Optional[str] = None + external_id: Optional[str] = None + repository_params: List[RepositoryParam] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "displayName": self.display_name, + "repositoryType": self.repository_type, + "repositoryId": self.repository_id, + } + optional_fields: Dict[str, Any] = { + "description": self.description, + "externalId": self.external_id, + } + for key, value in optional_fields.items(): + if value is not None: + payload[key] = value + + if self.repository_params: + payload["repositoryParams"] = [p.to_dict() for p in self.repository_params] + + return payload + + +@dataclass +class ConnectionRequest: + destination_name: str + display_name: Optional[str] = None + description: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + payload: Dict[str, Any] = {"destinationName": self.destination_name} + if self.display_name is not None: + payload["displayName"] = self.display_name + if self.description is not None: + payload["description"] = self.description + return payload + + +@dataclass +class ExternalRepoRequest: + repository: ExternalRepoDetails + connection: ConnectionRequest + + def to_dict(self) -> Dict[str, Any]: + return { + "repository": self.repository.to_dict(), + "connection": self.connection.to_dict(), + } \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/services/AdminService.py b/src/sap_cloud_sdk/dms/services/AdminService.py new file mode 100644 index 0000000..7a20779 --- /dev/null +++ b/src/sap_cloud_sdk/dms/services/AdminService.py @@ -0,0 +1,73 @@ +from typing import Any, Dict, List,Optional + +from sap_cloud_sdk.dms._models import DMSCredentials +from sap_cloud_sdk.dms.model.repository import Repository +from sap_cloud_sdk.dms.services.BaseService import BaseService +from sap_cloud_sdk.dms.model.repository_request import InternalRepoRequest + +_V3_ACCEPT = "application/vnd.sap.sdm.repositories+json;version=3" + + +class AdminService(BaseService): + + def __init__(self, dms_credentials: DMSCredentials, + connect_timeout: Optional[int] = None, + read_timeout: Optional[int] = None,) -> None: + super().__init__(dms_credentials, connect_timeout, read_timeout) + + def get_repositories(self) -> List[Repository]: + """ + Fetch all connected repositories for the current consumer. + + Returns: + List of Repository objects. + + Raises: + DmsException: If the request fails. + + Example: + >>> repos = client.admin.get_repositories() + >>> for repo in repos: + ... print(repo.name, repo.is_encryption_enabled) + """ + + data: Dict[str, Any] = self._get( + "/rest/v2/repositories", + headers={"Accept": _V3_ACCEPT}, + ) + + raw_list: List[Dict[str, Any]] = data.get("repoAndConnectionInfos") or [] + + repos = [ + Repository.from_dict(item.get("repository") or {}) + for item in raw_list + ] + return repos + + def onboard_repository(self, repo_request: InternalRepoRequest) -> Repository: + """ + Onboard a new internal repository. + + Args: + repo_request: InternalRepoRequest object containing repository details. + + Returns: + Repository object representing the newly onboarded repository. + + Raises: + DmsException: If the request fails. + + Example: + >>> repo_req = InternalRepoRequest(display_name="My Repo", is_encryption_enabled=True) + >>> new_repo = client.admin.onboard_repository(repo_req) + >>> print(new_repo.id, new_repo.name) + """ + + payload = repo_request.to_dict() + + data: Dict[str, Any] = self._post( + "/rest/v2/repositories", + json_data=payload + ) + return Repository.from_dict(data) + diff --git a/src/sap_cloud_sdk/dms/services/BaseService.py b/src/sap_cloud_sdk/dms/services/BaseService.py new file mode 100644 index 0000000..d053262 --- /dev/null +++ b/src/sap_cloud_sdk/dms/services/BaseService.py @@ -0,0 +1,85 @@ +import logging +import requests +from typing import Any, Dict, Optional + +from sap_cloud_sdk.dms._models import DMSCredentials +from sap_cloud_sdk.dms.exceptions import DmsException + +logger = logging.getLogger(__name__) + + +class BaseService: + DEFAULT_CONNECT_TIMEOUT: int = 30 + DEFAULT_READ_TIMEOUT: int = 600 + + def __init__( + self, + dms_credentials: DMSCredentials, + connect_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + ) -> None: + self._credentials = dms_credentials + self._session = requests.Session() + self._connect_timeout: int = connect_timeout or self.DEFAULT_CONNECT_TIMEOUT + self._read_timeout: int = read_timeout or self.DEFAULT_READ_TIMEOUT + + def _auth_headers(self) -> Dict[str, str]: + return { + "Authorization": f"Bearer {self._credentials.access_token}", + "User-Agent": "sap-cloud-sdk-python", + } + + def _get( + self, + path: str, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> Any: + req_headers = self._auth_headers().copy() + if headers: + req_headers.update(headers) + resp = self._session.get( + f"{self._credentials.uri}{path}", + headers=req_headers, + params=params, + timeout=(self._connect_timeout, self._read_timeout), + ) + return self._parse_response(resp) + + def _post( + self, + path: str, + json_data: Optional[Dict[str, Any]] = None, + data: Optional[Dict[str, Any]] = None, + files: Optional[Any] = None, + ) -> Any: + resp = self._session.post( + f"{self._credentials.uri}{path}", + headers=self._auth_headers(), + json=json_data, + data=data, + files=files, + timeout=(self._connect_timeout, self._read_timeout), + ) + return self._parse_response(resp) + + def _delete(self, path: str) -> None: + resp = self._session.delete( + f"{self._credentials.uri}{path}", + headers=self._auth_headers(), + timeout=(self._connect_timeout, self._read_timeout), + ) + self._parse_response(resp) + + def _parse_response(self, response: requests.Response) -> Any: + + if response.ok: + if response.status_code == 204 or not response.content: + return None + return response.json() + + raise DmsException( + message=response.reason or f"HTTP {response.status_code}", + status_code=response.status_code, + error_content=response.text or None, + ) \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/services/__init__.py b/src/sap_cloud_sdk/dms/services/__init__.py new file mode 100644 index 0000000..e69de29 From 0ba96b514748745f8292596393f2e5c4bff84786 Mon Sep 17 00:00:00 2001 From: Jagnath Reddy Date: Wed, 18 Mar 2026 00:33:20 +0530 Subject: [PATCH 03/24] clean up --- src/sap_cloud_sdk/dms/__init__.py | 16 ++++----- src/sap_cloud_sdk/dms/client.py | 34 +++++++++++++------ src/sap_cloud_sdk/dms/config.py | 2 +- .../{_models.py => model/dms_credentials.py} | 0 .../dms/model/repository_request.py | 8 ++--- src/sap_cloud_sdk/dms/py.typed | 1 + .../dms/services/AdminService.py | 10 +++--- src/sap_cloud_sdk/dms/services/BaseService.py | 12 ++++--- 8 files changed, 48 insertions(+), 35 deletions(-) rename src/sap_cloud_sdk/dms/{_models.py => model/dms_credentials.py} (100%) create mode 100644 src/sap_cloud_sdk/dms/py.typed diff --git a/src/sap_cloud_sdk/dms/__init__.py b/src/sap_cloud_sdk/dms/__init__.py index 230b58d..145d375 100644 --- a/src/sap_cloud_sdk/dms/__init__.py +++ b/src/sap_cloud_sdk/dms/__init__.py @@ -1,24 +1,20 @@ from typing import Optional -from sap_cloud_sdk.dms._models import DMSCredentials +from sap_cloud_sdk.dms.model.dms_credentials import DMSCredentials from sap_cloud_sdk.dms.client import DMSClient -from sap_cloud_sdk.dms.config import BindingData, load_sdm_config_from_env_or_mount +from sap_cloud_sdk.dms.config import load_sdm_config_from_env_or_mount def create_client( *, instance: Optional[str] = None, - config: Optional[BindingData] = None, dms_cred: Optional[DMSCredentials] = None ): - - if config is not None and dms_cred is not None: - raise ValueError("Cannot provide both config and dms_cred. Please choose one.") - if config is not None: - config.validate() - return DMSClient(config.to_credentials()) if dms_cred is not None: return DMSClient(dms_cred) if instance is not None: return DMSClient(load_sdm_config_from_env_or_mount(instance)) - raise ValueError("No configuration provided. Please provide either instance name, config, or dms_cred.") \ No newline at end of file + raise ValueError("No configuration provided. Please provide either instance name, config, or dms_cred.") + + +__all__ = ["create_client"] \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/client.py b/src/sap_cloud_sdk/dms/client.py index 1f4a0e0..5699e34 100644 --- a/src/sap_cloud_sdk/dms/client.py +++ b/src/sap_cloud_sdk/dms/client.py @@ -1,26 +1,40 @@ -from sap_cloud_sdk.dms._models import DMSCredentials - +from sap_cloud_sdk.dms.model.dms_credentials import DMSCredentials +from sap_cloud_sdk.dms.services.AdminService import AdminService +from typing import Optional class DMSClient: """Client for interacting with the DMS service.""" - def __init__(self, credentials: DMSCredentials): - """Initialize the DMS client with provided credentials. + def __init__( + self, + credentials: DMSCredentials, + connect_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + ): + """Initialize the DMS client with provided credentials and optional timeouts. Args: credentials: DMSCredentials constructed from either BindingData or directly with required fields. + connect_timeout: Optional connect timeout in seconds. + read_timeout: Optional read timeout in seconds. """ - #fetch access token + # fetch access token try: credentials.access_token except Exception as e: raise ValueError(f"Failed to fetch access token: {e}") - + self.credentials = credentials - self._admin = None # Lazy initialization of AdminService + self.connect_timeout = connect_timeout + self.read_timeout = read_timeout + self._admin = AdminService( + self.credentials, + connect_timeout=self.connect_timeout, + read_timeout=self.read_timeout, + ) @property def admin(self) -> AdminService: - if self._admin is None: - self._admin = AdminService(self._base_url, self._token_manager) - return self._admin \ No newline at end of file + return self._admin + + \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/config.py b/src/sap_cloud_sdk/dms/config.py index 1035dcb..47b6b44 100644 --- a/src/sap_cloud_sdk/dms/config.py +++ b/src/sap_cloud_sdk/dms/config.py @@ -5,7 +5,7 @@ from sap_cloud_sdk.core.secret_resolver.resolver import read_from_mount_and_fallback_to_env_var from sap_cloud_sdk.destination.exceptions import ConfigError -from sap_cloud_sdk.dms._models import DMSCredentials +from sap_cloud_sdk.dms.model.dms_credentials import DMSCredentials @dataclass class BindingData: diff --git a/src/sap_cloud_sdk/dms/_models.py b/src/sap_cloud_sdk/dms/model/dms_credentials.py similarity index 100% rename from src/sap_cloud_sdk/dms/_models.py rename to src/sap_cloud_sdk/dms/model/dms_credentials.py diff --git a/src/sap_cloud_sdk/dms/model/repository_request.py b/src/sap_cloud_sdk/dms/model/repository_request.py index 1676998..5130112 100644 --- a/src/sap_cloud_sdk/dms/model/repository_request.py +++ b/src/sap_cloud_sdk/dms/model/repository_request.py @@ -1,10 +1,8 @@ -# sap_cloud_sdk/dms/model/repository_request.py - from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Dict, List, Literal, Optional -# ── Constrained types ───────────────────────────────────────────────────────── +# Constrained types RepositoryCategory = Literal["Collaboration", "Instant", "Favorites"] RepositoryType = Literal["internal", "external"] @@ -22,7 +20,7 @@ def to_dict(self) -> Dict[str, Any]: } -# ── Internal Repository ─────────────────────────────────────────────────────── +#Internal Repository @dataclass class InternalRepoRequest: @@ -71,7 +69,7 @@ def to_dict(self) -> Dict[str, Any]: return payload -# ── External Repository ─────────────────────────────────────────────────────── +#External Repository @dataclass class ExternalRepoDetails: diff --git a/src/sap_cloud_sdk/dms/py.typed b/src/sap_cloud_sdk/dms/py.typed new file mode 100644 index 0000000..86bc7df --- /dev/null +++ b/src/sap_cloud_sdk/dms/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 to indicate the 'dms' package is typed. diff --git a/src/sap_cloud_sdk/dms/services/AdminService.py b/src/sap_cloud_sdk/dms/services/AdminService.py index 7a20779..8f3d5cf 100644 --- a/src/sap_cloud_sdk/dms/services/AdminService.py +++ b/src/sap_cloud_sdk/dms/services/AdminService.py @@ -1,9 +1,9 @@ from typing import Any, Dict, List,Optional -from sap_cloud_sdk.dms._models import DMSCredentials +from sap_cloud_sdk.dms.model.dms_credentials import DMSCredentials from sap_cloud_sdk.dms.model.repository import Repository from sap_cloud_sdk.dms.services.BaseService import BaseService -from sap_cloud_sdk.dms.model.repository_request import InternalRepoRequest +from sap_cloud_sdk.dms.model.repository_request import InternalRepoRequest, ExternalRepoRequest _V3_ACCEPT = "application/vnd.sap.sdm.repositories+json;version=3" @@ -44,12 +44,12 @@ def get_repositories(self) -> List[Repository]: ] return repos - def onboard_repository(self, repo_request: InternalRepoRequest) -> Repository: + def onboard_repository(self, repo_request: Union[InternalRepoRequest, ExternalRepoRequest]) -> Repository: """ Onboard a new internal repository. Args: - repo_request: InternalRepoRequest object containing repository details. + repo_request: InternalRepoRequest or ExternalRepoRequest object containing repository details. Returns: Repository object representing the newly onboarded repository. @@ -67,7 +67,7 @@ def onboard_repository(self, repo_request: InternalRepoRequest) -> Repository: data: Dict[str, Any] = self._post( "/rest/v2/repositories", - json_data=payload + json_data={"repository": payload} ) return Repository.from_dict(data) diff --git a/src/sap_cloud_sdk/dms/services/BaseService.py b/src/sap_cloud_sdk/dms/services/BaseService.py index d053262..c2deb25 100644 --- a/src/sap_cloud_sdk/dms/services/BaseService.py +++ b/src/sap_cloud_sdk/dms/services/BaseService.py @@ -1,8 +1,7 @@ import logging import requests from typing import Any, Dict, Optional - -from sap_cloud_sdk.dms._models import DMSCredentials +from sap_cloud_sdk.dms.model.dms_credentials import DMSCredentials from sap_cloud_sdk.dms.exceptions import DmsException logger = logging.getLogger(__name__) @@ -52,10 +51,15 @@ def _post( json_data: Optional[Dict[str, Any]] = None, data: Optional[Dict[str, Any]] = None, files: Optional[Any] = None, + headers: Optional[Dict[str, str]] = None, ) -> Any: + #merge headers like Content-Type with auth headers if provided + req_headers = self._auth_headers().copy() + if headers: + req_headers.update(headers) resp = self._session.post( f"{self._credentials.uri}{path}", - headers=self._auth_headers(), + headers=req_headers, json=json_data, data=data, files=files, @@ -78,7 +82,7 @@ def _parse_response(self, response: requests.Response) -> Any: return None return response.json() - raise DmsException( + raise DmsException( #TODO make this more specific by parsing error details from response if available message=response.reason or f"HTTP {response.status_code}", status_code=response.status_code, error_content=response.text or None, From 11c33af854b9f93f7d4795c3fc201010c5dfd63a Mon Sep 17 00:00:00 2001 From: Jagnath Reddy Date: Wed, 18 Mar 2026 00:36:21 +0530 Subject: [PATCH 04/24] clean up - minor --- src/sap_cloud_sdk/dms/model/repository.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sap_cloud_sdk/dms/model/repository.py b/src/sap_cloud_sdk/dms/model/repository.py index 8352465..a27879e 100644 --- a/src/sap_cloud_sdk/dms/model/repository.py +++ b/src/sap_cloud_sdk/dms/model/repository.py @@ -83,7 +83,6 @@ def change_log_duration(self) -> Optional[int]: @classmethod def from_dict(cls, data: Dict[str, Any]) -> Repository: - # ✅ typed variable — pins type of p in the comprehension params_raw: List[Dict[str, Any]] = data.get("repositoryParams") or [] return cls( From 9a0723b234fc382b4ff87d8d11007f7ae7d11721 Mon Sep 17 00:00:00 2001 From: Jagnath Reddy Date: Wed, 18 Mar 2026 00:42:34 +0530 Subject: [PATCH 05/24] adding union import --- src/sap_cloud_sdk/dms/services/AdminService.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/dms/services/AdminService.py b/src/sap_cloud_sdk/dms/services/AdminService.py index 8f3d5cf..dfc730d 100644 --- a/src/sap_cloud_sdk/dms/services/AdminService.py +++ b/src/sap_cloud_sdk/dms/services/AdminService.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List,Optional +from typing import Any, Dict, List, Optional, Union from sap_cloud_sdk.dms.model.dms_credentials import DMSCredentials from sap_cloud_sdk.dms.model.repository import Repository From ba7c708a71cdd4dc39d948787163449040d3c9ea Mon Sep 17 00:00:00 2001 From: Jagnath Reddy Date: Wed, 18 Mar 2026 00:44:29 +0530 Subject: [PATCH 06/24] deleting config --- src/sap_cloud_sdk/dms/model/config.py | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 src/sap_cloud_sdk/dms/model/config.py diff --git a/src/sap_cloud_sdk/dms/model/config.py b/src/sap_cloud_sdk/dms/model/config.py deleted file mode 100644 index 313bc1a..0000000 --- a/src/sap_cloud_sdk/dms/model/config.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import Optional, List - -class Config: - def __init__(self, id: str, blocked_file_extensions: Optional[List[str]] = None, tempspace_max_content_size: Optional[int] = None, is_cross_domain_mapping_allowed: Optional[bool] = None): - self.id = id - self.blocked_file_extensions = blocked_file_extensions or [] - self.tempspace_max_content_size = tempspace_max_content_size - self.is_cross_domain_mapping_allowed = is_cross_domain_mapping_allowed From 64c4a5c11a98482adf8f243063a4d17dbc341a5f Mon Sep 17 00:00:00 2001 From: Jagnath Reddy Date: Mon, 23 Mar 2026 16:39:44 +0530 Subject: [PATCH 07/24] redesing --- src/sap_cloud_sdk/dms/__init__.py | 3 +- src/sap_cloud_sdk/dms/_auth.py | 70 ++++++++++ src/sap_cloud_sdk/dms/_http.py | 62 +++++++++ src/sap_cloud_sdk/dms/client.py | 48 +++---- src/sap_cloud_sdk/dms/config.py | 8 +- src/sap_cloud_sdk/dms/exceptions.py | 30 +++-- .../dms/model/dms_credentials.py | 62 --------- src/sap_cloud_sdk/dms/model/model.py | 59 ++++++++ src/sap_cloud_sdk/dms/model/repository.py | 113 ---------------- .../dms/model/repository_request.py | 127 ------------------ .../dms/services/AdminService.py | 73 ---------- src/sap_cloud_sdk/dms/services/BaseService.py | 89 ------------ .../dms/integration}/__init__.py | 0 tests/dms/integration/conftest.py | 28 ++++ 14 files changed, 260 insertions(+), 512 deletions(-) create mode 100644 src/sap_cloud_sdk/dms/_auth.py create mode 100644 src/sap_cloud_sdk/dms/_http.py delete mode 100644 src/sap_cloud_sdk/dms/model/dms_credentials.py create mode 100644 src/sap_cloud_sdk/dms/model/model.py delete mode 100644 src/sap_cloud_sdk/dms/model/repository.py delete mode 100644 src/sap_cloud_sdk/dms/model/repository_request.py delete mode 100644 src/sap_cloud_sdk/dms/services/AdminService.py delete mode 100644 src/sap_cloud_sdk/dms/services/BaseService.py rename {src/sap_cloud_sdk/dms/services => tests/dms/integration}/__init__.py (100%) create mode 100644 tests/dms/integration/conftest.py diff --git a/src/sap_cloud_sdk/dms/__init__.py b/src/sap_cloud_sdk/dms/__init__.py index 145d375..fee368b 100644 --- a/src/sap_cloud_sdk/dms/__init__.py +++ b/src/sap_cloud_sdk/dms/__init__.py @@ -1,5 +1,5 @@ from typing import Optional -from sap_cloud_sdk.dms.model.dms_credentials import DMSCredentials +from sap_cloud_sdk.dms.model.model import DMSCredentials from sap_cloud_sdk.dms.client import DMSClient from sap_cloud_sdk.dms.config import load_sdm_config_from_env_or_mount @@ -16,5 +16,4 @@ def create_client( raise ValueError("No configuration provided. Please provide either instance name, config, or dms_cred.") - __all__ = ["create_client"] \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/_auth.py b/src/sap_cloud_sdk/dms/_auth.py new file mode 100644 index 0000000..4fc5e2c --- /dev/null +++ b/src/sap_cloud_sdk/dms/_auth.py @@ -0,0 +1,70 @@ +import time +import requests +from typing import Optional, TypedDict +from sap_cloud_sdk.dms.exceptions import HttpError +from sap_cloud_sdk.dms.model.model import DMSCredentials + + +class _TokenResponse(TypedDict): + access_token: str + expires_in: int + + +class _CachedToken: + def __init__(self, token: str, expires_at: float) -> None: + self.token = token + self.expires_at = expires_at + + def is_valid(self) -> bool: + return time.monotonic() < self.expires_at - 30 + + +class Auth: + """Fetches and caches OAuth2 access tokens for DMS service requests.""" + + def __init__(self, credentials: DMSCredentials) -> None: + self._credentials = credentials + self._cache: dict[str, _CachedToken] = {} + + def get_token(self, tenant_subdomain: Optional[str] = None) -> str: + cache_key = tenant_subdomain or "techinical" + + cached = self._cache.get(cache_key) + if cached and cached.is_valid(): + return cached.token + + token_url = self._resolve_token_url(tenant_subdomain) + token = self._fetch_token(token_url) + + self._cache[cache_key] = _CachedToken( + token=token["access_token"], + expires_at=time.monotonic() + token.get("expires_in", 3600), + ) + return self._cache[cache_key].token + + def _resolve_token_url(self, tenant_subdomain: Optional[str]) -> str: + if not tenant_subdomain: + return self._credentials.token_url + return self._credentials.token_url.replace( + self._credentials.identityzone, + tenant_subdomain, + ) + + def _fetch_token(self, token_url: str) -> _TokenResponse: + response = requests.post( + f"{token_url}/oauth/token", + data={ + "grant_type": "client_credentials", + "client_id": self._credentials.client_id, + "client_secret": self._credentials.client_secret, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=10, + ) + response.raise_for_status() + payload: _TokenResponse = response.json() + + if not payload.get("access_token"): + raise HttpError("token response missing access_token") + + return payload \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/_http.py b/src/sap_cloud_sdk/dms/_http.py new file mode 100644 index 0000000..ebea68c --- /dev/null +++ b/src/sap_cloud_sdk/dms/_http.py @@ -0,0 +1,62 @@ +from typing import Any, Optional +import requests +from sap_cloud_sdk.dms._auth import Auth +from sap_cloud_sdk.dms.exceptions import HttpError + + +class HttpInvoker: + """Low-level HTTP layer. Injects auth headers and enforces timeouts.""" + + def __init__( + self, + auth: Auth, + base_url: str, + connect_timeout: int | None = None, + read_timeout: int | None = None, + ) -> None: + self._auth = auth + self._base_url = base_url.rstrip("/") + self._connect_timeout = connect_timeout or 10 + self._read_timeout = read_timeout or 30 + + def get(self, path: str, tenant_subdomain: Optional[str] = None) -> Any: + response = requests.get( + f"{self._base_url}{path}", + headers=self._headers(tenant_subdomain), + timeout=(self._connect_timeout, self._read_timeout), + ) + return self._handle(response) + + def post(self, path: str, payload: dict[str, Any], tenant_subdomain: Optional[str] = None) -> Any: + response = requests.post( + f"{self._base_url}{path}", + headers=self._headers(tenant_subdomain), + json=payload, + timeout=(self._connect_timeout, self._read_timeout), + ) + return self._handle(response) + + def delete(self, path: str, tenant_subdomain: Optional[str] = None) -> Any: + response = requests.delete( + f"{self._base_url}{path}", + headers=self._headers(tenant_subdomain), + timeout=(self._connect_timeout, self._read_timeout), + ) + return self._handle(response) + + def _headers(self, tenant_subdomain: Optional[str] = None) -> dict[str, str]: + return { + "Authorization": f"Bearer {self._auth.get_token(tenant_subdomain)}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + def _handle(self, response: requests.Response) -> Any: + if response.status_code in (200, 201, 204): + return response.json() if response.content else None + + raise HttpError( + message=response.text, + status_code=response.status_code, + response_text=response.text, + ) \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/client.py b/src/sap_cloud_sdk/dms/client.py index 5699e34..27be6a1 100644 --- a/src/sap_cloud_sdk/dms/client.py +++ b/src/sap_cloud_sdk/dms/client.py @@ -1,6 +1,8 @@ -from sap_cloud_sdk.dms.model.dms_credentials import DMSCredentials -from sap_cloud_sdk.dms.services.AdminService import AdminService -from typing import Optional +from typing import Any, Optional +from sap_cloud_sdk.dms.model.model import DMSCredentials, InternalRepoRequest +from sap_cloud_sdk.dms._auth import Auth +from sap_cloud_sdk.dms._http import HttpInvoker + class DMSClient: """Client for interacting with the DMS service.""" @@ -10,31 +12,19 @@ def __init__( credentials: DMSCredentials, connect_timeout: Optional[int] = None, read_timeout: Optional[int] = None, - ): - """Initialize the DMS client with provided credentials and optional timeouts. - - Args: - credentials: DMSCredentials constructed from either BindingData or directly with required fields. - connect_timeout: Optional connect timeout in seconds. - read_timeout: Optional read timeout in seconds. - """ - # fetch access token - try: - credentials.access_token - except Exception as e: - raise ValueError(f"Failed to fetch access token: {e}") - - self.credentials = credentials - self.connect_timeout = connect_timeout - self.read_timeout = read_timeout - self._admin = AdminService( - self.credentials, - connect_timeout=self.connect_timeout, - read_timeout=self.read_timeout, + ) -> None: + auth = Auth(credentials) + self._http : HttpInvoker = HttpInvoker( + auth=auth, + base_url=credentials.uri, + connect_timeout=connect_timeout, + read_timeout=read_timeout, ) - @property - def admin(self) -> AdminService: - return self._admin - - \ No newline at end of file + def onboard_repository( + self, + request: InternalRepoRequest, + tenant: Optional[str] = None, + ) -> Any: + """Create a new internal repository.""" + # return self._http.post("/rest/v2/repositories", request.to_dict(), tenant_subdomain) \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/config.py b/src/sap_cloud_sdk/dms/config.py index 47b6b44..985c4ba 100644 --- a/src/sap_cloud_sdk/dms/config.py +++ b/src/sap_cloud_sdk/dms/config.py @@ -5,7 +5,7 @@ from sap_cloud_sdk.core.secret_resolver.resolver import read_from_mount_and_fallback_to_env_var from sap_cloud_sdk.destination.exceptions import ConfigError -from sap_cloud_sdk.dms.model.dms_credentials import DMSCredentials +from sap_cloud_sdk.dms.model.model import DMSCredentials @dataclass class BindingData: @@ -60,7 +60,8 @@ def _validate_uaa(self) -> None: required_fields = { "clientid", "clientsecret", - "url" + "url", + "identityzone" } try: @@ -95,7 +96,8 @@ def to_credentials(self) -> DMSCredentials: uri=self.uri, client_id=uaa_data["clientid"], client_secret=uaa_data["clientsecret"], - token_url=token_url + token_url=token_url, + identityzone=uaa_data["identityzone"] ) diff --git a/src/sap_cloud_sdk/dms/exceptions.py b/src/sap_cloud_sdk/dms/exceptions.py index ba3080e..14d11e8 100644 --- a/src/sap_cloud_sdk/dms/exceptions.py +++ b/src/sap_cloud_sdk/dms/exceptions.py @@ -1,20 +1,22 @@ -from typing import Optional - -class DmsException(Exception): +class DMSError(Exception): """Base exception for all DMS SDK errors.""" + +class HttpError(DMSError): + """Raised for HTTP-related errors from the DMS service. + + Attributes: + status_code: HTTP status code returned by the service, if available. + message: Human-readable error message. + response_text: Raw response payload for diagnostics, if available. + """ + def __init__( self, - message: Optional[str] = None, - status_code: Optional[int] = None, - error_content: Optional[str] = None, - cause: Optional[Exception] = None, + message: str, + status_code: int | None = None, + response_text: str | None = None, ) -> None: - super().__init__(message if message is not None else "") + super().__init__(message) self.status_code = status_code - self.error_content = error_content - if cause is not None: - self.__cause__ = cause - - def __repr__(self) -> str: - return f"DmsException(status_code={self.status_code}, message={str(self)!r})" \ No newline at end of file + self.response_text = response_text \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/model/dms_credentials.py b/src/sap_cloud_sdk/dms/model/dms_credentials.py deleted file mode 100644 index 41e199c..0000000 --- a/src/sap_cloud_sdk/dms/model/dms_credentials.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Data models for DMS service.""" - -from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone -import requests - - -@dataclass -class DMSCredentials: - """Credentials for DMS service access. - - Contains the necessary information to authenticate and connect to the DMS service, - including the service URI and UAA credentials for OAuth2 authentication. - - Token lifecycle is managed manually because the service uses OAuth2 client credentials - grant, which does not issue refresh tokens. Libraries like requests_oauthlib assume - refresh token flow for token renewal and are not suitable here. - """ - instance_name: str - uri: str - client_id: str - client_secret: str - token_url: str - _access_token: str = field(default="", repr=False) - _token_expiry: datetime = field( - default_factory=lambda: datetime.now(tz=timezone.utc), - repr=False - ) - - @property - def access_token(self) -> str: - if not self._access_token or datetime.now(tz=timezone.utc) >= self._token_expiry: - self._access_token, self._token_expiry = self._retrieve_access_token() - return self._access_token - - def _retrieve_access_token(self) -> tuple[str, datetime]: - """Fetch a new OAuth2 token using client credentials grant. - - Raises: - RuntimeError: If the token response is missing access_token. - requests.HTTPError: If the token endpoint returns a non-2xx response. - """ - response = requests.post( - self.token_url, - data={ - "grant_type": "client_credentials", - "client_id": self.client_id, - "client_secret": self.client_secret, - }, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - response.raise_for_status() - - token_response = response.json() - if "access_token" not in token_response: - raise RuntimeError("access_token missing in response") - - expires_in = token_response.get("expires_in", 3600) # fallback 1hr - expiry = datetime.now(tz=timezone.utc) + timedelta(seconds=expires_in) - timedelta(minutes=5) - - return token_response["access_token"], expiry - \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/model/model.py b/src/sap_cloud_sdk/dms/model/model.py new file mode 100644 index 0000000..74cc282 --- /dev/null +++ b/src/sap_cloud_sdk/dms/model/model.py @@ -0,0 +1,59 @@ +"""Data models for DMS service.""" + +from dataclasses import dataclass + +@dataclass +class DMSCredentials: + instance_name: str + uri: str + client_id: str + client_secret: str + token_url: str + identityzone: str + + +from dataclasses import dataclass, field, asdict +from typing import Any, Optional +from enum import Enum + + +class RepositoryType(str, Enum): + INTERNAL = "internal" + + +class RepositoryCategory(str, Enum): + COLLABORATION = "Collaboration" + INSTANT = "Instant" + FAVORITES = "Favorites" + + +@dataclass +class RepositoryParam: + paramName: str + paramValue: str + + +@dataclass +class InternalRepoRequest: + # Required fields + displayName: str + repositoryType: RepositoryType = RepositoryType.INTERNAL + + # Optional fields + description: Optional[str] = None + repositoryCategory: Optional[RepositoryCategory] = None + isVersionEnabled: Optional[bool] = None + isVirusScanEnabled: Optional[bool] = None + skipVirusScanForLargeFile: Optional[bool] = None + hashAlgorithms: Optional[str] = None + isThumbnailEnabled: Optional[bool] = None + isEncryptionEnabled: Optional[bool] = None + isClientCacheEnabled: Optional[bool] = None + externalId: Optional[str] = None + isContentBridgeEnabled: Optional[bool] = None + isAIEnabled: Optional[bool] = None + repositoryParams: list[RepositoryParam] = field(default_factory=lambda: []) + + def to_dict(self) -> dict[str, Any]: + raw: dict[str, Any] = asdict(self) + return {k: v for k, v in raw.items() if v is not None} \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/model/repository.py b/src/sap_cloud_sdk/dms/model/repository.py deleted file mode 100644 index a27879e..0000000 --- a/src/sap_cloud_sdk/dms/model/repository.py +++ /dev/null @@ -1,113 +0,0 @@ -from __future__ import annotations -from dataclasses import dataclass, field -from datetime import datetime, timezone -from typing import Any, Dict, List, Optional - - -@dataclass -class RepositoryParam: - param_name: str - param_value: Any - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> RepositoryParam: - return cls( - param_name=data.get("paramName", ""), - param_value=data.get("paramValue") - ) - - -@dataclass -class Repository: - id: Optional[str] = None - cmis_repository_id: Optional[str] = None - name: Optional[str] = None - repository_type: Optional[str] = None - repository_sub_type: Optional[str] = None - repository_category: Optional[str] = None - created_time: Optional[datetime] = None - last_updated_time: Optional[datetime] = None - repository_params: List[RepositoryParam] = field(default_factory=list) - _params_lookup: Dict[str, Any] = field(default_factory=dict, init=False, repr=False) - - def __post_init__(self) -> None: - params: List[RepositoryParam] = self.repository_params - self._params_lookup: Dict[str, Any] = { - p.param_name: p.param_value for p in params - } - - def _get_param(self, name: str) -> Any: - return self._params_lookup.get(name) - - - # values taken from params list - @property - def is_version_enabled(self) -> Optional[bool]: - return self._get_param("isVersionEnabled") - - @property - def is_virus_scan_enabled(self) -> Optional[bool]: - return self._get_param("isVirusScanEnabled") - - @property - def is_thumbnail_enabled(self) -> Optional[bool]: - return self._get_param("isThumbnailEnabled") - - @property - def is_encryption_enabled(self) -> Optional[bool]: - return self._get_param("isEncryptionEnabled") - - @property - def is_client_cache_enabled(self) -> Optional[bool]: - return self._get_param("isClientCacheEnabled") - - @property - def is_ai_enabled(self) -> Optional[bool]: - return self._get_param("isAIEnabled") - - @property - def is_async_virus_scan_enabled(self) -> Optional[bool]: - return self._get_param("isAsyncVirusScanEnabled") - - @property - def skip_virus_scan_for_large_file(self) -> Optional[bool]: - return self._get_param("skipVirusScanForLargeFile") - - @property - def hash_algorithms(self) -> Optional[str]: - return self._get_param("hashAlgorithms") - - @property - def change_log_duration(self) -> Optional[int]: - return self._get_param("changeLogDuration") - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> Repository: - params_raw: List[Dict[str, Any]] = data.get("repositoryParams") or [] - - return cls( - id=data.get("id"), - cmis_repository_id=data.get("cmisRepositoryId"), - name=data.get("name"), - repository_type=data.get("repositoryType"), - repository_sub_type=data.get("repositorySubType"), - repository_category=data.get("repositoryCategory"), - created_time=_parse_datetime(data.get("createdTime")), - last_updated_time=_parse_datetime(data.get("lastUpdatedTime")), - repository_params=[RepositoryParam.from_dict(p) for p in params_raw], - ) - - def __repr__(self) -> str: - return ( - f"Repository(id={self.id!r}, name={self.name!r}, " - f"type={self.repository_type!r}, " - f"category={self.repository_category!r})" - ) - - -def _parse_datetime(val: Any) -> Optional[datetime]: - if val is None: - return None - if isinstance(val, int): - return datetime.fromtimestamp(val / 1000, tz=timezone.utc) # assuming milliseconds - return datetime.fromisoformat(str(val).replace("Z", "+00:00")) \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/model/repository_request.py b/src/sap_cloud_sdk/dms/model/repository_request.py deleted file mode 100644 index 5130112..0000000 --- a/src/sap_cloud_sdk/dms/model/repository_request.py +++ /dev/null @@ -1,127 +0,0 @@ -from __future__ import annotations -from dataclasses import dataclass, field -from typing import Any, Dict, List, Literal, Optional - -# Constrained types - -RepositoryCategory = Literal["Collaboration", "Instant", "Favorites"] -RepositoryType = Literal["internal", "external"] - - -@dataclass -class RepositoryParam: - param_name: str - param_value: Any - - def to_dict(self) -> Dict[str, Any]: - return { - "paramName": self.param_name, - "paramValue": self.param_value, - } - - -#Internal Repository - -@dataclass -class InternalRepoRequest: - display_name: str - repository_type: RepositoryType = "internal" - description: Optional[str] = None - repository_category: Optional[RepositoryCategory] = None - external_id: Optional[str] = None - is_version_enabled: Optional[bool] = None - is_virus_scan_enabled: Optional[bool] = None - skip_virus_scan_for_large_file: Optional[bool] = None - hash_algorithms: Optional[str] = None - is_thumbnail_enabled: Optional[bool] = None - is_encryption_enabled: Optional[bool] = None - is_client_cache_enabled: Optional[bool] = None - is_content_bridge_enabled: Optional[bool] = None - is_ai_enabled: Optional[bool] = None - repository_params: List[RepositoryParam] = field(default_factory=list) - - def to_dict(self) -> Dict[str, Any]: - payload: Dict[str, Any] = { - "displayName": self.display_name, - "repositoryType": self.repository_type, - } - optional_fields: Dict[str, Any] = { - "description": self.description, - "repositoryCategory": self.repository_category, - "externalId": self.external_id, - "isVersionEnabled": self.is_version_enabled, - "isVirusScanEnabled": self.is_virus_scan_enabled, - "skipVirusScanForLargeFile": self.skip_virus_scan_for_large_file, - "hashAlgorithms": self.hash_algorithms, - "isThumbnailEnabled": self.is_thumbnail_enabled, - "isEncryptionEnabled": self.is_encryption_enabled, - "isClientCacheEnabled": self.is_client_cache_enabled, - "isContentBridgeEnabled": self.is_content_bridge_enabled, - "isAIEnabled": self.is_ai_enabled, - } - for key, value in optional_fields.items(): - if value is not None: - payload[key] = value - - if self.repository_params: - payload["repositoryParams"] = [p.to_dict() for p in self.repository_params] - - return payload - - -#External Repository - -@dataclass -class ExternalRepoDetails: - display_name: str - repository_id: str - repository_type: RepositoryType = "external" - description: Optional[str] = None - external_id: Optional[str] = None - repository_params: List[RepositoryParam] = field(default_factory=list) - - def to_dict(self) -> Dict[str, Any]: - payload: Dict[str, Any] = { - "displayName": self.display_name, - "repositoryType": self.repository_type, - "repositoryId": self.repository_id, - } - optional_fields: Dict[str, Any] = { - "description": self.description, - "externalId": self.external_id, - } - for key, value in optional_fields.items(): - if value is not None: - payload[key] = value - - if self.repository_params: - payload["repositoryParams"] = [p.to_dict() for p in self.repository_params] - - return payload - - -@dataclass -class ConnectionRequest: - destination_name: str - display_name: Optional[str] = None - description: Optional[str] = None - - def to_dict(self) -> Dict[str, Any]: - payload: Dict[str, Any] = {"destinationName": self.destination_name} - if self.display_name is not None: - payload["displayName"] = self.display_name - if self.description is not None: - payload["description"] = self.description - return payload - - -@dataclass -class ExternalRepoRequest: - repository: ExternalRepoDetails - connection: ConnectionRequest - - def to_dict(self) -> Dict[str, Any]: - return { - "repository": self.repository.to_dict(), - "connection": self.connection.to_dict(), - } \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/services/AdminService.py b/src/sap_cloud_sdk/dms/services/AdminService.py deleted file mode 100644 index dfc730d..0000000 --- a/src/sap_cloud_sdk/dms/services/AdminService.py +++ /dev/null @@ -1,73 +0,0 @@ -from typing import Any, Dict, List, Optional, Union - -from sap_cloud_sdk.dms.model.dms_credentials import DMSCredentials -from sap_cloud_sdk.dms.model.repository import Repository -from sap_cloud_sdk.dms.services.BaseService import BaseService -from sap_cloud_sdk.dms.model.repository_request import InternalRepoRequest, ExternalRepoRequest - -_V3_ACCEPT = "application/vnd.sap.sdm.repositories+json;version=3" - - -class AdminService(BaseService): - - def __init__(self, dms_credentials: DMSCredentials, - connect_timeout: Optional[int] = None, - read_timeout: Optional[int] = None,) -> None: - super().__init__(dms_credentials, connect_timeout, read_timeout) - - def get_repositories(self) -> List[Repository]: - """ - Fetch all connected repositories for the current consumer. - - Returns: - List of Repository objects. - - Raises: - DmsException: If the request fails. - - Example: - >>> repos = client.admin.get_repositories() - >>> for repo in repos: - ... print(repo.name, repo.is_encryption_enabled) - """ - - data: Dict[str, Any] = self._get( - "/rest/v2/repositories", - headers={"Accept": _V3_ACCEPT}, - ) - - raw_list: List[Dict[str, Any]] = data.get("repoAndConnectionInfos") or [] - - repos = [ - Repository.from_dict(item.get("repository") or {}) - for item in raw_list - ] - return repos - - def onboard_repository(self, repo_request: Union[InternalRepoRequest, ExternalRepoRequest]) -> Repository: - """ - Onboard a new internal repository. - - Args: - repo_request: InternalRepoRequest or ExternalRepoRequest object containing repository details. - - Returns: - Repository object representing the newly onboarded repository. - - Raises: - DmsException: If the request fails. - - Example: - >>> repo_req = InternalRepoRequest(display_name="My Repo", is_encryption_enabled=True) - >>> new_repo = client.admin.onboard_repository(repo_req) - >>> print(new_repo.id, new_repo.name) - """ - - payload = repo_request.to_dict() - - data: Dict[str, Any] = self._post( - "/rest/v2/repositories", - json_data={"repository": payload} - ) - return Repository.from_dict(data) - diff --git a/src/sap_cloud_sdk/dms/services/BaseService.py b/src/sap_cloud_sdk/dms/services/BaseService.py deleted file mode 100644 index c2deb25..0000000 --- a/src/sap_cloud_sdk/dms/services/BaseService.py +++ /dev/null @@ -1,89 +0,0 @@ -import logging -import requests -from typing import Any, Dict, Optional -from sap_cloud_sdk.dms.model.dms_credentials import DMSCredentials -from sap_cloud_sdk.dms.exceptions import DmsException - -logger = logging.getLogger(__name__) - - -class BaseService: - DEFAULT_CONNECT_TIMEOUT: int = 30 - DEFAULT_READ_TIMEOUT: int = 600 - - def __init__( - self, - dms_credentials: DMSCredentials, - connect_timeout: Optional[int] = None, - read_timeout: Optional[int] = None, - ) -> None: - self._credentials = dms_credentials - self._session = requests.Session() - self._connect_timeout: int = connect_timeout or self.DEFAULT_CONNECT_TIMEOUT - self._read_timeout: int = read_timeout or self.DEFAULT_READ_TIMEOUT - - def _auth_headers(self) -> Dict[str, str]: - return { - "Authorization": f"Bearer {self._credentials.access_token}", - "User-Agent": "sap-cloud-sdk-python", - } - - def _get( - self, - path: str, - params: Optional[Dict[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - ) -> Any: - req_headers = self._auth_headers().copy() - if headers: - req_headers.update(headers) - resp = self._session.get( - f"{self._credentials.uri}{path}", - headers=req_headers, - params=params, - timeout=(self._connect_timeout, self._read_timeout), - ) - return self._parse_response(resp) - - def _post( - self, - path: str, - json_data: Optional[Dict[str, Any]] = None, - data: Optional[Dict[str, Any]] = None, - files: Optional[Any] = None, - headers: Optional[Dict[str, str]] = None, - ) -> Any: - #merge headers like Content-Type with auth headers if provided - req_headers = self._auth_headers().copy() - if headers: - req_headers.update(headers) - resp = self._session.post( - f"{self._credentials.uri}{path}", - headers=req_headers, - json=json_data, - data=data, - files=files, - timeout=(self._connect_timeout, self._read_timeout), - ) - return self._parse_response(resp) - - def _delete(self, path: str) -> None: - resp = self._session.delete( - f"{self._credentials.uri}{path}", - headers=self._auth_headers(), - timeout=(self._connect_timeout, self._read_timeout), - ) - self._parse_response(resp) - - def _parse_response(self, response: requests.Response) -> Any: - - if response.ok: - if response.status_code == 204 or not response.content: - return None - return response.json() - - raise DmsException( #TODO make this more specific by parsing error details from response if available - message=response.reason or f"HTTP {response.status_code}", - status_code=response.status_code, - error_content=response.text or None, - ) \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/services/__init__.py b/tests/dms/integration/__init__.py similarity index 100% rename from src/sap_cloud_sdk/dms/services/__init__.py rename to tests/dms/integration/__init__.py diff --git a/tests/dms/integration/conftest.py b/tests/dms/integration/conftest.py new file mode 100644 index 0000000..d1f3135 --- /dev/null +++ b/tests/dms/integration/conftest.py @@ -0,0 +1,28 @@ +from sap_cloud_sdk.dms import create_client +from tests.destination.integration.conftest import _setup_cloud_mode +import pytest +from pathlib import Path +from dotenv import load_dotenv + + + +@pytest.fixture(scope="session") +def dms_client(): + """Create a DMS client for cloud testing using secret resolver.""" + _setup_cloud_mode() + + try: + # Secret resolver handles configuration automatically from /etc/secrets/appfnd or CLOUD_SDK_CFG + client = create_client() + return client + except Exception as e: + pytest.fail(f"Failed to create DMS client for cloud integration tests: {e}") # ty: ignore[invalid-argument-type] + + + +def _setup_cloud_mode(): + """Common setup for cloud mode integration tests.""" + env_file = Path(__file__).parents[3] / ".env_integration_tests" + if env_file.exists(): + load_dotenv(env_file) + \ No newline at end of file From f809559422b94b1755d2ed23faa4042faf2a1a96 Mon Sep 17 00:00:00 2001 From: Jagnath Reddy Date: Tue, 24 Mar 2026 11:16:35 +0530 Subject: [PATCH 08/24] created overriden headers and CONSTAnTs --- src/sap_cloud_sdk/dms/_auth.py | 2 +- src/sap_cloud_sdk/dms/_endpoints.py | 1 + src/sap_cloud_sdk/dms/_http.py | 37 +++++++++++++++++++++++------ src/sap_cloud_sdk/dms/client.py | 5 ++-- 4 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 src/sap_cloud_sdk/dms/_endpoints.py diff --git a/src/sap_cloud_sdk/dms/_auth.py b/src/sap_cloud_sdk/dms/_auth.py index 4fc5e2c..a4eb61b 100644 --- a/src/sap_cloud_sdk/dms/_auth.py +++ b/src/sap_cloud_sdk/dms/_auth.py @@ -18,7 +18,7 @@ def __init__(self, token: str, expires_at: float) -> None: def is_valid(self) -> bool: return time.monotonic() < self.expires_at - 30 - +#TODO : limit number of access tokens in cache to 10 class Auth: """Fetches and caches OAuth2 access tokens for DMS service requests.""" diff --git a/src/sap_cloud_sdk/dms/_endpoints.py b/src/sap_cloud_sdk/dms/_endpoints.py new file mode 100644 index 0000000..617f0c3 --- /dev/null +++ b/src/sap_cloud_sdk/dms/_endpoints.py @@ -0,0 +1 @@ +REPOSITORIES = "/rest/v2/repositories" \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/_http.py b/src/sap_cloud_sdk/dms/_http.py index ebea68c..ffec410 100644 --- a/src/sap_cloud_sdk/dms/_http.py +++ b/src/sap_cloud_sdk/dms/_http.py @@ -19,38 +19,61 @@ def __init__( self._connect_timeout = connect_timeout or 10 self._read_timeout = read_timeout or 30 - def get(self, path: str, tenant_subdomain: Optional[str] = None) -> Any: + def get( + self, + path: str, + tenant_subdomain: Optional[str] = None, + headers: Optional[dict[str, str]] = None, + ) -> Any: response = requests.get( f"{self._base_url}{path}", - headers=self._headers(tenant_subdomain), + headers=self._merged_headers(tenant_subdomain, headers), timeout=(self._connect_timeout, self._read_timeout), ) return self._handle(response) - def post(self, path: str, payload: dict[str, Any], tenant_subdomain: Optional[str] = None) -> Any: + def post( + self, + path: str, + payload: dict[str, Any], + tenant_subdomain: Optional[str] = None, + headers: Optional[dict[str, str]] = None, + ) -> Any: response = requests.post( f"{self._base_url}{path}", - headers=self._headers(tenant_subdomain), + headers=self._merged_headers(tenant_subdomain, headers), json=payload, timeout=(self._connect_timeout, self._read_timeout), ) return self._handle(response) - def delete(self, path: str, tenant_subdomain: Optional[str] = None) -> Any: + def delete( + self, + path: str, + tenant_subdomain: Optional[str] = None, + headers: Optional[dict[str, str]] = None, + ) -> Any: response = requests.delete( f"{self._base_url}{path}", - headers=self._headers(tenant_subdomain), + headers=self._merged_headers(tenant_subdomain, headers), timeout=(self._connect_timeout, self._read_timeout), ) return self._handle(response) - def _headers(self, tenant_subdomain: Optional[str] = None) -> dict[str, str]: + def _default_headers(self, tenant_subdomain: Optional[str] = None) -> dict[str, str]: return { "Authorization": f"Bearer {self._auth.get_token(tenant_subdomain)}", "Content-Type": "application/json", "Accept": "application/json", } + def _merged_headers( + self, + tenant_subdomain: Optional[str], + overrides: Optional[dict[str, str]], + ) -> dict[str, str]: + return {**self._default_headers(tenant_subdomain), **(overrides or {})} + def _handle(self, response: requests.Response) -> Any: if response.status_code in (200, 201, 204): return response.json() if response.content else None diff --git a/src/sap_cloud_sdk/dms/client.py b/src/sap_cloud_sdk/dms/client.py index 27be6a1..2e100b7 100644 --- a/src/sap_cloud_sdk/dms/client.py +++ b/src/sap_cloud_sdk/dms/client.py @@ -2,7 +2,7 @@ from sap_cloud_sdk.dms.model.model import DMSCredentials, InternalRepoRequest from sap_cloud_sdk.dms._auth import Auth from sap_cloud_sdk.dms._http import HttpInvoker - +from sap_cloud_sdk.dms import _endpoints as endpoints class DMSClient: """Client for interacting with the DMS service.""" @@ -27,4 +27,5 @@ def onboard_repository( tenant: Optional[str] = None, ) -> Any: """Create a new internal repository.""" - # return self._http.post("/rest/v2/repositories", request.to_dict(), tenant_subdomain) \ No newline at end of file + return self._http.post(endpoints.REPOSITORIES, request.to_dict(), tenant) + From f2a388180f27db7f59e4f964ea7be5dde780642d Mon Sep 17 00:00:00 2001 From: Jagnath Reddy Date: Tue, 24 Mar 2026 12:25:46 +0530 Subject: [PATCH 09/24] added support for ecmuser and principals headers --- src/sap_cloud_sdk/dms/_http.py | 27 +++++++++++++++++++++++---- src/sap_cloud_sdk/dms/model/model.py | 18 ++++++++++++++++-- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/sap_cloud_sdk/dms/_http.py b/src/sap_cloud_sdk/dms/_http.py index ffec410..7602038 100644 --- a/src/sap_cloud_sdk/dms/_http.py +++ b/src/sap_cloud_sdk/dms/_http.py @@ -2,6 +2,7 @@ import requests from sap_cloud_sdk.dms._auth import Auth from sap_cloud_sdk.dms.exceptions import HttpError +from sap_cloud_sdk.dms.model.model import UserClaim class HttpInvoker: @@ -24,10 +25,11 @@ def get( path: str, tenant_subdomain: Optional[str] = None, headers: Optional[dict[str, str]] = None, + user_claim: Optional[UserClaim] = None, ) -> Any: response = requests.get( f"{self._base_url}{path}", - headers=self._merged_headers(tenant_subdomain, headers), + headers=self._merged_headers(tenant_subdomain, headers, user_claim), timeout=(self._connect_timeout, self._read_timeout), ) return self._handle(response) @@ -38,10 +40,11 @@ def post( payload: dict[str, Any], tenant_subdomain: Optional[str] = None, headers: Optional[dict[str, str]] = None, + user_claim: Optional[UserClaim] = None, ) -> Any: response = requests.post( f"{self._base_url}{path}", - headers=self._merged_headers(tenant_subdomain, headers), + headers=self._merged_headers(tenant_subdomain, headers, user_claim), json=payload, timeout=(self._connect_timeout, self._read_timeout), ) @@ -52,10 +55,11 @@ def delete( path: str, tenant_subdomain: Optional[str] = None, headers: Optional[dict[str, str]] = None, + user_claim: Optional[UserClaim] = None, ) -> Any: response = requests.delete( f"{self._base_url}{path}", - headers=self._merged_headers(tenant_subdomain, headers), + headers=self._merged_headers(tenant_subdomain, headers, user_claim), timeout=(self._connect_timeout, self._read_timeout), ) return self._handle(response) @@ -67,12 +71,27 @@ def _default_headers(self, tenant_subdomain: Optional[str] = None) -> dict[str, "Accept": "application/json", } + def _user_claim_headers(self, user_claim: Optional[UserClaim]) -> dict[str, str]: + if not user_claim: + return {} + headers: dict[str, str] = {} + if user_claim.x_ecm_user_enc: + headers["X-EcmUserEnc"] = user_claim.x_ecm_user_enc + if user_claim.x_ecm_add_principals: + headers["X-EcmAddPrincipals"] = ";".join(user_claim.x_ecm_add_principals) + return headers + def _merged_headers( self, tenant_subdomain: Optional[str], overrides: Optional[dict[str, str]], + user_claim: Optional[UserClaim] = None, ) -> dict[str, str]: - return {**self._default_headers(tenant_subdomain), **(overrides or {})} + return { + **self._default_headers(tenant_subdomain), + **self._user_claim_headers(user_claim), + **(overrides or {}), + } def _handle(self, response: requests.Response) -> Any: if response.status_code in (200, 201, 204): diff --git a/src/sap_cloud_sdk/dms/model/model.py b/src/sap_cloud_sdk/dms/model/model.py index 74cc282..80c8503 100644 --- a/src/sap_cloud_sdk/dms/model/model.py +++ b/src/sap_cloud_sdk/dms/model/model.py @@ -13,7 +13,7 @@ class DMSCredentials: from dataclasses import dataclass, field, asdict -from typing import Any, Optional +from typing import Any, List, Optional from enum import Enum @@ -56,4 +56,18 @@ class InternalRepoRequest: def to_dict(self) -> dict[str, Any]: raw: dict[str, Any] = asdict(self) - return {k: v for k, v in raw.items() if v is not None} \ No newline at end of file + return {k: v for k, v in raw.items() if v is not None} + + +@dataclass +class UserClaim: + """Represents user identity claims forwarded to the DMS service. + + Attributes: + x_ecm_user_enc: User identifier (e.g. username or email) passed as a request header. + x_ecm_add_principals: Additional principals to include in the request. + - Groups: prefix the group name with ``~`` (e.g. ``~group1``) + - Extra identifiers: plain username or email (e.g. ``username2``) + """ + x_ecm_user_enc: Optional[str] = None + x_ecm_add_principals: Optional[List[str]] = field(default_factory=lambda: []) \ No newline at end of file From 443dd53caf797c4cd72d7893631520d8eb78e887 Mon Sep 17 00:00:00 2001 From: Jagnath Reddy Date: Wed, 25 Mar 2026 15:02:57 +0530 Subject: [PATCH 10/24] added telemetry with new admin apis and models --- src/sap_cloud_sdk/core/telemetry/module.py | 1 + src/sap_cloud_sdk/core/telemetry/operation.py | 12 + src/sap_cloud_sdk/dms/__init__.py | 2 +- src/sap_cloud_sdk/dms/_auth.py | 57 +++- src/sap_cloud_sdk/dms/_endpoints.py | 3 +- src/sap_cloud_sdk/dms/_http.py | 133 ++++++-- src/sap_cloud_sdk/dms/client.py | 306 ++++++++++++++++- src/sap_cloud_sdk/dms/config.py | 2 +- src/sap_cloud_sdk/dms/exceptions.py | 40 ++- src/sap_cloud_sdk/dms/model.py | 314 ++++++++++++++++++ src/sap_cloud_sdk/dms/model/model.py | 73 ---- 11 files changed, 795 insertions(+), 148 deletions(-) create mode 100644 src/sap_cloud_sdk/dms/model.py delete mode 100644 src/sap_cloud_sdk/dms/model/model.py diff --git a/src/sap_cloud_sdk/core/telemetry/module.py b/src/sap_cloud_sdk/core/telemetry/module.py index b7d592b..6abb913 100644 --- a/src/sap_cloud_sdk/core/telemetry/module.py +++ b/src/sap_cloud_sdk/core/telemetry/module.py @@ -10,6 +10,7 @@ class Module(str, Enum): AUDITLOG = "auditlog" DESTINATION = "destination" OBJECTSTORE = "objectstore" + DMS = "dms" def __str__(self) -> str: return self.value diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index f640953..0667dca 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -52,5 +52,17 @@ class Operation(str, Enum): AICORE_SET_CONFIG = "set_aicore_config" AICORE_AUTO_INSTRUMENT = "auto_instrument" + + # DMS Operations + DMS_ONBOARD_REPOSITORY = "onboard_repository" + DMS_GET_REPOSITORY = "get_repository" + DMS_GET_ALL_REPOSITORIES = "get_all_repositories" + DMS_UPDATE_REPOSITORY = "update_repository" + DMS_DELETE_REPOSITORY = "delete_repository" + DMS_CREATE_CONFIG = "create_config" + DMS_GET_CONFIGS = "get_configs" + DMS_UPDATE_CONFIG = "update_config" + DMS_DELETE_CONFIG = "delete_config" + def __str__(self) -> str: return self.value diff --git a/src/sap_cloud_sdk/dms/__init__.py b/src/sap_cloud_sdk/dms/__init__.py index fee368b..c361345 100644 --- a/src/sap_cloud_sdk/dms/__init__.py +++ b/src/sap_cloud_sdk/dms/__init__.py @@ -1,5 +1,5 @@ from typing import Optional -from sap_cloud_sdk.dms.model.model import DMSCredentials +from sap_cloud_sdk.dms.model import DMSCredentials from sap_cloud_sdk.dms.client import DMSClient from sap_cloud_sdk.dms.config import load_sdm_config_from_env_or_mount diff --git a/src/sap_cloud_sdk/dms/_auth.py b/src/sap_cloud_sdk/dms/_auth.py index a4eb61b..8e1f8c6 100644 --- a/src/sap_cloud_sdk/dms/_auth.py +++ b/src/sap_cloud_sdk/dms/_auth.py @@ -1,8 +1,12 @@ +import logging import time import requests +from requests.exceptions import RequestException from typing import Optional, TypedDict -from sap_cloud_sdk.dms.exceptions import HttpError -from sap_cloud_sdk.dms.model.model import DMSCredentials +from sap_cloud_sdk.dms.exceptions import DMSError, DMSConnectionError, DMSPermissionDeniedException +from sap_cloud_sdk.dms.model import DMSCredentials + +logger = logging.getLogger(__name__) class _TokenResponse(TypedDict): @@ -18,7 +22,8 @@ def __init__(self, token: str, expires_at: float) -> None: def is_valid(self) -> bool: return time.monotonic() < self.expires_at - 30 -#TODO : limit number of access tokens in cache to 10 + +# TODO: limit number of access tokens in cache to 10 class Auth: """Fetches and caches OAuth2 access tokens for DMS service requests.""" @@ -27,12 +32,14 @@ def __init__(self, credentials: DMSCredentials) -> None: self._cache: dict[str, _CachedToken] = {} def get_token(self, tenant_subdomain: Optional[str] = None) -> str: - cache_key = tenant_subdomain or "techinical" + cache_key = tenant_subdomain or "technical" cached = self._cache.get(cache_key) if cached and cached.is_valid(): + logger.debug("Using cached token for key '%s'", cache_key) return cached.token + logger.debug("Fetching new token for key '%s'", cache_key) token_url = self._resolve_token_url(tenant_subdomain) token = self._fetch_token(token_url) @@ -40,31 +47,47 @@ def get_token(self, tenant_subdomain: Optional[str] = None) -> str: token=token["access_token"], expires_at=time.monotonic() + token.get("expires_in", 3600), ) + logger.debug("Token cached for key '%s'", cache_key) return self._cache[cache_key].token def _resolve_token_url(self, tenant_subdomain: Optional[str]) -> str: if not tenant_subdomain: return self._credentials.token_url + logger.debug("Resolving token URL for tenant '%s'", tenant_subdomain) return self._credentials.token_url.replace( self._credentials.identityzone, tenant_subdomain, ) def _fetch_token(self, token_url: str) -> _TokenResponse: - response = requests.post( - f"{token_url}/oauth/token", - data={ - "grant_type": "client_credentials", - "client_id": self._credentials.client_id, - "client_secret": self._credentials.client_secret, - }, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - timeout=10, - ) - response.raise_for_status() - payload: _TokenResponse = response.json() + try: + response = requests.post( + f"{token_url}/oauth/token", + data={ + "grant_type": "client_credentials", + "client_id": self._credentials.client_id, + "client_secret": self._credentials.client_secret, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=10, + ) + response.raise_for_status() + except requests.exceptions.ConnectionError as e: + logger.error("Failed to connect to token endpoint") + raise DMSConnectionError("Failed to connect to the authentication server") from e + except requests.exceptions.HTTPError as e: + status = e.response.status_code if e.response is not None else None + logger.error("Token request failed with status %s", status) + if status in (401, 403): + raise DMSPermissionDeniedException("Authentication failed — invalid client credentials", status) from e + raise DMSError("Failed to obtain access token", status) from e + except RequestException as e: + logger.error("Unexpected error during token fetch") + raise DMSConnectionError("Unexpected error during authentication") from e + payload: _TokenResponse = response.json() if not payload.get("access_token"): - raise HttpError("token response missing access_token") + raise DMSError("Token response missing access_token") + logger.debug("Token fetched successfully") return payload \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/_endpoints.py b/src/sap_cloud_sdk/dms/_endpoints.py index 617f0c3..aef2cce 100644 --- a/src/sap_cloud_sdk/dms/_endpoints.py +++ b/src/sap_cloud_sdk/dms/_endpoints.py @@ -1 +1,2 @@ -REPOSITORIES = "/rest/v2/repositories" \ No newline at end of file +REPOSITORIES = "/rest/v2/repositories" +CONFIGS = "/rest/v2/configs" \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/_http.py b/src/sap_cloud_sdk/dms/_http.py index 7602038..b8b2c8e 100644 --- a/src/sap_cloud_sdk/dms/_http.py +++ b/src/sap_cloud_sdk/dms/_http.py @@ -1,8 +1,20 @@ +import logging from typing import Any, Optional +from requests import Response import requests +from requests.exceptions import RequestException from sap_cloud_sdk.dms._auth import Auth -from sap_cloud_sdk.dms.exceptions import HttpError -from sap_cloud_sdk.dms.model.model import UserClaim +from sap_cloud_sdk.dms.exceptions import ( + DMSError, + DMSConnectionError, + DMSInvalidArgumentException, + DMSObjectNotFoundException, + DMSPermissionDeniedException, + DMSRuntimeException, +) +from sap_cloud_sdk.dms.model import UserClaim + +logger = logging.getLogger(__name__) class HttpInvoker: @@ -26,13 +38,15 @@ def get( tenant_subdomain: Optional[str] = None, headers: Optional[dict[str, str]] = None, user_claim: Optional[UserClaim] = None, - ) -> Any: - response = requests.get( - f"{self._base_url}{path}", - headers=self._merged_headers(tenant_subdomain, headers, user_claim), - timeout=(self._connect_timeout, self._read_timeout), - ) - return self._handle(response) + ) -> Response: + logger.debug("GET %s", path) + return self._handle(self._execute( + lambda: requests.get( + f"{self._base_url}{path}", + headers=self._merged_headers(tenant_subdomain, headers, user_claim), + timeout=(self._connect_timeout, self._read_timeout), + ) + )) def post( self, @@ -41,14 +55,34 @@ def post( tenant_subdomain: Optional[str] = None, headers: Optional[dict[str, str]] = None, user_claim: Optional[UserClaim] = None, - ) -> Any: - response = requests.post( - f"{self._base_url}{path}", - headers=self._merged_headers(tenant_subdomain, headers, user_claim), - json=payload, - timeout=(self._connect_timeout, self._read_timeout), - ) - return self._handle(response) + ) -> Response: + logger.debug("POST %s", path) + return self._handle(self._execute( + lambda: requests.post( + f"{self._base_url}{path}", + headers=self._merged_headers(tenant_subdomain, headers, user_claim), + json=payload, + timeout=(self._connect_timeout, self._read_timeout), + ) + )) + + def put( + self, + path: str, + payload: dict[str, Any], + tenant_subdomain: Optional[str] = None, + headers: Optional[dict[str, str]] = None, + user_claim: Optional[UserClaim] = None, + ) -> Response: + logger.debug("PUT %s", path) + return self._handle(self._execute( + lambda: requests.put( + f"{self._base_url}{path}", + headers=self._merged_headers(tenant_subdomain, headers, user_claim), + json=payload, + timeout=(self._connect_timeout, self._read_timeout), + ) + )) def delete( self, @@ -56,13 +90,29 @@ def delete( tenant_subdomain: Optional[str] = None, headers: Optional[dict[str, str]] = None, user_claim: Optional[UserClaim] = None, - ) -> Any: - response = requests.delete( - f"{self._base_url}{path}", - headers=self._merged_headers(tenant_subdomain, headers, user_claim), - timeout=(self._connect_timeout, self._read_timeout), - ) - return self._handle(response) + ) -> Response: + logger.debug("DELETE %s", path) + return self._handle(self._execute( + lambda: requests.delete( + f"{self._base_url}{path}", + headers=self._merged_headers(tenant_subdomain, headers, user_claim), + timeout=(self._connect_timeout, self._read_timeout), + ) + )) + + def _execute(self, fn: Any) -> Response: + """Execute an HTTP call, wrapping network errors into DMSConnectionError.""" + try: + return fn() + except requests.exceptions.ConnectionError as e: + logger.error("Connection error during HTTP request") + raise DMSConnectionError("Failed to connect to the DMS service") from e + except requests.exceptions.Timeout as e: + logger.error("Request timed out") + raise DMSConnectionError("Request to DMS service timed out") from e + except RequestException as e: + logger.error("Unexpected network error") + raise DMSConnectionError("Unexpected network error") from e def _default_headers(self, tenant_subdomain: Optional[str] = None) -> dict[str, str]: return { @@ -93,12 +143,33 @@ def _merged_headers( **(overrides or {}), } - def _handle(self, response: requests.Response) -> Any: + def _handle(self, response: Response) -> Response: + logger.debug("Response status: %s", response.status_code) if response.status_code in (200, 201, 204): - return response.json() if response.content else None + return response + + # error_content kept for debugging but not surfaced in the exception message + error_content = response.text + logger.warning("Request failed with status %s", response.status_code) - raise HttpError( - message=response.text, - status_code=response.status_code, - response_text=response.text, - ) \ No newline at end of file + match response.status_code: + case 400: + raise DMSInvalidArgumentException( + "Request contains invalid or disallowed parameters", 400, error_content + ) + case 401 | 403: + raise DMSPermissionDeniedException( + "Access denied — invalid or expired token", response.status_code, error_content + ) + case 404: + raise DMSObjectNotFoundException( + "The requested resource was not found", 404, error_content + ) + case 500: + raise DMSRuntimeException( + "The DMS service encountered an internal error", 500, error_content + ) + case _: + raise DMSError( + f"Unexpected response from DMS service : "+error_content, response.status_code, error_content + ) \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/client.py b/src/sap_cloud_sdk/dms/client.py index 2e100b7..6e81b39 100644 --- a/src/sap_cloud_sdk/dms/client.py +++ b/src/sap_cloud_sdk/dms/client.py @@ -1,11 +1,19 @@ -from typing import Any, Optional -from sap_cloud_sdk.dms.model.model import DMSCredentials, InternalRepoRequest +import logging +from typing import Optional +from sap_cloud_sdk.dms.model import ( + DMSCredentials, InternalRepoRequest, Repository, UserClaim, + UpdateRepoRequest, CreateConfigRequest, RepositoryConfig, UpdateConfigRequest, +) from sap_cloud_sdk.dms._auth import Auth from sap_cloud_sdk.dms._http import HttpInvoker from sap_cloud_sdk.dms import _endpoints as endpoints +from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics + +logger = logging.getLogger(__name__) + class DMSClient: - """Client for interacting with the DMS service.""" + """Client for interacting with the SAP Document Management Service Admin API.""" def __init__( self, @@ -14,18 +22,302 @@ def __init__( read_timeout: Optional[int] = None, ) -> None: auth = Auth(credentials) - self._http : HttpInvoker = HttpInvoker( + self._http: HttpInvoker = HttpInvoker( auth=auth, base_url=credentials.uri, connect_timeout=connect_timeout, read_timeout=read_timeout, ) + logger.debug("DMSClient initialized for instance '%s'", credentials.instance_name) + @record_metrics(Module.DMS, Operation.DMS_ONBOARD_REPOSITORY) def onboard_repository( self, request: InternalRepoRequest, tenant: Optional[str] = None, - ) -> Any: - """Create a new internal repository.""" - return self._http.post(endpoints.REPOSITORIES, request.to_dict(), tenant) + user_claim: Optional[UserClaim] = None, + ) -> Repository: + """Onboard a new internal repository. + + Args: + request: The repository creation request payload. + tenant: Optional tenant subdomain to scope the request. + user_claim: Optional user identity claims. + + Returns: + Repository: The created repository instance. + + Raises: + DMSInvalidArgumentException: If the request payload is invalid. + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + logger.info("Onboarding repository '%s'", request.to_dict()) + response = self._http.post( + path=endpoints.REPOSITORIES, + payload={"repository": request.to_dict()}, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + repo = Repository.from_dict(response.json()) + logger.info("Repository onboarded successfully with id '%s'", repo.id) + return repo + + @record_metrics(Module.DMS, Operation.DMS_GET_ALL_REPOSITORIES) + def get_all_repositories( + self, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> list[Repository]: + """Retrieve all onboarded repositories. + + Args: + tenant: Optional tenant subdomain. + user_claim: Optional user identity claims. + + Returns: + list[Repository]: List of all repositories. + + Raises: + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + logger.info("Fetching all repositories") + response = self._http.get( + path=endpoints.REPOSITORIES, + tenant_subdomain=tenant, + user_claim=user_claim, + headers={"Accept": "application/vnd.sap.sdm.repositories+json;version=3"}, + ) + data = response.json() + infos = data.get("repoAndConnectionInfos", []) + repos = [Repository.from_dict(item["repository"]) for item in infos] + logger.info("Fetched %d repositories", len(repos)) + return repos + + + @record_metrics(Module.DMS, Operation.DMS_GET_REPOSITORY) + def get_repository( + self, + repo_id: str, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Repository: + """Retrieve details of a specific repository. + + Args: + repo_id: The repository UUID. + tenant: Optional tenant subdomain. + user_claim: Optional user identity claims. + + Returns: + Repository: The repository details. + + Raises: + DMSObjectNotFoundException: If the repository does not exist. + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + logger.info("Fetching repository '%s'", repo_id) + response = self._http.get( + path=f"{endpoints.REPOSITORIES}/{repo_id}", + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return Repository.from_dict(response.json()["repository"]) + + + @record_metrics(Module.DMS, Operation.DMS_UPDATE_REPOSITORY) + def update_repository( + self, + repo_id: str, + request: UpdateRepoRequest, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Repository: + """Update metadata parameters of a repository. + + Args: + repo_id: The repository UUID. + request: The update request payload. + tenant: Optional tenant subdomain. + user_claim: Optional user identity claims. + + Returns: + Repository: The updated repository. + + Raises: + DMSObjectNotFoundException: If the repository does not exist. + DMSInvalidArgumentException: If the request payload is invalid. + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + if not repo_id or not repo_id.strip(): + raise ValueError("repo_id must not be empty") + logger.info("Updating repository '%s'", repo_id) + response = self._http.put( + path=f"{endpoints.REPOSITORIES}/{repo_id}", + payload=request.to_dict(), + tenant_subdomain=tenant, + user_claim=user_claim, + ) + repo = Repository.from_dict(response.json()) + logger.info("Repository '%s' updated successfully", repo_id) + return repo + + + @record_metrics(Module.DMS, Operation.DMS_DELETE_REPOSITORY) + def delete_repository( + self, + repo_id: str, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> None: + """Delete a specific repository. + Args: + repo_id: The repository UUID. + tenant: Optional tenant subdomain. + user_claim: Optional user identity claims. + + Raises: + DMSObjectNotFoundException: If the repository does not exist. + DMSInvalidArgumentException: If the request payload is invalid. + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + self._http.delete( + path=f"{endpoints.REPOSITORIES}/{repo_id}", + tenant_subdomain=tenant, + user_claim=user_claim, + ) + + + @record_metrics(Module.DMS, Operation.DMS_CREATE_CONFIG) + def create_config( + self, + request: CreateConfigRequest, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> RepositoryConfig: + """Create a new repository configuration. + + Args: + request: The config creation request payload. + tenant: Optional tenant subdomain. + user_claim: Optional user identity claims. + + Returns: + RepositoryConfig: The created configuration. + + Raises: + DMSInvalidArgumentException: If the request payload is invalid. + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + logger.info("Creating config '%s'", request.config_name) + response = self._http.post( + path=endpoints.CONFIGS, + payload=request.to_dict(), + tenant_subdomain=tenant, + user_claim=user_claim, + ) + config = RepositoryConfig.from_dict(response.json()) + logger.info("Config created successfully with id '%s'", config.id) + return config + + + @record_metrics(Module.DMS, Operation.DMS_GET_CONFIGS) + def get_configs( + self, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> list[RepositoryConfig]: + """Retrieve all repository configurations. + + Args: + tenant: Optional tenant subdomain. + user_claim: Optional user identity claims. + + Returns: + list[RepositoryConfig]: List of all configurations. + + Raises: + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + logger.info("Fetching all configs") + response = self._http.get( + path=endpoints.CONFIGS, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + configs = [RepositoryConfig.from_dict(c) for c in response.json()] + logger.info("Fetched %d configs", len(configs)) + return configs + + @record_metrics(Module.DMS, Operation.DMS_UPDATE_CONFIG) + def update_config( + self, + config_id: str, + request: UpdateConfigRequest, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> RepositoryConfig: + """Update a repository configuration. + + Args: + config_id: The configuration UUID. + request: The update request payload. + tenant: Optional tenant subdomain. + user_claim: Optional user identity claims. + + Returns: + RepositoryConfig: The updated configuration. + + Raises: + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + if not config_id or not config_id.strip(): + raise ValueError("config_id must not be empty") + logger.info("Updating config '%s'", config_id) + response = self._http.put( + path=f"{endpoints.CONFIGS}/{config_id}", + payload=request.to_dict(), + tenant_subdomain=tenant, + user_claim=user_claim, + ) + config = RepositoryConfig.from_dict(response.json()) + logger.info("Config '%s' updated successfully", config_id) + return config + + @record_metrics(Module.DMS, Operation.DMS_DELETE_CONFIG) + def delete_config( + self, + config_id: str, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> None: + """Delete a repository configuration. + + Args: + config_id: The configuration UUID. + tenant: Optional tenant subdomain. + user_claim: Optional user identity claims. + + Raises: + ValueError: If config_id is empty. + DMSObjectNotFoundException: If the config does not exist. + DMSPermissionDeniedException: If the access token is invalid or expired. + DMSRuntimeException: If the server encounters an internal error. + """ + if not config_id or not config_id.strip(): + raise ValueError("config_id must not be empty") + logger.info("Deleting config '%s'", config_id) + self._http.delete( + path=f"{endpoints.CONFIGS}/{config_id}", + tenant_subdomain=tenant, + user_claim=user_claim, + ) + logger.info("Config '%s' deleted successfully", config_id) \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/config.py b/src/sap_cloud_sdk/dms/config.py index 985c4ba..9f740e5 100644 --- a/src/sap_cloud_sdk/dms/config.py +++ b/src/sap_cloud_sdk/dms/config.py @@ -5,7 +5,7 @@ from sap_cloud_sdk.core.secret_resolver.resolver import read_from_mount_and_fallback_to_env_var from sap_cloud_sdk.destination.exceptions import ConfigError -from sap_cloud_sdk.dms.model.model import DMSCredentials +from sap_cloud_sdk.dms.model import DMSCredentials @dataclass class BindingData: diff --git a/src/sap_cloud_sdk/dms/exceptions.py b/src/sap_cloud_sdk/dms/exceptions.py index 14d11e8..d20d96e 100644 --- a/src/sap_cloud_sdk/dms/exceptions.py +++ b/src/sap_cloud_sdk/dms/exceptions.py @@ -1,22 +1,28 @@ +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + class DMSError(Exception): - """Base exception for all DMS SDK errors.""" + """Base exception for all DMS errors.""" + def __init__(self, message: Optional[str] = None, status_code: Optional[int] = None, error_content: str = "") -> None: + self.status_code = status_code + self.error_content = error_content + super().__init__(message) -class HttpError(DMSError): - """Raised for HTTP-related errors from the DMS service. +class DMSObjectNotFoundException(DMSError): + """The specified repository or resource does not exist.""" - Attributes: - status_code: HTTP status code returned by the service, if available. - message: Human-readable error message. - response_text: Raw response payload for diagnostics, if available. - """ +class DMSPermissionDeniedException(DMSError): + """Access token is invalid, expired, or lacks required permissions.""" - def __init__( - self, - message: str, - status_code: int | None = None, - response_text: str | None = None, - ) -> None: - super().__init__(message) - self.status_code = status_code - self.response_text = response_text \ No newline at end of file +class DMSInvalidArgumentException(DMSError): + """The request payload contains invalid or disallowed parameters.""" + +class DMSConnectionError(DMSError): + """A network or connection failure occurred.""" + +class DMSRuntimeException(DMSError): + """Unexpected server-side error.""" \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/model.py b/src/sap_cloud_sdk/dms/model.py new file mode 100644 index 0000000..25334a6 --- /dev/null +++ b/src/sap_cloud_sdk/dms/model.py @@ -0,0 +1,314 @@ +"""Data models for DMS service.""" + +from dataclasses import dataclass, field, asdict +from datetime import datetime +from enum import Enum +from typing import Any, List, Optional, TypedDict, cast +from urllib.parse import urlparse + +def _serialize(v: Any) -> Any: + """Recursively serialize values — converts Enums to their values, handles nested dicts/lists.""" + if isinstance(v, Enum): + return v.value + if isinstance(v, dict): + d: dict[str, Any] = cast(dict[str, Any],v) + return {str(k): _serialize(val) for k, val in d.items()} + if isinstance(v, list): + lst: list[Any] = cast(list[Any],v) + return [_serialize(i) for i in lst] + return v + + +def _to_dict_drop_none(obj: Any) -> dict[str, Any]: + """Convert a dataclass to a dict, dropping None values and serializing enums.""" + raw: dict[str, Any] = asdict(obj) + return {k: _serialize(v) for k, v in raw.items() if v is not None} + + + +@dataclass +class DMSCredentials: + """Credentials for authenticating with the DMS service.""" + instance_name: str + uri: str + client_id: str + client_secret: str + token_url: str + identityzone: str + + def __post_init__(self) -> None: + self._validate() + + def _validate(self) -> None: + placeholders = { + k: v for k, v in { + "uri": self.uri, + "token_url": self.token_url, + "client_id": self.client_id, + "client_secret": self.client_secret, + "identityzone": self.identityzone, + }.items() + if not v or v.startswith("<") or v.endswith(">") + } + if placeholders: + raise ValueError( + f"DMSCredentials contains unfilled placeholder values: {list(placeholders.keys())}. " + "Replace all <...> values with real credentials before creating a client." + ) + for fname, value in {"uri": self.uri, "token_url": self.token_url}.items(): + parsed = urlparse(value) + if not parsed.scheme or not parsed.netloc: + raise ValueError(f"DMSCredentials.{fname} is not a valid URL: '{value}'") + + +class RepositoryType(str, Enum): + INTERNAL = "internal" + EXTERNAL = "external" + + +class RepositoryCategory(str, Enum): + COLLABORATION = "Collaboration" + INSTANT = "Instant" + FAVORITES = "Favorites" + + +class ConfigName(str, Enum): + BLOCKED_FILE_EXTENSIONS = "blockedFileExtensions" + TEMPSPACE_MAX_CONTENT_SIZE = "tempspaceMaxContentSize" + IS_CROSS_DOMAIN_MAPPING_ALLOWED = "isCrossDomainMappingAllowed" + + +@dataclass +class UserClaim: + """User identity claims forwarded to the DMS service. + + Attributes: + x_ecm_user_enc: User identifier (e.g. username or email). + x_ecm_add_principals: Additional principals. + - Groups: prefix with ``~`` (e.g. ``~group1``) + - Extra users: plain username or email + """ + x_ecm_user_enc: Optional[str] = None + x_ecm_add_principals: Optional[List[str]] = field(default_factory=lambda:[]) + + +@dataclass +class RepositoryParam: + paramName: str + paramValue: str + + +@dataclass +class InternalRepoRequest: + """Request payload for onboarding a new internal repository.""" + + # Required + displayName: str + repositoryType: RepositoryType = RepositoryType.INTERNAL + + # Optional + description: Optional[str] = None + repositoryCategory: Optional[RepositoryCategory] = None + isVersionEnabled: Optional[bool] = None + isVirusScanEnabled: Optional[bool] = None + skipVirusScanForLargeFile: Optional[bool] = None + hashAlgorithms: Optional[str] = None # TODO provide enum + isThumbnailEnabled: Optional[bool] = None + isEncryptionEnabled: Optional[bool] = None + externalId: Optional[str] = None + isContentBridgeEnabled: Optional[bool] = None + isAIEnabled: Optional[bool] = None + repositoryParams: List[RepositoryParam] = field(default_factory=lambda:[]) + + def to_dict(self) -> dict[str, Any]: + return _to_dict_drop_none(self) + + +@dataclass +class UpdateRepoRequest: + """Request payload for updating an internal repository.""" + + description: Optional[str] = None + isVirusScanEnabled: Optional[bool] = None + skipVirusScanForLargeFile: Optional[bool] = None + isThumbnailEnabled: Optional[bool] = None + isClientCacheEnabled: Optional[bool] = None + isAIEnabled: Optional[bool] = None + repositoryParams: List[RepositoryParam] = field(default_factory=lambda:[]) + + def to_dict(self) -> dict[str, Any]: + return {"repository": _to_dict_drop_none(self)} + + +class RepositoryParams(TypedDict, total=False): + """Typed schema for known repository parameters returned by the API. + + All keys are optional since the API may not always return every param. + Unknown params can still be accessed via get_param() on the Repository object. + """ + changeLogDuration: int + isVersionEnabled: bool + isThumbnailEnabled: bool + isVirusScanEnabled: bool + hashAlgorithms: str + skipVirusScanForLargeFile: bool + isEncryptionEnabled: bool + isClientCacheEnabled: bool + isAIEnabled: bool + + +@dataclass +class Repository: + """Represents a repository entity returned by the Document Management API. + + Attributes: + cmis_repository_id: Internal CMIS repository identifier. + created_time: Timestamp when the repository was created (UTC). + id: Unique repository UUID. + last_updated_time: Timestamp of the last update (UTC). + name: Human-readable repository name. + repository_category: Category of the repository (e.g. "Instant"). + repository_params: Flat dict of repository parameters. Known keys are + typed via RepositoryParams. Unknown keys can be accessed via get_param(). + repository_sub_type: Repository sub-type (e.g. "SAP Document Management Service"). + repository_type: Repository type (e.g. "internal"). + """ + cmis_repository_id: str + created_time: datetime + id: str + last_updated_time: datetime + name: str + repository_category: str + repository_params: RepositoryParams + repository_sub_type: str + repository_type: str + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Repository": + """Parse a raw API response dict into a Repository instance. + + Converts the repositoryParams list of {paramName, paramValue} objects + into a flat dict for easier access. + + Args: + data: Raw dict returned by the repository API. + + Returns: + Repository: Parsed repository instance. + """ + return cls( + cmis_repository_id=data["cmisRepositoryId"], + created_time=datetime.fromisoformat(data["createdTime"].replace("Z", "+00:00")), + id=data["id"], + last_updated_time=datetime.fromisoformat(data["lastUpdatedTime"].replace("Z", "+00:00")), + name=data["name"], + repository_category=data["repositoryCategory"], + repository_params=cast(RepositoryParams, {p["paramName"]: p["paramValue"] for p in data["repositoryParams"]}), + repository_sub_type=data["repositorySubType"], + repository_type=data["repositoryType"], + ) + + def to_dict(self) -> dict[str, Any]: + """Serialize back into an API-compatible payload. + + Converts the flat repository_params dict back into the + [{paramName, paramValue}] list format expected by the API. + """ + return { + "cmisRepositoryId": self.cmis_repository_id, + "createdTime": self.created_time.isoformat().replace("+00:00", "Z"), + "id": self.id, + "lastUpdatedTime": self.last_updated_time.isoformat().replace("+00:00", "Z"), + "name": self.name, + "repositoryCategory": self.repository_category, + "repositoryParams": [ + {"paramName": k, "paramValue": v} + for k, v in self.repository_params.items() + ], + "repositorySubType": self.repository_sub_type, + "repositoryType": self.repository_type, + } + + def get_param(self, name: str, default: Any = None) -> Any: + """Get a repository parameter value by name. + + Use for unknown or dynamic param keys not defined in RepositoryParams. + For known keys, prefer direct access via repository_params for type safety. + + Args: + name: The paramName to look up (e.g. "isEncryptionEnabled"). + default: Fallback value if the param is not found. + + Example: + repo.get_param("isEncryptionEnabled") # True + repo.get_param("unknownParam", "N/A") # "N/A" + """ + return self.repository_params.get(name, default) + + +@dataclass +class CreateConfigRequest: + """Request payload for creating a repository configuration. + + Use ConfigName enum for known config keys. Unknown keys can be passed as raw strings. + + Example: + CreateConfigRequest(ConfigName.BLOCKED_FILE_EXTENSIONS, "bat,dmg,txt") + CreateConfigRequest("someCustomConfig", "value") + """ + config_name: ConfigName | str + config_value: str + + def to_dict(self) -> dict[str, Any]: + return { + "configName": _serialize(self.config_name), + "configValue": self.config_value, + } + + +@dataclass +class UpdateConfigRequest: + """Request payload for updating a repository configuration. + + Args: + id: Config Id. + config_name: ConfigName enum or raw string. + config_value: Value for the given config name. + service_instance_id: Optional service instance id. + """ + id: str + config_name: ConfigName | str + config_value: str + service_instance_id: Optional[str] = None + + def to_dict(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "id": self.id, + "configName": _serialize(self.config_name), + "configValue": self.config_value, + } + if self.service_instance_id: + payload["serviceInstanceId"] = self.service_instance_id + return payload + + +@dataclass +class RepositoryConfig: + """Represents a repository configuration entry.""" + id: str + config_name: str + config_value: str + created_time: datetime + last_updated_time: datetime + service_instance_id: str + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "RepositoryConfig": + return cls( + id=data["id"], + config_name=data["configName"], + config_value=data["configValue"], + created_time=datetime.fromisoformat(data["createdTime"].replace("Z", "+00:00")), + last_updated_time=datetime.fromisoformat(data["lastUpdatedTime"].replace("Z", "+00:00")), + service_instance_id=data["serviceInstanceId"], + ) \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/model/model.py b/src/sap_cloud_sdk/dms/model/model.py deleted file mode 100644 index 80c8503..0000000 --- a/src/sap_cloud_sdk/dms/model/model.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Data models for DMS service.""" - -from dataclasses import dataclass - -@dataclass -class DMSCredentials: - instance_name: str - uri: str - client_id: str - client_secret: str - token_url: str - identityzone: str - - -from dataclasses import dataclass, field, asdict -from typing import Any, List, Optional -from enum import Enum - - -class RepositoryType(str, Enum): - INTERNAL = "internal" - - -class RepositoryCategory(str, Enum): - COLLABORATION = "Collaboration" - INSTANT = "Instant" - FAVORITES = "Favorites" - - -@dataclass -class RepositoryParam: - paramName: str - paramValue: str - - -@dataclass -class InternalRepoRequest: - # Required fields - displayName: str - repositoryType: RepositoryType = RepositoryType.INTERNAL - - # Optional fields - description: Optional[str] = None - repositoryCategory: Optional[RepositoryCategory] = None - isVersionEnabled: Optional[bool] = None - isVirusScanEnabled: Optional[bool] = None - skipVirusScanForLargeFile: Optional[bool] = None - hashAlgorithms: Optional[str] = None - isThumbnailEnabled: Optional[bool] = None - isEncryptionEnabled: Optional[bool] = None - isClientCacheEnabled: Optional[bool] = None - externalId: Optional[str] = None - isContentBridgeEnabled: Optional[bool] = None - isAIEnabled: Optional[bool] = None - repositoryParams: list[RepositoryParam] = field(default_factory=lambda: []) - - def to_dict(self) -> dict[str, Any]: - raw: dict[str, Any] = asdict(self) - return {k: v for k, v in raw.items() if v is not None} - - -@dataclass -class UserClaim: - """Represents user identity claims forwarded to the DMS service. - - Attributes: - x_ecm_user_enc: User identifier (e.g. username or email) passed as a request header. - x_ecm_add_principals: Additional principals to include in the request. - - Groups: prefix the group name with ``~`` (e.g. ``~group1``) - - Extra identifiers: plain username or email (e.g. ``username2``) - """ - x_ecm_user_enc: Optional[str] = None - x_ecm_add_principals: Optional[List[str]] = field(default_factory=lambda: []) \ No newline at end of file From 7238b8749fbde09ad3582293338d0f154c937c4a Mon Sep 17 00:00:00 2001 From: Karan Shukla Date: Thu, 26 Mar 2026 17:10:13 +0700 Subject: [PATCH 11/24] Add CMIS retrieval and user APIs for DMS module --- src/sap_cloud_sdk/core/telemetry/operation.py | 12 + src/sap_cloud_sdk/dms/_http.py | 62 ++ src/sap_cloud_sdk/dms/client.py | 648 +++++++++++- src/sap_cloud_sdk/dms/model.py | 194 +++- src/sap_cloud_sdk/dms/user-guide.md | 482 +++++++++ tests/core/unit/telemetry/test_module.py | 3 +- tests/core/unit/telemetry/test_operation.py | 4 +- tests/dms/unit/__init__.py | 0 tests/dms/unit/test_client_cmis.py | 955 ++++++++++++++++++ tests/dms/unit/test_cmis_models.py | 361 +++++++ tests/dms/unit/test_http_invoker.py | 319 ++++++ 11 files changed, 3033 insertions(+), 7 deletions(-) create mode 100644 src/sap_cloud_sdk/dms/user-guide.md create mode 100644 tests/dms/unit/__init__.py create mode 100644 tests/dms/unit/test_client_cmis.py create mode 100644 tests/dms/unit/test_cmis_models.py create mode 100644 tests/dms/unit/test_http_invoker.py diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index 0667dca..260abde 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -64,5 +64,17 @@ class Operation(str, Enum): DMS_UPDATE_CONFIG = "update_config" DMS_DELETE_CONFIG = "delete_config" + # DMS CMIS Operations + DMS_CREATE_FOLDER = "create_folder" + DMS_CREATE_DOCUMENT = "create_document" + DMS_CHECK_OUT = "check_out" + DMS_CHECK_IN = "check_in" + DMS_CANCEL_CHECK_OUT = "cancel_check_out" + DMS_APPLY_ACL = "apply_acl" + DMS_GET_OBJECT = "get_object" + DMS_GET_CONTENT = "get_content" + DMS_UPDATE_PROPERTIES = "update_properties" + DMS_GET_CHILDREN = "get_children" + def __str__(self) -> str: return self.value diff --git a/src/sap_cloud_sdk/dms/_http.py b/src/sap_cloud_sdk/dms/_http.py index b8b2c8e..f8c8377 100644 --- a/src/sap_cloud_sdk/dms/_http.py +++ b/src/sap_cloud_sdk/dms/_http.py @@ -38,12 +38,14 @@ def get( tenant_subdomain: Optional[str] = None, headers: Optional[dict[str, str]] = None, user_claim: Optional[UserClaim] = None, + params: Optional[dict[str, str]] = None, ) -> Response: logger.debug("GET %s", path) return self._handle(self._execute( lambda: requests.get( f"{self._base_url}{path}", headers=self._merged_headers(tenant_subdomain, headers, user_claim), + params=params, timeout=(self._connect_timeout, self._read_timeout), ) )) @@ -100,6 +102,55 @@ def delete( ) )) + def post_form( + self, + path: str, + *, + data: dict[str, str], + files: Optional[dict[str, Any]] = None, + tenant_subdomain: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Response: + """POST with form-encoded data and optional multipart file uploads. + + Does not set Content-Type — ``requests`` sets it automatically + to ``application/x-www-form-urlencoded`` or ``multipart/form-data``. + """ + logger.debug("POST_FORM %s", path) + return self._handle(self._execute( + lambda: requests.post( + f"{self._base_url}{path}", + headers=self._auth_header(tenant_subdomain, user_claim), + data=data, + files=files, + timeout=(self._connect_timeout, self._read_timeout), + ) + )) + + def get_stream( + self, + path: str, + *, + params: Optional[dict[str, str]] = None, + tenant_subdomain: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Response: + """GET that returns a raw streaming Response for binary content. + + The caller is responsible for closing the response. + On non-2xx status the usual typed exception is raised. + """ + logger.debug("GET_STREAM %s", path) + return self._handle(self._execute( + lambda: requests.get( + f"{self._base_url}{path}", + headers=self._merged_headers(tenant_subdomain, None, user_claim), + params=params, + stream=True, + timeout=(self._connect_timeout, self._read_timeout), + ) + )) + def _execute(self, fn: Any) -> Response: """Execute an HTTP call, wrapping network errors into DMSConnectionError.""" try: @@ -114,6 +165,17 @@ def _execute(self, fn: Any) -> Response: logger.error("Unexpected network error") raise DMSConnectionError("Unexpected network error") from e + def _auth_header( + self, + tenant_subdomain: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> dict[str, str]: + """Auth-only headers (no Content-Type). Used by post_form.""" + return { + "Authorization": f"Bearer {self._auth.get_token(tenant_subdomain)}", + **self._user_claim_headers(user_claim), + } + def _default_headers(self, tenant_subdomain: Optional[str] = None) -> dict[str, str]: return { "Authorization": f"Bearer {self._auth.get_token(tenant_subdomain)}", diff --git a/src/sap_cloud_sdk/dms/client.py b/src/sap_cloud_sdk/dms/client.py index 6e81b39..7afb15e 100644 --- a/src/sap_cloud_sdk/dms/client.py +++ b/src/sap_cloud_sdk/dms/client.py @@ -1,8 +1,10 @@ import logging -from typing import Optional +from typing import Any, BinaryIO, Dict, List, Optional, Union +from requests import Response from sap_cloud_sdk.dms.model import ( DMSCredentials, InternalRepoRequest, Repository, UserClaim, UpdateRepoRequest, CreateConfigRequest, RepositoryConfig, UpdateConfigRequest, + Ace, Acl, ChildrenPage, CmisObject, Document, Folder, _prop_val, ) from sap_cloud_sdk.dms._auth import Auth from sap_cloud_sdk.dms._http import HttpInvoker @@ -12,6 +14,36 @@ logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# CMIS property helpers +# --------------------------------------------------------------------------- + +def _build_properties(props: Dict[str, str]) -> Dict[str, str]: + """Encode CMIS properties into indexed form fields. + + ``{"cmis:name": "Doc"} → {"propertyId[0]": "cmis:name", "propertyValue[0]": "Doc"}`` + """ + form: Dict[str, str] = {} + for idx, (key, val) in enumerate(props.items()): + form[f"propertyId[{idx}]"] = key + form[f"propertyValue[{idx}]"] = str(val) + return form + + +def _build_aces(aces: List[Ace], prefix: str) -> Dict[str, str]: + """Encode ACE entries into indexed CMIS form fields. + + *prefix* is ``addACEPrincipal`` or ``removeACEPrincipal``. + """ + perm_prefix = prefix.replace("Principal", "Permission") + form: Dict[str, str] = {} + for i, ace in enumerate(aces): + form[f"{prefix}[{i}]"] = ace.principal_id + for j, perm in enumerate(ace.permissions): + form[f"{perm_prefix}[{i}][{j}]"] = perm + return form + + class DMSClient: """Client for interacting with the SAP Document Management Service Admin API.""" @@ -320,4 +352,618 @@ def delete_config( tenant_subdomain=tenant, user_claim=user_claim, ) + + # ================================================================== + # CMIS — helpers + # ================================================================== + + @staticmethod + def _browser_url(repository_id: str, path: Optional[str] = None) -> str: + base = f"/browser/{repository_id}/root" + if path: + return f"{base}/{path.lstrip('/')}" + return base + + # ================================================================== + # CMIS — folder operations + # ================================================================== + + @record_metrics(Module.DMS, Operation.DMS_CREATE_FOLDER) + def create_folder( + self, + repository_id: str, + parent_folder_id: str, + folder_name: str, + *, + description: Optional[str] = None, + add_aces: Optional[List[Ace]] = None, + remove_aces: Optional[List[Ace]] = None, + path: Optional[str] = None, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Folder: + """Create a new folder. + + Args: + repository_id: Target repository ID. + parent_folder_id: CMIS objectId of the parent folder. + folder_name: Name for the new folder. + description: Optional folder description. + add_aces: Optional ACE entries to grant on the new folder. + remove_aces: Optional ACE entries to revoke on the new folder. + path: Optional directory path (appended to /browser/{repo_id}/root/). + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims forwarded to DMS. + + Returns: + Folder: The created folder. + + Raises: + DMSInvalidArgumentException: If the request payload is invalid. + DMSObjectNotFoundException: If the parent folder is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + cmis_props: Dict[str, str] = { + "cmis:name": folder_name, + "cmis:objectTypeId": "cmis:folder", + } + if description is not None: + cmis_props["cmis:description"] = description + + form_data: Dict[str, str] = { + "cmisaction": "createFolder", + "objectId": parent_folder_id, + "_charset_": "UTF-8", + } + form_data.update(_build_properties(cmis_props)) + if add_aces: + form_data.update(_build_aces(add_aces, prefix="addACEPrincipal")) + if remove_aces: + form_data.update(_build_aces(remove_aces, prefix="removeACEPrincipal")) + + logger.info("Creating folder '%s' in repo '%s'", folder_name, repository_id) + response = self._http.post_form( + self._browser_url(repository_id, path), + data=form_data, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return Folder.from_dict(response.json()) + + # ================================================================== + # CMIS — document operations + # ================================================================== + + @record_metrics(Module.DMS, Operation.DMS_CREATE_DOCUMENT) + def create_document( + self, + repository_id: str, + parent_folder_id: str, + document_name: str, + file: BinaryIO, + mime_type: str, + *, + description: Optional[str] = None, + add_aces: Optional[List[Ace]] = None, + remove_aces: Optional[List[Ace]] = None, + path: Optional[str] = None, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Document: + """Create a new document with content. + + Args: + repository_id: Target repository ID. + parent_folder_id: Parent folder CMIS objectId. + document_name: File name for the document. + file: Readable binary stream with the content. + mime_type: MIME type (e.g. ``application/pdf``). + description: Optional document description. + add_aces: Optional ACE entries to grant on the new document. + remove_aces: Optional ACE entries to revoke on the new document. + path: Optional directory path. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims forwarded to DMS. + + Returns: + Document: The created document. + + Raises: + DMSInvalidArgumentException: If the request payload is invalid. + DMSObjectNotFoundException: If the parent folder is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + cmis_props: Dict[str, str] = { + "cmis:name": document_name, + "cmis:objectTypeId": "cmis:document", + } + if description is not None: + cmis_props["cmis:description"] = description + + form_data: Dict[str, str] = { + "cmisaction": "createDocument", + "objectId": parent_folder_id, + "_charset_": "UTF-8", + } + form_data.update(_build_properties(cmis_props)) + if add_aces: + form_data.update(_build_aces(add_aces, prefix="addACEPrincipal")) + if remove_aces: + form_data.update(_build_aces(remove_aces, prefix="removeACEPrincipal")) + + logger.info("Creating document '%s' in repo '%s'", document_name, repository_id) + response = self._http.post_form( + self._browser_url(repository_id, path), + data=form_data, + files={"media": (document_name, file, mime_type)}, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return Document.from_dict(response.json()) + + # ================================================================== + # CMIS — versioning + # ================================================================== + + @record_metrics(Module.DMS, Operation.DMS_CHECK_OUT) + def check_out( + self, + repository_id: str, + document_id: str, + *, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Document: + """Check out a document, creating a Private Working Copy (PWC). + + Args: + repository_id: Target repository ID. + document_id: Document to check out. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims. + + Returns: + Document: The Private Working Copy. + + Raises: + DMSObjectNotFoundException: If the document is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + form_data: Dict[str, str] = { + "cmisaction": "checkOut", + "objectId": document_id, + "_charset_": "UTF-8", + } + logger.info("Checking out document '%s' in repo '%s'", document_id, repository_id) + response = self._http.post_form( + self._browser_url(repository_id), + data=form_data, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return Document.from_dict(response.json()) + + @record_metrics(Module.DMS, Operation.DMS_CHECK_IN) + def check_in( + self, + repository_id: str, + document_id: str, + *, + major: bool = True, + file: Optional[BinaryIO] = None, + file_name: Optional[str] = None, + mime_type: Optional[str] = None, + checkin_comment: Optional[str] = None, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Document: + """Check in a Private Working Copy, creating a new version. + + Args: + repository_id: Target repository ID. + document_id: PWC object ID. + major: True for a major version, False for minor. + file: Optional updated content stream. + file_name: File name when providing new content. + mime_type: MIME type when providing new content. + checkin_comment: Optional version comment. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims. + + Returns: + Document: The new document version. + + Raises: + DMSObjectNotFoundException: If the document is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + form_data: Dict[str, str] = { + "cmisaction": "checkIn", + "objectId": document_id, + "major": str(major).lower(), + "_charset_": "UTF-8", + } + if checkin_comment is not None: + form_data["checkinComment"] = checkin_comment + + files = None + if file is not None: + files = {"content": (file_name or "content", file, mime_type or "application/octet-stream")} + + logger.info("Checking in document '%s' in repo '%s'", document_id, repository_id) + response = self._http.post_form( + self._browser_url(repository_id), + data=form_data, + files=files, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return Document.from_dict(response.json()) + + @record_metrics(Module.DMS, Operation.DMS_CANCEL_CHECK_OUT) + def cancel_check_out( + self, + repository_id: str, + document_id: str, + *, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> None: + """Cancel a check-out and discard the Private Working Copy. + + Args: + repository_id: Target repository ID. + document_id: PWC object ID. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims. + + Raises: + DMSObjectNotFoundException: If the document is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + form_data: Dict[str, str] = { + "cmisaction": "cancelCheckOut", + "objectId": document_id, + "_charset_": "UTF-8", + } + logger.info("Cancelling check-out for '%s' in repo '%s'", document_id, repository_id) + self._http.post_form( + self._browser_url(repository_id), + data=form_data, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + + # ================================================================== + # CMIS — ACL operations + # ================================================================== + + @record_metrics(Module.DMS, Operation.DMS_APPLY_ACL) + def apply_acl( + self, + repository_id: str, + object_id: str, + *, + add_aces: Optional[List[Ace]] = None, + remove_aces: Optional[List[Ace]] = None, + acl_propagation: str = "propagate", + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Acl: + """Get, add, or remove access control entries on an object. + + When neither *add_aces* nor *remove_aces* is provided the current + ACL is fetched (HTTP GET). Otherwise the CMIS ``applyACL`` action + is executed (HTTP POST) with the supplied entries. + + Args: + repository_id: Target repository ID. + object_id: Document or folder CMIS objectId. + add_aces: Optional ACE entries to grant. + remove_aces: Optional ACE entries to revoke. + acl_propagation: ACL propagation mode — ``"propagate"``, + ``"objectonly"``, or ``"repositorydetermined"``. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims forwarded to DMS. + + Returns: + Acl: The current or updated ACL. + + Raises: + DMSObjectNotFoundException: If the object is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + if not add_aces and not remove_aces: + # Read-only: fetch current ACL + logger.info("Fetching ACL for object '%s' in repo '%s'", object_id, repository_id) + response = self._http.get( + self._browser_url(repository_id), + params={"objectId": object_id, "cmisselector": "acl"}, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return Acl.from_dict(response.json()) + + form_data: Dict[str, str] = { + "cmisaction": "applyACL", + "objectId": object_id, + "ACLPropagation": acl_propagation, + "_charset_": "UTF-8", + } + if add_aces: + form_data.update(_build_aces(add_aces, prefix="addACEPrincipal")) + if remove_aces: + form_data.update(_build_aces(remove_aces, prefix="removeACEPrincipal")) + + logger.info("Applying ACL to object '%s' in repo '%s'", object_id, repository_id) + response = self._http.post_form( + self._browser_url(repository_id), + data=form_data, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return Acl.from_dict(response.json()) + + # ================================================================== + # CMIS — object read operations + # ================================================================== + + @record_metrics(Module.DMS, Operation.DMS_GET_OBJECT) + def get_object( + self, + repository_id: str, + object_id: str, + *, + filter: Optional[str] = None, + include_acl: bool = False, + include_allowable_actions: bool = False, + succinct: bool = True, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Union[Folder, Document, CmisObject]: + """Retrieve a CMIS object by its ID. + + Automatically returns a :class:`Folder` or :class:`Document` + based on the ``cmis:baseTypeId`` value; falls back to + :class:`CmisObject` for unknown types. + + Args: + repository_id: Target repository ID. + object_id: CMIS objectId to retrieve. + filter: Comma-separated property list (e.g. ``"*"`` for all). + include_acl: Include ACL data in the response. + include_allowable_actions: Include allowable actions. + succinct: Use succinct property format. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims. + + Returns: + Folder, Document, or CmisObject depending on the base type. + + Raises: + DMSObjectNotFoundException: If the object is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + params: Dict[str, str] = { + "objectId": object_id, + "cmisselector": "object", + } + if filter: + params["filter"] = filter + if include_acl: + params["includeACL"] = "true" + if include_allowable_actions: + params["includeAllowableActions"] = "true" + if succinct: + params["succinct"] = "true" + + logger.info("Getting object '%s' from repo '%s'", object_id, repository_id) + response = self._http.get( + self._browser_url(repository_id), + params=params, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + data = response.json() + props = data.get("succinctProperties") or data.get("properties") or {} + base_type = _prop_val(props, "cmis:baseTypeId") or "" + if base_type == "cmis:document": + return Document.from_dict(data) + if base_type == "cmis:folder": + return Folder.from_dict(data) + return CmisObject.from_dict(data) + + @record_metrics(Module.DMS, Operation.DMS_GET_CONTENT) + def get_content( + self, + repository_id: str, + document_id: str, + *, + download: Optional[str] = None, + stream_id: Optional[str] = None, + filename: Optional[str] = None, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Response: + """Download the content stream of a document. + + Returns the raw :class:`requests.Response` with ``stream=True`` + so the caller can iterate over chunks or read all bytes:: + + resp = client.get_content(repo_id, doc_id, download="attachment") + with open("file.pdf", "wb") as f: + for chunk in resp.iter_content(chunk_size=8192): + f.write(chunk) + resp.close() + + Args: + repository_id: Target repository ID. + document_id: Document CMIS objectId. + download: Download disposition — ``"attachment"`` (save-as) or + ``"inline"`` (display in browser). Omit to let the server + decide. + stream_id: Rendition stream identifier (e.g. + ``"sap:zipRendition"``). When omitted the primary content + stream is returned. + filename: Override the file name in the ``Content-Disposition`` + response header. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims. + + Returns: + Response: Raw streaming response. Caller must close it. + + Raises: + DMSObjectNotFoundException: If the document is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + params: Dict[str, str] = { + "objectId": document_id, + "cmisselector": "content", + } + if download: + params["download"] = download + if stream_id: + params["streamId"] = stream_id + if filename: + params["filename"] = filename + + logger.info("Getting content for document '%s' from repo '%s'", document_id, repository_id) + return self._http.get_stream( + self._browser_url(repository_id), + params=params, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + + @record_metrics(Module.DMS, Operation.DMS_UPDATE_PROPERTIES) + def update_properties( + self, + repository_id: str, + object_id: str, + properties: Dict[str, str], + *, + change_token: Optional[str] = None, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Union[Folder, Document, CmisObject]: + """Update properties of an existing CMIS object. + + Args: + repository_id: Target repository ID. + object_id: CMIS objectId to update. + properties: Map of property IDs to new values + (e.g. ``{"cmis:name": "Renamed", "cmis:description": "New desc"}``) + change_token: Optional concurrency token for optimistic locking. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims. + + Returns: + Folder, Document, or CmisObject depending on the base type. + + Raises: + DMSObjectNotFoundException: If the object is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + form_data: Dict[str, str] = { + "cmisaction": "update", + "objectId": object_id, + "_charset_": "UTF-8", + } + if change_token is not None: + form_data["changeToken"] = change_token + form_data.update(_build_properties(properties)) + + logger.info("Updating properties for object '%s' in repo '%s'", object_id, repository_id) + response = self._http.post_form( + self._browser_url(repository_id), + data=form_data, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + data = response.json() + props = data.get("succinctProperties") or data.get("properties") or {} + base_type = _prop_val(props, "cmis:baseTypeId") or "" + if base_type == "cmis:document": + return Document.from_dict(data) + if base_type == "cmis:folder": + return Folder.from_dict(data) + return CmisObject.from_dict(data) + + @record_metrics(Module.DMS, Operation.DMS_GET_CHILDREN) + def get_children( + self, + repository_id: str, + folder_id: str, + *, + max_items: int = 100, + skip_count: int = 0, + order_by: Optional[str] = None, + filter: Optional[str] = None, + include_allowable_actions: bool = False, + include_path_segment: bool = False, + succinct: bool = True, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> ChildrenPage: + """List children of a folder (one page). + + Use *skip_count* and *max_items* for pagination. The returned + :class:`ChildrenPage` has a ``has_more_items`` flag. + + Args: + repository_id: Target repository ID. + folder_id: Parent folder CMIS objectId. + max_items: Maximum number of items to return (default 100). + skip_count: Number of items to skip (pagination offset). + order_by: Sort order (e.g. ``"cmis:creationDate ASC"``). + filter: Comma-separated property list. + include_allowable_actions: Include allowable actions per child. + include_path_segment: Include the path segment per child. + succinct: Use succinct property format. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims. + + Returns: + ChildrenPage: A page of child objects. + + Raises: + DMSObjectNotFoundException: If the folder is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + params: Dict[str, str] = { + "objectId": folder_id, + "cmisselector": "children", + "maxItems": str(max_items), + "skipCount": str(skip_count), + } + if order_by: + params["orderBy"] = order_by + if filter: + params["filter"] = filter + if include_allowable_actions: + params["includeAllowableActions"] = "true" + if include_path_segment: + params["includePathSegment"] = "true" + if succinct: + params["succinct"] = "true" + + logger.info("Getting children of folder '%s' in repo '%s'", folder_id, repository_id) + response = self._http.get( + self._browser_url(repository_id), + params=params, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return ChildrenPage.from_dict(response.json()) logger.info("Config '%s' deleted successfully", config_id) \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/model.py b/src/sap_cloud_sdk/dms/model.py index 25334a6..18bb385 100644 --- a/src/sap_cloud_sdk/dms/model.py +++ b/src/sap_cloud_sdk/dms/model.py @@ -1,9 +1,9 @@ """Data models for DMS service.""" from dataclasses import dataclass, field, asdict -from datetime import datetime +from datetime import datetime, timezone from enum import Enum -from typing import Any, List, Optional, TypedDict, cast +from typing import Any, Dict, List, Optional, TypedDict, cast from urllib.parse import urlparse def _serialize(v: Any) -> Any: @@ -183,6 +183,21 @@ class Repository: repository_sub_type: str repository_type: str + @staticmethod + def _parse_repo_params(raw: Any) -> "RepositoryParams": + """Normalise repositoryParams from the API response. + + The API may return: + - A list of ``{paramName, paramValue}`` dicts (common case) + - A single ``{paramName, paramValue}`` dict (when only one param) + - An empty list or ``None`` + """ + if not raw: + return cast(RepositoryParams, {}) + if isinstance(raw, dict): + raw = [raw] + return cast(RepositoryParams, {p["paramName"]: p["paramValue"] for p in raw}) + @classmethod def from_dict(cls, data: dict[str, Any]) -> "Repository": """Parse a raw API response dict into a Repository instance. @@ -203,7 +218,7 @@ def from_dict(cls, data: dict[str, Any]) -> "Repository": last_updated_time=datetime.fromisoformat(data["lastUpdatedTime"].replace("Z", "+00:00")), name=data["name"], repository_category=data["repositoryCategory"], - repository_params=cast(RepositoryParams, {p["paramName"]: p["paramValue"] for p in data["repositoryParams"]}), + repository_params=cls._parse_repo_params(data.get("repositoryParams")), repository_sub_type=data["repositorySubType"], repository_type=data["repositoryType"], ) @@ -311,4 +326,177 @@ def from_dict(cls, data: dict[str, Any]) -> "RepositoryConfig": created_time=datetime.fromisoformat(data["createdTime"].replace("Z", "+00:00")), last_updated_time=datetime.fromisoformat(data["lastUpdatedTime"].replace("Z", "+00:00")), service_instance_id=data["serviceInstanceId"], + ) + + +# --------------------------------------------------------------------------- +# CMIS browser-binding response models +# --------------------------------------------------------------------------- + +def _parse_datetime(val: Any) -> Optional[datetime]: + """Parse a CMIS timestamp (epoch millis or ISO string) into a datetime.""" + if val is None: + return None + if isinstance(val, (int, float)): + return datetime.fromtimestamp(val / 1000, tz=timezone.utc) + return datetime.fromisoformat(str(val).replace("Z", "+00:00")) + + +def _prop_val(props: Dict[str, Any], key: str) -> Any: + """Extract a property value from verbose or succinct CMIS properties. + + Verbose format: ``{"cmis:name": {"value": "MyDoc"}}`` + Succinct format: ``{"cmis:name": "MyDoc"}`` + """ + raw = props.get(key) + if isinstance(raw, dict) and "value" in raw: + return raw["value"] + return raw + + +@dataclass +class CmisObject: + """Base CMIS object with shared properties.""" + + object_id: str = "" + name: str = "" + base_type_id: str = "" + object_type_id: str = "" + created_by: Optional[str] = None + creation_date: Optional[datetime] = None + last_modified_by: Optional[str] = None + last_modification_date: Optional[datetime] = None + change_token: Optional[str] = None + parent_ids: Optional[List[str]] = None + description: Optional[str] = None + properties: Dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "CmisObject": + props = data.get("succinctProperties") or data.get("properties") or {} + return cls( + object_id=_prop_val(props, "cmis:objectId") or "", + name=_prop_val(props, "cmis:name") or "", + base_type_id=_prop_val(props, "cmis:baseTypeId") or "", + object_type_id=_prop_val(props, "cmis:objectTypeId") or "", + created_by=_prop_val(props, "cmis:createdBy"), + creation_date=_parse_datetime(_prop_val(props, "cmis:creationDate")), + last_modified_by=_prop_val(props, "cmis:lastModifiedBy"), + last_modification_date=_parse_datetime(_prop_val(props, "cmis:lastModificationDate")), + change_token=_prop_val(props, "cmis:changeToken"), + parent_ids=_prop_val(props, "sap:parentIds"), + description=_prop_val(props, "cmis:description"), + properties=props, + ) + + +@dataclass +class Folder(CmisObject): + """CMIS folder object.""" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Folder": + base = CmisObject.from_dict(data) + return cls(**{k: v for k, v in base.__dict__.items() if k in cls.__dataclass_fields__}) + + +@dataclass +class Document(CmisObject): + """CMIS document with content stream and versioning metadata.""" + + content_stream_length: Optional[int] = None + content_stream_mime_type: Optional[str] = None + content_stream_file_name: Optional[str] = None + version_series_id: Optional[str] = None + version_label: Optional[str] = None + is_latest_version: Optional[bool] = None + is_major_version: Optional[bool] = None + is_latest_major_version: Optional[bool] = None + is_private_working_copy: Optional[bool] = None + checkin_comment: Optional[str] = None + is_version_series_checked_out: Optional[bool] = None + version_series_checked_out_id: Optional[str] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Document": + base = CmisObject.from_dict(data) + props = base.properties + return cls( + **{k: v for k, v in base.__dict__.items() if k in CmisObject.__dataclass_fields__}, + content_stream_length=_prop_val(props, "cmis:contentStreamLength"), + content_stream_mime_type=_prop_val(props, "cmis:contentStreamMimeType"), + content_stream_file_name=_prop_val(props, "cmis:contentStreamFileName"), + version_series_id=_prop_val(props, "cmis:versionSeriesId"), + version_label=_prop_val(props, "cmis:versionLabel"), + is_latest_version=_prop_val(props, "cmis:isLatestVersion"), + is_major_version=_prop_val(props, "cmis:isMajorVersion"), + is_latest_major_version=_prop_val(props, "cmis:isLatestMajorVersion"), + is_private_working_copy=_prop_val(props, "cmis:isPrivateWorkingCopy"), + checkin_comment=_prop_val(props, "cmis:checkinComment"), + is_version_series_checked_out=_prop_val(props, "cmis:isVersionSeriesCheckedOut"), + version_series_checked_out_id=_prop_val(props, "cmis:versionSeriesCheckedOutId"), + ) + + +@dataclass +class Ace: + """Single access control entry.""" + + principal_id: str + permissions: List[str] = field(default_factory=list) + is_direct: bool = True + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Ace": + principal = data.get("principal", {}) + return cls( + principal_id=principal.get("principalId", ""), + permissions=data.get("permissions", []), + is_direct=data.get("isDirect", True), + ) + + +@dataclass +class Acl: + """Access control list for a CMIS object.""" + + aces: List[Ace] = field(default_factory=list) + is_exact: bool = True + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Acl": + raw_aces = data.get("aces", []) + return cls( + aces=[Ace.from_dict(a) for a in raw_aces], + is_exact=data.get("isExact", True), + ) + + +@dataclass +class ChildrenPage: + """Paginated result from a CMIS ``getChildren`` request.""" + + objects: List[CmisObject] = field(default_factory=list) + has_more_items: bool = False + num_items: Optional[int] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ChildrenPage": + raw_objects = data.get("objects") or [] + parsed: List[CmisObject] = [] + for entry in raw_objects: + obj_data = entry.get("object") or entry + props = (obj_data.get("succinctProperties") + or obj_data.get("properties") or {}) + base_type = _prop_val(props, "cmis:baseTypeId") or "" + if base_type == "cmis:document": + parsed.append(Document.from_dict(obj_data)) + elif base_type == "cmis:folder": + parsed.append(Folder.from_dict(obj_data)) + else: + parsed.append(CmisObject.from_dict(obj_data)) + return cls( + objects=parsed, + has_more_items=data.get("hasMoreItems", False), + num_items=data.get("numItems"), ) \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/user-guide.md b/src/sap_cloud_sdk/dms/user-guide.md new file mode 100644 index 0000000..055f3a6 --- /dev/null +++ b/src/sap_cloud_sdk/dms/user-guide.md @@ -0,0 +1,482 @@ +# Document Management Service draft user guide + +This module provides a Python client for the SAP Document Management Service (DMS). It covers both the **Admin API** (repository and configuration management) and the **CMIS Browser Binding API** (folders, documents, versioning, ACLs). + +## Installation + +```bash + +# Using pip +pip install sap-cloud-sdk +``` + +See further information about installation in the [main documentation](/README.md#installation). + +## Import + +```python +from sap_cloud_sdk.dms import create_client +from sap_cloud_sdk.dms.model import ( + InternalRepoRequest, UpdateRepoRequest, Repository, + CreateConfigRequest, UpdateConfigRequest, RepositoryConfig, ConfigName, + UserClaim, Ace, Acl, Folder, Document, CmisObject, ChildrenPage, +) +from sap_cloud_sdk.dms.exceptions import ( + DMSError, DMSObjectNotFoundException, DMSPermissionDeniedException, + DMSInvalidArgumentException, DMSConnectionError, DMSRuntimeException, +) +``` + +--- + +## Getting Started + +Use `create_client()` to get a client with automatic configuration detection: + +```python +from sap_cloud_sdk.dms import create_client + +# Load credentials from mounted secrets or environment variables +client = create_client(instance="my-instance") +``` + +You can also provide credentials directly: + +```python +from sap_cloud_sdk.dms import create_client +from sap_cloud_sdk.dms.model import DMSCredentials + +creds = DMSCredentials( + instance_name="my-instance", + uri="https://api-sdm-di.cfapps.eu10.hana.ondemand.com", + client_id="your-client-id", + client_secret="your-client-secret", + token_url="https://your-subdomain.authentication.eu10.hana.ondemand.com/oauth/token", + identityzone="your-subdomain", +) + +client = create_client(dms_cred=creds) +``` + +> **`instance` refers to the instance name defined in your Cloud descriptor.** +> +> This name determines which set of credentials or mounted secrets to resolve from the environment. + +--- + +## User Identity (UserClaim) + +Many DMS operations accept an optional `user_claim` parameter to impersonate a user when the SDK authenticates via `client_credentials` grant. This forwards `X-EcmUserEnc` and `X-EcmAddPrincipals` headers to the service. + +```python +from sap_cloud_sdk.dms.model import UserClaim + +claim = UserClaim( + x_ecm_user_enc="john.doe@example.com", + x_ecm_add_principals=["~GroupA", "~GroupB", "another.user@example.com"], +) + +# Pass to any API call +repos = client.get_all_repositories(user_claim=claim) +``` + +Groups are prefixed with `~`. Omit the `user_claim` parameter to use the service identity. + +--- + +## Admin API — Repository Management + +### Onboard a Repository + +```python +from sap_cloud_sdk.dms.model import InternalRepoRequest + +request = InternalRepoRequest( + displayName="My Repository", + description="Main document store", + isVersionEnabled=True, + isVirusScanEnabled=True, +) + +repo = client.onboard_repository(request) +print(f"Created: {repo.name} (id={repo.id})") +``` + +### List All Repositories + +```python +repos = client.get_all_repositories() + +for repo in repos: + print(f"{repo.name} — {repo.repository_type} — id={repo.id}") + print(f" CMIS repo ID: {repo.cmis_repository_id}") + print(f" Versioning: {repo.get_param('isVersionEnabled')}") +``` + +### Get a Single Repository + +```python +repo = client.get_repository("repository-uuid") +print(f"{repo.name}: {repo.repository_category}") +``` + +### Update a Repository + +```python +from sap_cloud_sdk.dms.model import UpdateRepoRequest + +request = UpdateRepoRequest( + description="Updated description", + isVirusScanEnabled=False, +) + +repo = client.update_repository("repository-uuid", request) +``` + +### Delete a Repository + +```python +client.delete_repository("repository-uuid") +``` + +--- + +## Admin API — Configuration Management + +### Create a Configuration + +```python +from sap_cloud_sdk.dms.model import CreateConfigRequest, ConfigName + +request = CreateConfigRequest( + config_name=ConfigName.BLOCKED_FILE_EXTENSIONS, + config_value="bat,dmg,exe", +) + +config = client.create_config(request) +print(f"Config: {config.config_name} = {config.config_value}") +``` + +### Get All Configurations + +```python +configs = client.get_configs() + +for cfg in configs: + print(f"{cfg.config_name} = {cfg.config_value} (id={cfg.id})") +``` + +### Update a Configuration + +```python +from sap_cloud_sdk.dms.model import UpdateConfigRequest + +request = UpdateConfigRequest( + id="config-uuid", + config_name=ConfigName.BLOCKED_FILE_EXTENSIONS, + config_value="bat,dmg,exe,msi", +) + +config = client.update_config("config-uuid", request) +``` + +### Delete a Configuration + +```python +client.delete_config("config-uuid") +``` + +--- + +## CMIS API — Folder Operations + +All CMIS operations require a `repository_id` (the CMIS repository ID from `repo.cmis_repository_id`) and use the CMIS Browser Binding protocol under the hood. + +### Create a Folder + +```python +folder = client.create_folder( + repository_id="cmis-repo-id", + parent_folder_id="root-folder-object-id", + folder_name="My Folder", + description="Optional description", +) + +print(f"Created folder: {folder.name} (objectId={folder.object_id})") +``` + +### Create a Folder with ACLs + +```python +from sap_cloud_sdk.dms.model import Ace + +folder = client.create_folder( + repository_id="cmis-repo-id", + parent_folder_id="root-folder-object-id", + folder_name="Secured Folder", + add_aces=[ + Ace(principal_id="user@example.com", permissions=["cmis:read", "cmis:write"]), + ], +) +``` + +--- + +## CMIS API — Document Operations + +### Create a Document + +```python +import io + +# From in-memory bytes +content = io.BytesIO(b"Hello, World!") +doc = client.create_document( + repository_id="cmis-repo-id", + parent_folder_id="parent-folder-id", + document_name="hello.txt", + file=content, + mime_type="text/plain", +) + +print(f"Created: {doc.name} ({doc.content_stream_length} bytes)") +print(f"Version: {doc.version_label}") +``` + +### Upload a File from Disk + +```python +with open("/path/to/report.pdf", "rb") as f: + doc = client.create_document( + repository_id="cmis-repo-id", + parent_folder_id="parent-folder-id", + document_name="report.pdf", + file=f, + mime_type="application/pdf", + description="Q4 financial report", + ) +``` + +### Download Document Content + +```python +response = client.get_content( + repository_id="cmis-repo-id", + document_id="document-object-id", + download="attachment", +) + +try: + with open("downloaded.pdf", "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) +finally: + response.close() +``` + +Optional parameters: +- `stream_id` — download a specific rendition (e.g. `"sap:zipRendition"`) +- `filename` — override the filename in the response header + +--- + +## CMIS API — Versioning + +Versioning requires a version-enabled repository. + +### Check Out a Document + +Creates a Private Working Copy (PWC): + +```python +pwc = client.check_out( + repository_id="cmis-repo-id", + document_id="document-object-id", +) + +print(f"PWC objectId: {pwc.object_id}") +print(f"Is PWC: {pwc.is_private_working_copy}") +``` + +### Check In a Document + +Creates a new version from the PWC: + +```python +import io + +new_content = io.BytesIO(b"Updated content for v2") + +doc = client.check_in( + repository_id="cmis-repo-id", + document_id=pwc.object_id, # the PWC objectId + major=True, + file=new_content, + file_name="hello.txt", + mime_type="text/plain", + checkin_comment="Version 2.0 — updated content", +) + +print(f"New version: {doc.version_label}") +print(f"Is latest: {doc.is_latest_version}") +``` + +### Cancel Check Out + +Discards the PWC without creating a new version: + +```python +client.cancel_check_out( + repository_id="cmis-repo-id", + document_id=pwc.object_id, +) +``` + +--- + +## CMIS API — Access Control Lists (ACL) + +### Get the Current ACL + +```python +acl = client.apply_acl( + repository_id="cmis-repo-id", + object_id="document-or-folder-id", +) + +for ace in acl.aces: + print(f"{ace.principal_id}: {ace.permissions} (direct={ace.is_direct})") +``` + +### Add ACE Entries + +```python +from sap_cloud_sdk.dms.model import Ace + +acl = client.apply_acl( + repository_id="cmis-repo-id", + object_id="document-or-folder-id", + add_aces=[ + Ace(principal_id="user@example.com", permissions=["cmis:read"]), + Ace(principal_id="admin@example.com", permissions=["cmis:all"]), + ], +) +``` + +### Remove ACE Entries + +```python +acl = client.apply_acl( + repository_id="cmis-repo-id", + object_id="document-or-folder-id", + remove_aces=[ + Ace(principal_id="user@example.com", permissions=["cmis:read"]), + ], +) +``` + +### ACL Propagation + +Control whether ACL changes propagate to children: + +```python +acl = client.apply_acl( + repository_id="cmis-repo-id", + object_id="folder-id", + add_aces=[Ace(principal_id="team@example.com", permissions=["cmis:read"])], + acl_propagation="propagate", # "propagate" | "objectonly" | "repositorydetermined" +) +``` + +--- + +## CMIS API — Reading Objects + +### Get Object by ID + +Returns `Folder`, `Document`, or `CmisObject` depending on the base type: + +```python +obj = client.get_object( + repository_id="cmis-repo-id", + object_id="some-object-id", + include_acl=True, +) + +print(f"Type: {type(obj).__name__}") +print(f"Name: {obj.name}") +print(f"Modified: {obj.last_modification_date}") + +if isinstance(obj, Document): + print(f"MIME: {obj.content_stream_mime_type}") + print(f"Size: {obj.content_stream_length}") + print(f"Version: {obj.version_label}") +``` + +### Update Properties + +```python +updated = client.update_properties( + repository_id="cmis-repo-id", + object_id="some-object-id", + properties={ + "cmis:name": "Renamed Document.pdf", + "cmis:description": "Updated description", + }, +) + +print(f"New name: {updated.name}") +``` + +Use `change_token` for optimistic concurrency control: + +```python +updated = client.update_properties( + repository_id="cmis-repo-id", + object_id="some-object-id", + properties={"cmis:name": "New Name"}, + change_token="current-change-token-value", +) +``` + +### List Children (Pagination) + +```python +page = client.get_children( + repository_id="cmis-repo-id", + folder_id="parent-folder-id", + max_items=50, + skip_count=0, + order_by="cmis:creationDate ASC", +) + +print(f"Items: {len(page.objects)}, hasMore: {page.has_more_items}, total: {page.num_items}") + +for obj in page.objects: + kind = "Folder" if isinstance(obj, Folder) else "Document" + print(f" [{kind}] {obj.name} — {obj.object_id}") +``` + +Paginate through all children: + +```python +skip = 0 +page_size = 100 + +while True: + page = client.get_children( + repository_id="cmis-repo-id", + folder_id="parent-folder-id", + max_items=page_size, + skip_count=skip, + ) + + for obj in page.objects: + print(obj.name) + + if not page.has_more_items: + break + skip += page_size +``` + +--- diff --git a/tests/core/unit/telemetry/test_module.py b/tests/core/unit/telemetry/test_module.py index 19c8019..3c564be 100644 --- a/tests/core/unit/telemetry/test_module.py +++ b/tests/core/unit/telemetry/test_module.py @@ -44,11 +44,12 @@ def test_module_in_collection(self): def test_all_modules_present(self): """Test that all expected modules are present.""" all_modules = list(Module) - assert len(all_modules) == 4 + assert len(all_modules) == 5 assert Module.AICORE in all_modules assert Module.AUDITLOG in all_modules assert Module.DESTINATION in all_modules assert Module.OBJECTSTORE in all_modules + assert Module.DMS in all_modules def test_module_iteration(self): """Test iterating over Module enum.""" diff --git a/tests/core/unit/telemetry/test_operation.py b/tests/core/unit/telemetry/test_operation.py index f200b5a..31d9b02 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -105,5 +105,5 @@ def test_operation_iteration(self): def test_operation_count(self): """Test that we have the expected number of operations.""" all_operations = list(Operation) - # 2 auditlog + 8 destination + 7 certificate + 7 fragment + 8 objectstore + 2 aicore = 34 - assert len(all_operations) == 34 \ No newline at end of file + # 2 auditlog + 8 destination + 7 certificate + 7 fragment + 8 objectstore + 2 aicore + 18 dms = 52 + assert len(all_operations) == 52 diff --git a/tests/dms/unit/__init__.py b/tests/dms/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/dms/unit/test_client_cmis.py b/tests/dms/unit/test_client_cmis.py new file mode 100644 index 0000000..e762e5e --- /dev/null +++ b/tests/dms/unit/test_client_cmis.py @@ -0,0 +1,955 @@ +"""Unit tests for DMSClient CMIS operations. + +Tests mock HttpInvoker to verify: +- Correct URL construction +- Correct form-data encoding (cmisaction, objectId, indexed properties) +- Correct file tuple for upload methods +- Response parsing into typed models +""" + +from io import BytesIO +from unittest.mock import Mock, patch + +import pytest + +from sap_cloud_sdk.dms.client import DMSClient, _build_properties, _build_aces +from sap_cloud_sdk.dms.model import ( + Ace, Acl, ChildrenPage, CmisObject, DMSCredentials, Document, Folder, UserClaim, +) + + +# --------------------------------------------------------------- +# Helper to wrap a dict in a mock Response with .json() +# --------------------------------------------------------------- + +def _mock_response(data): + """Create a Mock that behaves like requests.Response with .json() returning *data*.""" + resp = Mock() + resp.json.return_value = data + resp.status_code = 200 + return resp + + +# --------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------- + +_FOLDER_RESPONSE = { + "succinctProperties": { + "cmis:objectId": "folder-abc", + "cmis:name": "NewFolder", + "cmis:baseTypeId": "cmis:folder", + "cmis:objectTypeId": "cmis:folder", + "cmis:createdBy": "admin", + "cmis:creationDate": 1705320000000, + } +} + +_DOCUMENT_RESPONSE = { + "succinctProperties": { + "cmis:objectId": "doc-xyz", + "cmis:name": "report.pdf", + "cmis:baseTypeId": "cmis:document", + "cmis:objectTypeId": "cmis:document", + "cmis:contentStreamLength": 2048, + "cmis:contentStreamMimeType": "application/pdf", + "cmis:contentStreamFileName": "report.pdf", + "cmis:versionSeriesId": "vs-1", + "cmis:versionLabel": "1.0", + "cmis:isLatestVersion": True, + "cmis:isMajorVersion": True, + "cmis:isPrivateWorkingCopy": False, + } +} + +_PWC_RESPONSE = { + "succinctProperties": { + "cmis:objectId": "pwc-001", + "cmis:name": "report.pdf", + "cmis:baseTypeId": "cmis:document", + "cmis:objectTypeId": "cmis:document", + "cmis:isPrivateWorkingCopy": True, + "cmis:isVersionSeriesCheckedOut": True, + } +} + +_ACL_RESPONSE = { + "aces": [ + { + "principal": {"principalId": "user1"}, + "permissions": ["cmis:read"], + "isDirect": True, + } + ], + "isExact": True, +} + + +@pytest.fixture +def client(): + """Create a DMSClient with a mocked HttpInvoker.""" + creds = DMSCredentials( + instance_name="test-instance", + uri="https://api.example.com", + client_id="test-client", + client_secret="test-secret", + token_url="https://auth.example.com/oauth/token", + identityzone="test-zone", + ) + with patch("sap_cloud_sdk.dms.client.Auth"), \ + patch("sap_cloud_sdk.dms.client.HttpInvoker") as mock_http_cls: + mock_http = Mock() + mock_http_cls.return_value = mock_http + c = DMSClient(creds) + # Expose the mock for assertions + c._mock_http = mock_http + yield c + + +# --------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------- + +class TestBuildProperties: + def test_single_property(self): + result = _build_properties({"cmis:name": "Doc"}) + assert result == { + "propertyId[0]": "cmis:name", + "propertyValue[0]": "Doc", + } + + def test_multiple_properties(self): + result = _build_properties({ + "cmis:name": "Doc", + "cmis:objectTypeId": "cmis:document", + "cmis:description": "A doc", + }) + assert result["propertyId[0]"] == "cmis:name" + assert result["propertyValue[0]"] == "Doc" + assert result["propertyId[1]"] == "cmis:objectTypeId" + assert result["propertyValue[1]"] == "cmis:document" + assert result["propertyId[2]"] == "cmis:description" + assert result["propertyValue[2]"] == "A doc" + + def test_empty_properties(self): + assert _build_properties({}) == {} + + def test_integer_value_coerced_to_string(self): + result = _build_properties({"cmis:contentStreamLength": "1024"}) + assert result["propertyValue[0]"] == "1024" + + +class TestBuildAces: + def test_single_ace_single_permission(self): + ace = Ace(principal_id="user1", permissions=["cmis:read"]) + result = _build_aces([ace], prefix="addACEPrincipal") + assert result == { + "addACEPrincipal[0]": "user1", + "addACEPermission[0][0]": "cmis:read", + } + + def test_single_ace_multiple_permissions(self): + ace = Ace(principal_id="user1", permissions=["cmis:read", "cmis:write"]) + result = _build_aces([ace], prefix="addACEPrincipal") + assert result == { + "addACEPrincipal[0]": "user1", + "addACEPermission[0][0]": "cmis:read", + "addACEPermission[0][1]": "cmis:write", + } + + def test_multiple_aces(self): + aces = [ + Ace(principal_id="user1", permissions=["cmis:read"]), + Ace(principal_id="user2", permissions=["cmis:all"]), + ] + result = _build_aces(aces, prefix="removeACEPrincipal") + assert result["removeACEPrincipal[0]"] == "user1" + assert result["removeACEPermission[0][0]"] == "cmis:read" + assert result["removeACEPrincipal[1]"] == "user2" + assert result["removeACEPermission[1][0]"] == "cmis:all" + + def test_empty_aces(self): + assert _build_aces([], prefix="addACEPrincipal") == {} + + +class TestBrowserUrl: + def test_no_path(self): + assert DMSClient._browser_url("repo1") == "/browser/repo1/root" + + def test_with_path(self): + assert DMSClient._browser_url("repo1", "sub/folder") == "/browser/repo1/root/sub/folder" + + def test_strips_leading_slash(self): + assert DMSClient._browser_url("repo1", "/sub") == "/browser/repo1/root/sub" + + def test_none_path_same_as_no_path(self): + assert DMSClient._browser_url("repo1", None) == "/browser/repo1/root" + + +# --------------------------------------------------------------- +# create_folder +# --------------------------------------------------------------- + +class TestCreateFolder: + def test_basic(self, client): + client._mock_http.post_form.return_value = _mock_response(_FOLDER_RESPONSE) + + folder = client.create_folder("repo1", "parent-id", "NewFolder") + + assert isinstance(folder, Folder) + assert folder.object_id == "folder-abc" + assert folder.name == "NewFolder" + + call_args = client._mock_http.post_form.call_args + assert call_args[0][0] == "/browser/repo1/root" + data = call_args[1]["data"] + assert data["cmisaction"] == "createFolder" + assert data["objectId"] == "parent-id" + assert data["propertyId[0]"] == "cmis:name" + assert data["propertyValue[0]"] == "NewFolder" + assert data["propertyId[1]"] == "cmis:objectTypeId" + assert data["propertyValue[1]"] == "cmis:folder" + assert data["_charset_"] == "UTF-8" + assert call_args[1]["tenant_subdomain"] is None + + def test_with_description(self, client): + client._mock_http.post_form.return_value = _mock_response(_FOLDER_RESPONSE) + + client.create_folder("repo1", "parent-id", "NewFolder", description="Desc") + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["propertyId[2]"] == "cmis:description" + assert data["propertyValue[2]"] == "Desc" + + def test_with_path_and_tenant(self, client): + client._mock_http.post_form.return_value = _mock_response(_FOLDER_RESPONSE) + + client.create_folder("repo1", "parent-id", "F", path="sub/dir", tenant="t1") + + call_args = client._mock_http.post_form.call_args + assert call_args[0][0] == "/browser/repo1/root/sub/dir" + assert call_args[1]["tenant_subdomain"] == "t1" + + def test_with_user_claim(self, client): + client._mock_http.post_form.return_value = _mock_response(_FOLDER_RESPONSE) + claim = UserClaim(x_ecm_user_enc="alice@sap.com", x_ecm_add_principals=["~editors"]) + + client.create_folder("repo1", "parent-id", "F", user_claim=claim) + + assert client._mock_http.post_form.call_args[1]["user_claim"] is claim + + def test_with_inline_aces(self, client): + client._mock_http.post_form.return_value = _mock_response(_FOLDER_RESPONSE) + add = [Ace(principal_id="u1", permissions=["cmis:read", "cmis:write"])] + remove = [Ace(principal_id="u2", permissions=["cmis:all"])] + + client.create_folder("repo1", "parent-id", "F", add_aces=add, remove_aces=remove) + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["addACEPrincipal[0]"] == "u1" + assert data["addACEPermission[0][0]"] == "cmis:read" + assert data["addACEPermission[0][1]"] == "cmis:write" + assert data["removeACEPrincipal[0]"] == "u2" + assert data["removeACEPermission[0][0]"] == "cmis:all" + + def test_no_aces_by_default(self, client): + client._mock_http.post_form.return_value = _mock_response(_FOLDER_RESPONSE) + + client.create_folder("repo1", "parent-id", "F") + + data = client._mock_http.post_form.call_args[1]["data"] + assert not any(k.startswith("addACE") or k.startswith("removeACE") for k in data) + + +# --------------------------------------------------------------- +# create_document +# --------------------------------------------------------------- + +class TestCreateDocument: + def test_basic(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + stream = BytesIO(b"hello world") + + doc = client.create_document("repo1", "folder-id", "report.pdf", stream, "application/pdf") + + assert isinstance(doc, Document) + assert doc.object_id == "doc-xyz" + assert doc.content_stream_mime_type == "application/pdf" + + call_args = client._mock_http.post_form.call_args + assert call_args[0][0] == "/browser/repo1/root" + data = call_args[1]["data"] + assert data["cmisaction"] == "createDocument" + assert data["objectId"] == "folder-id" + assert data["propertyId[0]"] == "cmis:name" + assert data["propertyValue[0]"] == "report.pdf" + assert data["propertyId[1]"] == "cmis:objectTypeId" + assert data["propertyValue[1]"] == "cmis:document" + + files_arg = call_args[1]["files"] + assert "media" in files_arg + assert files_arg["media"][0] == "report.pdf" + assert files_arg["media"][2] == "application/pdf" + + def test_with_description(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.create_document("repo1", "folder-id", "f.txt", BytesIO(b""), "text/plain", description="D") + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["propertyId[2]"] == "cmis:description" + assert data["propertyValue[2]"] == "D" + + def test_with_tenant(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.create_document("repo1", "fid", "f.txt", BytesIO(b""), "text/plain", tenant="sub1") + + assert client._mock_http.post_form.call_args[1]["tenant_subdomain"] == "sub1" + + def test_with_user_claim(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + claim = UserClaim(x_ecm_user_enc="bob@sap.com") + + client.create_document("repo1", "fid", "f.txt", BytesIO(b""), "text/plain", user_claim=claim) + + assert client._mock_http.post_form.call_args[1]["user_claim"] is claim + + def test_with_inline_aces(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + add = [Ace(principal_id="reader@sap.com", permissions=["cmis:read"])] + + client.create_document("repo1", "fid", "f.txt", BytesIO(b""), "text/plain", add_aces=add) + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["addACEPrincipal[0]"] == "reader@sap.com" + assert data["addACEPermission[0][0]"] == "cmis:read" + assert not any(k.startswith("removeACE") for k in data) + + +# --------------------------------------------------------------- +# check_out +# --------------------------------------------------------------- + +class TestCheckOut: + def test_basic(self, client): + client._mock_http.post_form.return_value = _mock_response(_PWC_RESPONSE) + + doc = client.check_out("repo1", "doc-xyz") + + assert isinstance(doc, Document) + assert doc.object_id == "pwc-001" + assert doc.is_private_working_copy is True + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["cmisaction"] == "checkOut" + assert data["objectId"] == "doc-xyz" + + def test_with_tenant(self, client): + client._mock_http.post_form.return_value = _mock_response(_PWC_RESPONSE) + + client.check_out("repo1", "doc-xyz", tenant="t1") + + assert client._mock_http.post_form.call_args[1]["tenant_subdomain"] == "t1" + + def test_with_user_claim(self, client): + client._mock_http.post_form.return_value = _mock_response(_PWC_RESPONSE) + claim = UserClaim(x_ecm_user_enc="dave@sap.com") + + client.check_out("repo1", "doc-xyz", user_claim=claim) + + assert client._mock_http.post_form.call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# check_in +# --------------------------------------------------------------- + +class TestCheckIn: + def test_major_version_no_file(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + doc = client.check_in("repo1", "pwc-001") + + assert isinstance(doc, Document) + data = client._mock_http.post_form.call_args[1]["data"] + assert data["cmisaction"] == "checkIn" + assert data["objectId"] == "pwc-001" + assert data["major"] == "true" + assert client._mock_http.post_form.call_args[1]["files"] is None + + def test_minor_version(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.check_in("repo1", "pwc-001", major=False) + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["major"] == "false" + + def test_with_file_and_comment(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + stream = BytesIO(b"updated content") + + client.check_in( + "repo1", "pwc-001", + file=stream, + file_name="report_v2.pdf", + mime_type="application/pdf", + checkin_comment="Version 2", + ) + + call_args = client._mock_http.post_form.call_args + data = call_args[1]["data"] + assert data["checkinComment"] == "Version 2" + + files_arg = call_args[1]["files"] + assert "content" in files_arg + assert files_arg["content"][0] == "report_v2.pdf" + assert files_arg["content"][2] == "application/pdf" + + def test_file_without_name_uses_default(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.check_in("repo1", "pwc-001", file=BytesIO(b"data")) + + files_arg = client._mock_http.post_form.call_args[1]["files"] + assert files_arg["content"][0] == "content" + assert files_arg["content"][2] == "application/octet-stream" + + def test_with_user_claim(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + claim = UserClaim(x_ecm_user_enc="eve@sap.com", x_ecm_add_principals=["~reviewers"]) + + client.check_in("repo1", "pwc-001", user_claim=claim) + + assert client._mock_http.post_form.call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# cancel_check_out +# --------------------------------------------------------------- + +class TestCancelCheckOut: + def test_basic(self, client): + client._mock_http.post_form.return_value = _mock_response({}) + + result = client.cancel_check_out("repo1", "pwc-001") + + assert result is None + data = client._mock_http.post_form.call_args[1]["data"] + assert data["cmisaction"] == "cancelCheckOut" + assert data["objectId"] == "pwc-001" + + def test_with_tenant(self, client): + client._mock_http.post_form.return_value = _mock_response({}) + + client.cancel_check_out("repo1", "pwc-001", tenant="sub2") + + assert client._mock_http.post_form.call_args[1]["tenant_subdomain"] == "sub2" + + def test_with_user_claim(self, client): + client._mock_http.post_form.return_value = _mock_response({}) + claim = UserClaim(x_ecm_user_enc="frank@sap.com") + + client.cancel_check_out("repo1", "pwc-001", user_claim=claim) + + assert client._mock_http.post_form.call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# apply_acl +# --------------------------------------------------------------- + +class TestApplyAcl: + def test_get_acl_when_no_aces(self, client): + """No add/remove => GET current ACL.""" + client._mock_http.get.return_value = _mock_response(_ACL_RESPONSE) + + acl = client.apply_acl("repo1", "obj-99") + + assert isinstance(acl, Acl) + assert len(acl.aces) == 1 + assert acl.aces[0].principal_id == "user1" + + call_args = client._mock_http.get.call_args + assert call_args[0][0] == "/browser/repo1/root" + assert call_args[1]["params"] == {"objectId": "obj-99", "cmisselector": "acl"} + + def test_get_acl_with_tenant(self, client): + client._mock_http.get.return_value = _mock_response(_ACL_RESPONSE) + + client.apply_acl("repo1", "obj-99", tenant="t1") + + assert client._mock_http.get.call_args[1]["tenant_subdomain"] == "t1" + + def test_get_acl_with_user_claim(self, client): + client._mock_http.get.return_value = _mock_response(_ACL_RESPONSE) + claim = UserClaim(x_ecm_user_enc="grace@sap.com") + + client.apply_acl("repo1", "obj-99", user_claim=claim) + + assert client._mock_http.get.call_args[1]["user_claim"] is claim + + def test_add_aces_only(self, client): + client._mock_http.post_form.return_value = _mock_response(_ACL_RESPONSE) + aces = [Ace(principal_id="user1", permissions=["cmis:read", "cmis:write"])] + + acl = client.apply_acl("repo1", "obj-99", add_aces=aces) + + assert isinstance(acl, Acl) + data = client._mock_http.post_form.call_args[1]["data"] + assert data["cmisaction"] == "applyACL" + assert data["objectId"] == "obj-99" + assert data["ACLPropagation"] == "propagate" + assert data["addACEPrincipal[0]"] == "user1" + assert data["addACEPermission[0][0]"] == "cmis:read" + assert data["addACEPermission[0][1]"] == "cmis:write" + assert not any(k.startswith("removeACE") for k in data) + + def test_remove_aces_only(self, client): + client._mock_http.post_form.return_value = _mock_response(_ACL_RESPONSE) + aces = [Ace(principal_id="user1", permissions=["cmis:write"])] + + acl = client.apply_acl("repo1", "obj-99", remove_aces=aces) + + assert isinstance(acl, Acl) + data = client._mock_http.post_form.call_args[1]["data"] + assert data["cmisaction"] == "applyACL" + assert data["removeACEPrincipal[0]"] == "user1" + assert data["removeACEPermission[0][0]"] == "cmis:write" + assert not any(k.startswith("addACE") for k in data) + + def test_add_and_remove_together(self, client): + client._mock_http.post_form.return_value = _mock_response(_ACL_RESPONSE) + add = [Ace(principal_id="u1", permissions=["cmis:read"])] + remove = [Ace(principal_id="u2", permissions=["cmis:all"])] + + acl = client.apply_acl("repo1", "obj-99", add_aces=add, remove_aces=remove) + + assert isinstance(acl, Acl) + data = client._mock_http.post_form.call_args[1]["data"] + assert data["addACEPrincipal[0]"] == "u1" + assert data["addACEPermission[0][0]"] == "cmis:read" + assert data["removeACEPrincipal[0]"] == "u2" + assert data["removeACEPermission[0][0]"] == "cmis:all" + + def test_multiple_add_aces(self, client): + client._mock_http.post_form.return_value = _mock_response(_ACL_RESPONSE) + aces = [ + Ace(principal_id="u1", permissions=["cmis:read"]), + Ace(principal_id="u2", permissions=["cmis:all"]), + ] + + client.apply_acl("repo1", "obj-99", add_aces=aces) + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["addACEPrincipal[0]"] == "u1" + assert data["addACEPrincipal[1]"] == "u2" + + def test_with_tenant_on_modify(self, client): + client._mock_http.post_form.return_value = _mock_response(_ACL_RESPONSE) + aces = [Ace(principal_id="u1", permissions=["cmis:read"])] + + client.apply_acl("repo1", "obj-99", remove_aces=aces, tenant="t1") + + assert client._mock_http.post_form.call_args[1]["tenant_subdomain"] == "t1" + + def test_with_user_claim_on_modify(self, client): + client._mock_http.post_form.return_value = _mock_response(_ACL_RESPONSE) + aces = [Ace(principal_id="u1", permissions=["cmis:read"])] + claim = UserClaim(x_ecm_user_enc="heidi@sap.com") + + client.apply_acl("repo1", "obj-99", add_aces=aces, user_claim=claim) + + assert client._mock_http.post_form.call_args[1]["user_claim"] is claim + + def test_custom_acl_propagation(self, client): + client._mock_http.post_form.return_value = _mock_response(_ACL_RESPONSE) + aces = [Ace(principal_id="u1", permissions=["cmis:read"])] + + client.apply_acl("repo1", "obj-99", add_aces=aces, acl_propagation="objectonly") + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["ACLPropagation"] == "objectonly" + + +# --------------------------------------------------------------- +# get_object +# --------------------------------------------------------------- + +class TestGetObject: + def test_returns_folder_for_folder_type(self, client): + client._mock_http.get.return_value = _mock_response(_FOLDER_RESPONSE) + + obj = client.get_object("repo1", "folder-abc") + + assert isinstance(obj, Folder) + assert obj.object_id == "folder-abc" + assert obj.name == "NewFolder" + + call_args = client._mock_http.get.call_args + assert call_args[0][0] == "/browser/repo1/root" + params = call_args[1]["params"] + assert params["objectId"] == "folder-abc" + assert params["cmisselector"] == "object" + assert params["succinct"] == "true" + + def test_returns_document_for_document_type(self, client): + client._mock_http.get.return_value = _mock_response(_DOCUMENT_RESPONSE) + + obj = client.get_object("repo1", "doc-xyz") + + assert isinstance(obj, Document) + assert obj.object_id == "doc-xyz" + assert obj.content_stream_mime_type == "application/pdf" + + def test_returns_cmis_object_for_unknown_type(self, client): + client._mock_http.get.return_value = _mock_response({ + "succinctProperties": { + "cmis:objectId": "item-1", + "cmis:name": "Item", + "cmis:baseTypeId": "cmis:item", + "cmis:objectTypeId": "cmis:item", + } + }) + + obj = client.get_object("repo1", "item-1") + + assert isinstance(obj, CmisObject) + assert not isinstance(obj, (Folder, Document)) + assert obj.object_id == "item-1" + + def test_with_filter_and_include_acl(self, client): + client._mock_http.get.return_value = _mock_response(_FOLDER_RESPONSE) + + client.get_object("repo1", "folder-abc", filter="*", include_acl=True) + + params = client._mock_http.get.call_args[1]["params"] + assert params["filter"] == "*" + assert params["includeACL"] == "true" + + def test_with_include_allowable_actions(self, client): + client._mock_http.get.return_value = _mock_response(_FOLDER_RESPONSE) + + client.get_object("repo1", "folder-abc", include_allowable_actions=True) + + params = client._mock_http.get.call_args[1]["params"] + assert params["includeAllowableActions"] == "true" + + def test_without_succinct(self, client): + resp = { + "properties": { + "cmis:objectId": {"value": "f1"}, + "cmis:name": {"value": "F"}, + "cmis:baseTypeId": {"value": "cmis:folder"}, + "cmis:objectTypeId": {"value": "cmis:folder"}, + } + } + client._mock_http.get.return_value = _mock_response(resp) + + obj = client.get_object("repo1", "f1", succinct=False) + + params = client._mock_http.get.call_args[1]["params"] + assert "succinct" not in params + assert isinstance(obj, Folder) + + def test_with_tenant_and_user_claim(self, client): + client._mock_http.get.return_value = _mock_response(_FOLDER_RESPONSE) + claim = UserClaim(x_ecm_user_enc="alice@sap.com") + + client.get_object("repo1", "folder-abc", tenant="t1", user_claim=claim) + + call_args = client._mock_http.get.call_args + assert call_args[1]["tenant_subdomain"] == "t1" + assert call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# get_content +# --------------------------------------------------------------- + +class TestGetContent: + def test_basic(self, client): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.content = b"PDF binary data" + client._mock_http.get_stream.return_value = mock_resp + + resp = client.get_content("repo1", "doc-xyz") + + assert resp is mock_resp + call_args = client._mock_http.get_stream.call_args + assert call_args[0][0] == "/browser/repo1/root" + params = call_args[1]["params"] + assert params["objectId"] == "doc-xyz" + assert params["cmisselector"] == "content" + + def test_with_tenant(self, client): + mock_resp = Mock() + client._mock_http.get_stream.return_value = mock_resp + + client.get_content("repo1", "doc-xyz", tenant="sub1") + + assert client._mock_http.get_stream.call_args[1]["tenant_subdomain"] == "sub1" + + def test_with_user_claim(self, client): + mock_resp = Mock() + client._mock_http.get_stream.return_value = mock_resp + claim = UserClaim(x_ecm_user_enc="bob@sap.com") + + client.get_content("repo1", "doc-xyz", user_claim=claim) + + assert client._mock_http.get_stream.call_args[1]["user_claim"] is claim + + def test_with_download_attachment(self, client): + mock_resp = Mock() + client._mock_http.get_stream.return_value = mock_resp + + client.get_content("repo1", "doc-xyz", download="attachment") + + params = client._mock_http.get_stream.call_args[1]["params"] + assert params["download"] == "attachment" + + def test_no_download_by_default(self, client): + mock_resp = Mock() + client._mock_http.get_stream.return_value = mock_resp + + client.get_content("repo1", "doc-xyz") + + params = client._mock_http.get_stream.call_args[1]["params"] + assert "download" not in params + + def test_with_stream_id(self, client): + mock_resp = Mock() + client._mock_http.get_stream.return_value = mock_resp + + client.get_content("repo1", "doc-xyz", stream_id="sap:zipRendition") + + params = client._mock_http.get_stream.call_args[1]["params"] + assert params["streamId"] == "sap:zipRendition" + + def test_with_filename(self, client): + mock_resp = Mock() + client._mock_http.get_stream.return_value = mock_resp + + client.get_content("repo1", "doc-xyz", filename="report.pdf") + + params = client._mock_http.get_stream.call_args[1]["params"] + assert params["filename"] == "report.pdf" + + def test_no_stream_id_or_filename_by_default(self, client): + mock_resp = Mock() + client._mock_http.get_stream.return_value = mock_resp + + client.get_content("repo1", "doc-xyz") + + params = client._mock_http.get_stream.call_args[1]["params"] + assert "streamId" not in params + assert "filename" not in params + + +# --------------------------------------------------------------- +# update_properties +# --------------------------------------------------------------- + +class TestUpdateProperties: + def test_basic_rename(self, client): + resp = { + "succinctProperties": { + "cmis:objectId": "doc-xyz", + "cmis:name": "Renamed.pdf", + "cmis:baseTypeId": "cmis:document", + "cmis:objectTypeId": "cmis:document", + "cmis:contentStreamLength": 2048, + "cmis:contentStreamMimeType": "application/pdf", + } + } + client._mock_http.post_form.return_value = _mock_response(resp) + + obj = client.update_properties("repo1", "doc-xyz", {"cmis:name": "Renamed.pdf"}) + + assert isinstance(obj, Document) + assert obj.name == "Renamed.pdf" + + call_args = client._mock_http.post_form.call_args + assert call_args[0][0] == "/browser/repo1/root" + data = call_args[1]["data"] + assert data["cmisaction"] == "update" + assert data["objectId"] == "doc-xyz" + assert data["_charset_"] == "UTF-8" + assert data["propertyId[0]"] == "cmis:name" + assert data["propertyValue[0]"] == "Renamed.pdf" + + def test_returns_folder_for_folder_type(self, client): + client._mock_http.post_form.return_value = _mock_response(_FOLDER_RESPONSE) + + obj = client.update_properties("repo1", "folder-abc", {"cmis:description": "Updated"}) + + assert isinstance(obj, Folder) + + def test_with_change_token(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.update_properties("repo1", "doc-xyz", {"cmis:name": "X"}, change_token="tok-123") + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["changeToken"] == "tok-123" + + def test_no_change_token_by_default(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.update_properties("repo1", "doc-xyz", {"cmis:name": "X"}) + + data = client._mock_http.post_form.call_args[1]["data"] + assert "changeToken" not in data + + def test_multiple_properties(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.update_properties("repo1", "doc-xyz", { + "cmis:name": "New", + "cmis:description": "Desc", + }) + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["propertyId[0]"] == "cmis:name" + assert data["propertyValue[0]"] == "New" + assert data["propertyId[1]"] == "cmis:description" + assert data["propertyValue[1]"] == "Desc" + + def test_with_tenant_and_user_claim(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + claim = UserClaim(x_ecm_user_enc="charlie@sap.com") + + client.update_properties("repo1", "doc-xyz", {"cmis:name": "X"}, tenant="t1", user_claim=claim) + + call_args = client._mock_http.post_form.call_args + assert call_args[1]["tenant_subdomain"] == "t1" + assert call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# get_children +# --------------------------------------------------------------- + +_CHILDREN_RESPONSE = { + "objects": [ + { + "object": { + "succinctProperties": { + "cmis:objectId": "child-folder-1", + "cmis:name": "SubFolder", + "cmis:baseTypeId": "cmis:folder", + "cmis:objectTypeId": "cmis:folder", + } + } + }, + { + "object": { + "succinctProperties": { + "cmis:objectId": "child-doc-1", + "cmis:name": "readme.txt", + "cmis:baseTypeId": "cmis:document", + "cmis:objectTypeId": "cmis:document", + "cmis:contentStreamLength": 512, + "cmis:contentStreamMimeType": "text/plain", + } + } + }, + ], + "hasMoreItems": True, + "numItems": 42, +} + + +class TestGetChildren: + def test_basic(self, client): + client._mock_http.get.return_value = _mock_response(_CHILDREN_RESPONSE) + + page = client.get_children("repo1", "root-folder-id") + + assert isinstance(page, ChildrenPage) + assert len(page.objects) == 2 + assert page.has_more_items is True + assert page.num_items == 42 + + # First child is Folder + assert isinstance(page.objects[0], Folder) + assert page.objects[0].object_id == "child-folder-1" + + # Second child is Document + assert isinstance(page.objects[1], Document) + assert page.objects[1].object_id == "child-doc-1" + + call_args = client._mock_http.get.call_args + assert call_args[0][0] == "/browser/repo1/root" + params = call_args[1]["params"] + assert params["objectId"] == "root-folder-id" + assert params["cmisselector"] == "children" + assert params["maxItems"] == "100" + assert params["skipCount"] == "0" + assert params["succinct"] == "true" + + def test_pagination_params(self, client): + client._mock_http.get.return_value = _mock_response({"objects": [], "hasMoreItems": False}) + + client.get_children("repo1", "fid", max_items=50, skip_count=200) + + params = client._mock_http.get.call_args[1]["params"] + assert params["maxItems"] == "50" + assert params["skipCount"] == "200" + + def test_with_order_by(self, client): + client._mock_http.get.return_value = _mock_response({"objects": [], "hasMoreItems": False}) + + client.get_children("repo1", "fid", order_by="cmis:creationDate ASC") + + params = client._mock_http.get.call_args[1]["params"] + assert params["orderBy"] == "cmis:creationDate ASC" + + def test_with_filter(self, client): + client._mock_http.get.return_value = _mock_response({"objects": [], "hasMoreItems": False}) + + client.get_children("repo1", "fid", filter="cmis:name,cmis:objectId") + + params = client._mock_http.get.call_args[1]["params"] + assert params["filter"] == "cmis:name,cmis:objectId" + + def test_with_include_allowable_and_path_segment(self, client): + client._mock_http.get.return_value = _mock_response({"objects": [], "hasMoreItems": False}) + + client.get_children("repo1", "fid", include_allowable_actions=True, include_path_segment=True) + + params = client._mock_http.get.call_args[1]["params"] + assert params["includeAllowableActions"] == "true" + assert params["includePathSegment"] == "true" + + def test_no_optional_params_omitted(self, client): + client._mock_http.get.return_value = _mock_response({"objects": [], "hasMoreItems": False}) + + client.get_children("repo1", "fid") + + params = client._mock_http.get.call_args[1]["params"] + assert "orderBy" not in params + assert "filter" not in params + assert "includeAllowableActions" not in params + assert "includePathSegment" not in params + + def test_empty_children(self, client): + client._mock_http.get.return_value = _mock_response({"objects": [], "hasMoreItems": False, "numItems": 0}) + + page = client.get_children("repo1", "fid") + + assert page.objects == [] + assert page.has_more_items is False + assert page.num_items == 0 + + def test_with_tenant_and_user_claim(self, client): + client._mock_http.get.return_value = _mock_response({"objects": [], "hasMoreItems": False}) + claim = UserClaim(x_ecm_user_enc="dana@sap.com") + + client.get_children("repo1", "fid", tenant="t1", user_claim=claim) + + call_args = client._mock_http.get.call_args + assert call_args[1]["tenant_subdomain"] == "t1" + assert call_args[1]["user_claim"] is claim diff --git a/tests/dms/unit/test_cmis_models.py b/tests/dms/unit/test_cmis_models.py new file mode 100644 index 0000000..87daebb --- /dev/null +++ b/tests/dms/unit/test_cmis_models.py @@ -0,0 +1,361 @@ +"""Unit tests for CMIS object models.""" + +from datetime import datetime, timezone + +import pytest + +from sap_cloud_sdk.dms.model import ( + Ace, + Acl, + ChildrenPage, + CmisObject, + Document, + Folder, + _parse_datetime as _parse_cmis_datetime, + _prop_val, +) + + +# --------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------- + +class TestParseCmisDatetime: + def test_none_returns_none(self): + assert _parse_cmis_datetime(None) is None + + def test_epoch_millis(self): + # 2024-01-15T12:00:00Z + result = _parse_cmis_datetime(1705320000000) + assert isinstance(result, datetime) + assert result.tzinfo == timezone.utc + assert result.year == 2024 + + def test_iso_string_with_z(self): + result = _parse_cmis_datetime("2024-01-15T12:00:00Z") + assert isinstance(result, datetime) + assert result.tzinfo is not None + + def test_iso_string_with_offset(self): + result = _parse_cmis_datetime("2024-01-15T12:00:00+00:00") + assert isinstance(result, datetime) + + +class TestPropVal: + def test_succinct_format(self): + props = {"cmis:name": "MyDoc"} + assert _prop_val(props, "cmis:name") == "MyDoc" + + def test_verbose_format(self): + props = {"cmis:name": {"value": "MyDoc"}} + assert _prop_val(props, "cmis:name") == "MyDoc" + + def test_missing_key_returns_none(self): + assert _prop_val({}, "cmis:name") is None + + def test_dict_without_value_key(self): + props = {"cmis:name": {"id": "cmis:name", "localName": "name"}} + result = _prop_val(props, "cmis:name") + assert isinstance(result, dict) + + def test_boolean_value(self): + props = {"cmis:isLatestVersion": True} + assert _prop_val(props, "cmis:isLatestVersion") is True + + def test_integer_value(self): + props = {"cmis:contentStreamLength": 42} + assert _prop_val(props, "cmis:contentStreamLength") == 42 + + +# --------------------------------------------------------------- +# CmisObject +# --------------------------------------------------------------- + +_SUCCINCT_FOLDER = { + "succinctProperties": { + "cmis:objectId": "folder-123", + "cmis:name": "TestFolder", + "cmis:baseTypeId": "cmis:folder", + "cmis:objectTypeId": "cmis:folder", + "cmis:createdBy": "admin", + "cmis:creationDate": 1705320000000, + "cmis:lastModifiedBy": "admin", + "cmis:lastModificationDate": 1705320000000, + "cmis:changeToken": "tok1", + "cmis:description": "A test folder", + "sap:parentIds": ["root-id"], + } +} + +_SUCCINCT_DOCUMENT = { + "succinctProperties": { + "cmis:objectId": "doc-456", + "cmis:name": "readme.txt", + "cmis:baseTypeId": "cmis:document", + "cmis:objectTypeId": "cmis:document", + "cmis:createdBy": "user1", + "cmis:creationDate": 1705320000000, + "cmis:lastModifiedBy": "user1", + "cmis:lastModificationDate": 1705320000000, + "cmis:contentStreamLength": 1024, + "cmis:contentStreamMimeType": "text/plain", + "cmis:contentStreamFileName": "readme.txt", + "cmis:versionSeriesId": "vs-789", + "cmis:versionLabel": "1.0", + "cmis:isLatestVersion": True, + "cmis:isMajorVersion": True, + "cmis:isLatestMajorVersion": True, + "cmis:isPrivateWorkingCopy": False, + "cmis:checkinComment": "Initial upload", + "cmis:isVersionSeriesCheckedOut": False, + "cmis:versionSeriesCheckedOutId": None, + "sap:parentIds": ["folder-123"], + } +} + + +class TestCmisObject: + def test_from_dict_succinct(self): + obj = CmisObject.from_dict(_SUCCINCT_FOLDER) + assert obj.object_id == "folder-123" + assert obj.name == "TestFolder" + assert obj.base_type_id == "cmis:folder" + assert obj.created_by == "admin" + assert obj.description == "A test folder" + assert obj.parent_ids == ["root-id"] + assert isinstance(obj.creation_date, datetime) + + def test_from_dict_verbose(self): + data = { + "properties": { + "cmis:objectId": {"value": "obj-99"}, + "cmis:name": {"value": "VerboseObj"}, + "cmis:baseTypeId": {"value": "cmis:folder"}, + "cmis:objectTypeId": {"value": "cmis:folder"}, + } + } + obj = CmisObject.from_dict(data) + assert obj.object_id == "obj-99" + assert obj.name == "VerboseObj" + + def test_from_dict_empty(self): + obj = CmisObject.from_dict({}) + assert obj.object_id == "" + assert obj.name == "" + assert obj.properties == {} + + def test_from_dict_prefers_succinct_over_properties(self): + data = { + "succinctProperties": {"cmis:name": "Succinct"}, + "properties": {"cmis:name": {"value": "Verbose"}}, + } + obj = CmisObject.from_dict(data) + assert obj.name == "Succinct" + + +# --------------------------------------------------------------- +# Folder +# --------------------------------------------------------------- + +class TestFolder: + def test_from_dict(self): + folder = Folder.from_dict(_SUCCINCT_FOLDER) + assert isinstance(folder, Folder) + assert isinstance(folder, CmisObject) + assert folder.object_id == "folder-123" + assert folder.name == "TestFolder" + + def test_from_dict_empty(self): + folder = Folder.from_dict({}) + assert folder.object_id == "" + + +# --------------------------------------------------------------- +# Document +# --------------------------------------------------------------- + +class TestDocument: + def test_from_dict_full(self): + doc = Document.from_dict(_SUCCINCT_DOCUMENT) + assert isinstance(doc, Document) + assert isinstance(doc, CmisObject) + assert doc.object_id == "doc-456" + assert doc.name == "readme.txt" + assert doc.content_stream_length == 1024 + assert doc.content_stream_mime_type == "text/plain" + assert doc.content_stream_file_name == "readme.txt" + assert doc.version_series_id == "vs-789" + assert doc.version_label == "1.0" + assert doc.is_latest_version is True + assert doc.is_major_version is True + assert doc.is_latest_major_version is True + assert doc.is_private_working_copy is False + assert doc.checkin_comment == "Initial upload" + assert doc.is_version_series_checked_out is False + assert doc.version_series_checked_out_id is None + assert doc.parent_ids == ["folder-123"] + + def test_from_dict_minimal(self): + data = { + "succinctProperties": { + "cmis:objectId": "doc-min", + "cmis:name": "minimal.pdf", + "cmis:baseTypeId": "cmis:document", + "cmis:objectTypeId": "cmis:document", + } + } + doc = Document.from_dict(data) + assert doc.object_id == "doc-min" + assert doc.content_stream_length is None + assert doc.version_label is None + + def test_from_dict_empty(self): + doc = Document.from_dict({}) + assert doc.object_id == "" + assert doc.content_stream_length is None + + +# --------------------------------------------------------------- +# Ace +# --------------------------------------------------------------- + +class TestAce: + def test_from_dict(self): + data = { + "principal": {"principalId": "user1"}, + "permissions": ["cmis:read", "cmis:write"], + "isDirect": True, + } + ace = Ace.from_dict(data) + assert ace.principal_id == "user1" + assert ace.permissions == ["cmis:read", "cmis:write"] + assert ace.is_direct is True + + def test_from_dict_missing_principal(self): + ace = Ace.from_dict({}) + assert ace.principal_id == "" + assert ace.permissions == [] + assert ace.is_direct is True + + def test_from_dict_indirect(self): + data = { + "principal": {"principalId": "role:Admin"}, + "permissions": ["cmis:all"], + "isDirect": False, + } + ace = Ace.from_dict(data) + assert ace.is_direct is False + + def test_constructor(self): + ace = Ace(principal_id="p1", permissions=["cmis:read"]) + assert ace.principal_id == "p1" + assert ace.permissions == ["cmis:read"] + + +# --------------------------------------------------------------- +# Acl +# --------------------------------------------------------------- + +class TestAcl: + def test_from_dict(self): + data = { + "aces": [ + { + "principal": {"principalId": "user1"}, + "permissions": ["cmis:read"], + "isDirect": True, + }, + { + "principal": {"principalId": "user2"}, + "permissions": ["cmis:write"], + "isDirect": False, + }, + ], + "isExact": False, + } + acl = Acl.from_dict(data) + assert len(acl.aces) == 2 + assert acl.aces[0].principal_id == "user1" + assert acl.aces[1].principal_id == "user2" + assert acl.is_exact is False + + def test_from_dict_empty(self): + acl = Acl.from_dict({}) + assert acl.aces == [] + assert acl.is_exact is True + + def test_from_dict_no_aces(self): + acl = Acl.from_dict({"aces": [], "isExact": True}) + assert acl.aces == [] + + +class TestChildrenPage: + def test_from_dict_mixed_types(self): + data = { + "objects": [ + { + "object": { + "succinctProperties": { + "cmis:objectId": "f1", + "cmis:name": "Folder1", + "cmis:baseTypeId": "cmis:folder", + "cmis:objectTypeId": "cmis:folder", + } + } + }, + { + "object": { + "succinctProperties": { + "cmis:objectId": "d1", + "cmis:name": "Doc1", + "cmis:baseTypeId": "cmis:document", + "cmis:objectTypeId": "cmis:document", + "cmis:contentStreamLength": 100, + } + } + }, + ], + "hasMoreItems": True, + "numItems": 50, + } + page = ChildrenPage.from_dict(data) + assert len(page.objects) == 2 + assert isinstance(page.objects[0], Folder) + assert isinstance(page.objects[1], Document) + assert page.objects[0].object_id == "f1" + assert page.objects[1].object_id == "d1" + assert page.objects[1].content_stream_length == 100 + assert page.has_more_items is True + assert page.num_items == 50 + + def test_from_dict_empty(self): + page = ChildrenPage.from_dict({"objects": [], "hasMoreItems": False}) + assert page.objects == [] + assert page.has_more_items is False + assert page.num_items is None + + def test_from_dict_unknown_type(self): + data = { + "objects": [ + { + "object": { + "succinctProperties": { + "cmis:objectId": "i1", + "cmis:name": "Item", + "cmis:baseTypeId": "cmis:item", + "cmis:objectTypeId": "cmis:item", + } + } + }, + ], + "hasMoreItems": False, + } + page = ChildrenPage.from_dict(data) + assert len(page.objects) == 1 + assert isinstance(page.objects[0], CmisObject) + assert not isinstance(page.objects[0], (Folder, Document)) + + def test_from_dict_no_num_items(self): + page = ChildrenPage.from_dict({"objects": [], "hasMoreItems": True}) + assert page.has_more_items is True + assert page.num_items is None diff --git a/tests/dms/unit/test_http_invoker.py b/tests/dms/unit/test_http_invoker.py new file mode 100644 index 0000000..4e0b66f --- /dev/null +++ b/tests/dms/unit/test_http_invoker.py @@ -0,0 +1,319 @@ +"""Unit tests for HttpInvoker (get, post_form, get_stream, header methods).""" + +from unittest.mock import Mock, patch + +import pytest +import requests + +from sap_cloud_sdk.dms._http import HttpInvoker +from sap_cloud_sdk.dms.exceptions import ( + DMSConnectionError, + DMSInvalidArgumentException, + DMSObjectNotFoundException, + DMSPermissionDeniedException, + DMSRuntimeException, +) + + +@pytest.fixture +def mock_auth(): + auth = Mock() + auth.get_token.return_value = "test-token-123" + return auth + + +@pytest.fixture +def invoker(mock_auth): + return HttpInvoker( + auth=mock_auth, + base_url="https://api.example.com", + connect_timeout=5, + read_timeout=15, + ) + + +# --------------------------------------------------------------- +# Header helpers +# --------------------------------------------------------------- + +class TestHeaders: + def test_auth_header(self, invoker): + headers = invoker._auth_header() + assert headers == {"Authorization": "Bearer test-token-123"} + + def test_auth_header_with_tenant(self, invoker, mock_auth): + invoker._auth_header("tenant-sub") + mock_auth.get_token.assert_called_with("tenant-sub") + + def test_default_headers(self, invoker): + headers = invoker._default_headers() + assert headers["Authorization"] == "Bearer test-token-123" + assert headers["Content-Type"] == "application/json" + assert headers["Accept"] == "application/json" + + def test_merged_headers_applies_overrides(self, invoker): + merged = invoker._merged_headers(None, {"Accept": "text/xml"}) + assert merged["Accept"] == "text/xml" + assert merged["Authorization"] == "Bearer test-token-123" + + +# --------------------------------------------------------------- +# GET +# --------------------------------------------------------------- + +class TestGet: + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_basic(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.content = b'{"key": "val"}' + mock_resp.json.return_value = {"key": "val"} + mock_get.return_value = mock_resp + + result = invoker.get("/rest/v2/repos") + + mock_get.assert_called_once_with( + "https://api.example.com/rest/v2/repos", + headers={ + "Authorization": "Bearer test-token-123", + "Content-Type": "application/json", + "Accept": "application/json", + }, + params=None, + timeout=(5, 15), + ) + assert result is mock_resp + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_with_params(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_get.return_value = mock_resp + + result = invoker.get("/path", params={"objectId": "abc", "cmisselector": "acl"}) + + call_kwargs = mock_get.call_args[1] + assert call_kwargs["params"] == {"objectId": "abc", "cmisselector": "acl"} + assert result is mock_resp + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_with_custom_headers(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_get.return_value = mock_resp + + invoker.get("/repos", headers={"Accept": "application/vnd.sap.sdm+json"}) + + call_kwargs = mock_get.call_args[1] + # Custom Accept should override default + assert call_kwargs["headers"]["Accept"] == "application/vnd.sap.sdm+json" + # Auth should still be present + assert call_kwargs["headers"]["Authorization"] == "Bearer test-token-123" + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_with_tenant(self, mock_get, invoker, mock_auth): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_get.return_value = mock_resp + + invoker.get("/path", tenant_subdomain="sub1") + + mock_auth.get_token.assert_called_with("sub1") + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_404_raises_not_found(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 404 + mock_resp.text = "Not Found" + mock_get.return_value = mock_resp + + with pytest.raises(DMSObjectNotFoundException) as exc_info: + invoker.get("/missing") + assert exc_info.value.status_code == 404 + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_400_raises_invalid_argument(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 400 + mock_resp.text = "Bad Request" + mock_get.return_value = mock_resp + + with pytest.raises(DMSInvalidArgumentException) as exc_info: + invoker.get("/bad") + assert exc_info.value.status_code == 400 + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_401_raises_permission_denied(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 401 + mock_resp.text = "Unauthorized" + mock_get.return_value = mock_resp + + with pytest.raises(DMSPermissionDeniedException) as exc_info: + invoker.get("/unauthorized") + assert exc_info.value.status_code == 401 + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_500_raises_runtime(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 500 + mock_resp.text = "Internal Server Error" + mock_get.return_value = mock_resp + + with pytest.raises(DMSRuntimeException) as exc_info: + invoker.get("/error") + assert exc_info.value.status_code == 500 + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_connection_error(self, mock_get, invoker): + mock_get.side_effect = requests.exceptions.ConnectionError("refused") + + with pytest.raises(DMSConnectionError): + invoker.get("/unreachable") + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_get_timeout_error(self, mock_get, invoker): + mock_get.side_effect = requests.exceptions.Timeout("timed out") + + with pytest.raises(DMSConnectionError): + invoker.get("/slow") + + +# --------------------------------------------------------------- +# POST (form-encoded) +# --------------------------------------------------------------- + +class TestPostForm: + @patch("sap_cloud_sdk.dms._http.requests.post") + def test_post_form_basic(self, mock_post, invoker): + mock_resp = Mock() + mock_resp.status_code = 201 + mock_resp.content = b'{"succinctProperties": {}}' + mock_resp.json.return_value = {"succinctProperties": {}} + mock_post.return_value = mock_resp + + form = {"cmisaction": "createFolder", "objectId": "root-id"} + result = invoker.post_form("/browser/repo1/root", data=form) + + mock_post.assert_called_once_with( + "https://api.example.com/browser/repo1/root", + headers={"Authorization": "Bearer test-token-123"}, + data=form, + files=None, + timeout=(5, 15), + ) + assert result is mock_resp + + @patch("sap_cloud_sdk.dms._http.requests.post") + def test_post_form_no_content_type_header(self, mock_post, invoker): + """post_form must NOT set Content-Type — let requests handle it.""" + mock_resp = Mock() + mock_resp.status_code = 201 + mock_post.return_value = mock_resp + + invoker.post_form("/path", data={"key": "val"}) + + headers_sent = mock_post.call_args[1]["headers"] + assert "Content-Type" not in headers_sent + + @patch("sap_cloud_sdk.dms._http.requests.post") + def test_post_form_with_files(self, mock_post, invoker): + mock_resp = Mock() + mock_resp.status_code = 201 + mock_post.return_value = mock_resp + + files = {"media": ("test.pdf", b"content", "application/pdf")} + invoker.post_form("/path", data={"cmisaction": "createDocument"}, files=files) + + call_kwargs = mock_post.call_args[1] + assert call_kwargs["files"] == files + assert call_kwargs["data"] == {"cmisaction": "createDocument"} + + @patch("sap_cloud_sdk.dms._http.requests.post") + def test_post_form_with_tenant(self, mock_post, invoker, mock_auth): + mock_resp = Mock() + mock_resp.status_code = 201 + mock_post.return_value = mock_resp + + invoker.post_form("/path", data={"a": "b"}, tenant_subdomain="tenant-x") + + mock_auth.get_token.assert_called_with("tenant-x") + + @patch("sap_cloud_sdk.dms._http.requests.post") + def test_post_form_500_raises_runtime(self, mock_post, invoker): + mock_resp = Mock() + mock_resp.status_code = 500 + mock_resp.text = "Internal Server Error" + mock_post.return_value = mock_resp + + with pytest.raises(DMSRuntimeException) as exc_info: + invoker.post_form("/path", data={}) + assert exc_info.value.status_code == 500 + + @patch("sap_cloud_sdk.dms._http.requests.post") + def test_post_form_204_returns_response(self, mock_post, invoker): + mock_resp = Mock() + mock_resp.status_code = 204 + mock_resp.content = b"" + mock_post.return_value = mock_resp + + result = invoker.post_form("/path", data={}) + assert result is mock_resp + + +# --------------------------------------------------------------- +# Base URL stripping +# --------------------------------------------------------------- + +class TestBaseUrl: + def test_trailing_slash_stripped(self, mock_auth): + inv = HttpInvoker( + auth=mock_auth, + base_url="https://api.example.com/", + ) + assert inv._base_url == "https://api.example.com" + + +# --------------------------------------------------------------- +# get_stream +# --------------------------------------------------------------- + +class TestGetStream: + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_returns_raw_response(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.content = b"binary content" + mock_get.return_value = mock_resp + + result = invoker.get_stream("/browser/repo1/root", params={"objectId": "d1", "cmisselector": "content"}) + + assert result is mock_resp + mock_get.assert_called_once() + call_kwargs = mock_get.call_args + assert call_kwargs[1]["stream"] is True + assert call_kwargs[1]["params"] == {"objectId": "d1", "cmisselector": "content"} + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_raises_on_error(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 404 + mock_resp.text = "Not found" + mock_get.return_value = mock_resp + + with pytest.raises(DMSObjectNotFoundException) as exc_info: + invoker.get_stream("/browser/repo1/root", params={"objectId": "d1", "cmisselector": "content"}) + assert exc_info.value.status_code == 404 + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_uses_auth_headers(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.content = b"data" + mock_get.return_value = mock_resp + + invoker.get_stream("/path") + + headers = mock_get.call_args[1]["headers"] + assert "Authorization" in headers + assert headers["Authorization"] == "Bearer test-token-123" From 783ac64996096dd9832f4dbb184100808ad10b79 Mon Sep 17 00:00:00 2001 From: Karan Shukla Date: Tue, 31 Mar 2026 18:41:04 +0700 Subject: [PATCH 12/24] removed aces from create doc and folder and added the unit test for admin operation and then added then integration test for DMS --- src/sap_cloud_sdk/dms/_auth.py | 2 +- src/sap_cloud_sdk/dms/_http.py | 22 +- src/sap_cloud_sdk/dms/client.py | 26 +- src/sap_cloud_sdk/dms/config.py | 2 +- src/sap_cloud_sdk/dms/exceptions.py | 5 +- src/sap_cloud_sdk/dms/user-guide.md | 146 ++++- tests/dms/integration/conftest.py | 4 +- tests/dms/integration/dms.feature | 193 ++++++ tests/dms/integration/test_dms_bdd.py | 816 ++++++++++++++++++++++++++ tests/dms/unit/test_client_admin.py | 417 +++++++++++++ tests/dms/unit/test_client_cmis.py | 41 +- tests/dms/unit/test_http_invoker.py | 80 +++ 12 files changed, 1673 insertions(+), 81 deletions(-) create mode 100644 tests/dms/integration/dms.feature create mode 100644 tests/dms/integration/test_dms_bdd.py create mode 100644 tests/dms/unit/test_client_admin.py diff --git a/src/sap_cloud_sdk/dms/_auth.py b/src/sap_cloud_sdk/dms/_auth.py index 8e1f8c6..cfbc800 100644 --- a/src/sap_cloud_sdk/dms/_auth.py +++ b/src/sap_cloud_sdk/dms/_auth.py @@ -13,7 +13,7 @@ class _TokenResponse(TypedDict): access_token: str expires_in: int - +# TODO: limit number of access tokens in cache to 10 class _CachedToken: def __init__(self, token: str, expires_at: float) -> None: self.token = token diff --git a/src/sap_cloud_sdk/dms/_http.py b/src/sap_cloud_sdk/dms/_http.py index f8c8377..7e0da2b 100644 --- a/src/sap_cloud_sdk/dms/_http.py +++ b/src/sap_cloud_sdk/dms/_http.py @@ -6,6 +6,7 @@ from sap_cloud_sdk.dms._auth import Auth from sap_cloud_sdk.dms.exceptions import ( DMSError, + DMSConflictException, DMSConnectionError, DMSInvalidArgumentException, DMSObjectNotFoundException, @@ -214,24 +215,35 @@ def _handle(self, response: Response) -> Response: error_content = response.text logger.warning("Request failed with status %s", response.status_code) + # Try to extract the server's error message from the JSON body + try: + body = response.json() + server_message = body.get("message", "") if isinstance(body, dict) else "" + except Exception: + server_message = "" + match response.status_code: case 400: raise DMSInvalidArgumentException( - "Request contains invalid or disallowed parameters", 400, error_content + server_message or "Request contains invalid or disallowed parameters", 400, error_content ) case 401 | 403: raise DMSPermissionDeniedException( - "Access denied — invalid or expired token", response.status_code, error_content + server_message or "Access denied — invalid or expired token", response.status_code, error_content ) case 404: raise DMSObjectNotFoundException( - "The requested resource was not found", 404, error_content + server_message or "The requested resource was not found", 404, error_content + ) + case 409: + raise DMSConflictException( + server_message or "The request conflicts with the current state of the resource", 409, error_content ) case 500: raise DMSRuntimeException( - "The DMS service encountered an internal error", 500, error_content + server_message or "The DMS service encountered an internal error", 500, error_content ) case _: raise DMSError( - f"Unexpected response from DMS service : "+error_content, response.status_code, error_content + f"Unexpected response from DMS service: {error_content}", response.status_code, error_content ) \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/client.py b/src/sap_cloud_sdk/dms/client.py index 7afb15e..3f0b25a 100644 --- a/src/sap_cloud_sdk/dms/client.py +++ b/src/sap_cloud_sdk/dms/client.py @@ -376,8 +376,6 @@ def create_folder( folder_name: str, *, description: Optional[str] = None, - add_aces: Optional[List[Ace]] = None, - remove_aces: Optional[List[Ace]] = None, path: Optional[str] = None, tenant: Optional[str] = None, user_claim: Optional[UserClaim] = None, @@ -389,8 +387,6 @@ def create_folder( parent_folder_id: CMIS objectId of the parent folder. folder_name: Name for the new folder. description: Optional folder description. - add_aces: Optional ACE entries to grant on the new folder. - remove_aces: Optional ACE entries to revoke on the new folder. path: Optional directory path (appended to /browser/{repo_id}/root/). tenant: Optional subscriber subdomain. user_claim: Optional user identity claims forwarded to DMS. @@ -417,10 +413,6 @@ def create_folder( "_charset_": "UTF-8", } form_data.update(_build_properties(cmis_props)) - if add_aces: - form_data.update(_build_aces(add_aces, prefix="addACEPrincipal")) - if remove_aces: - form_data.update(_build_aces(remove_aces, prefix="removeACEPrincipal")) logger.info("Creating folder '%s' in repo '%s'", folder_name, repository_id) response = self._http.post_form( @@ -442,11 +434,9 @@ def create_document( parent_folder_id: str, document_name: str, file: BinaryIO, - mime_type: str, *, + mime_type: Optional[str] = None, description: Optional[str] = None, - add_aces: Optional[List[Ace]] = None, - remove_aces: Optional[List[Ace]] = None, path: Optional[str] = None, tenant: Optional[str] = None, user_claim: Optional[UserClaim] = None, @@ -458,10 +448,9 @@ def create_document( parent_folder_id: Parent folder CMIS objectId. document_name: File name for the document. file: Readable binary stream with the content. - mime_type: MIME type (e.g. ``application/pdf``). + mime_type: MIME type (e.g. ``application/pdf``). Defaults to + ``application/octet-stream`` when not provided. description: Optional document description. - add_aces: Optional ACE entries to grant on the new document. - remove_aces: Optional ACE entries to revoke on the new document. path: Optional directory path. tenant: Optional subscriber subdomain. user_claim: Optional user identity claims forwarded to DMS. @@ -488,16 +477,12 @@ def create_document( "_charset_": "UTF-8", } form_data.update(_build_properties(cmis_props)) - if add_aces: - form_data.update(_build_aces(add_aces, prefix="addACEPrincipal")) - if remove_aces: - form_data.update(_build_aces(remove_aces, prefix="removeACEPrincipal")) logger.info("Creating document '%s' in repo '%s'", document_name, repository_id) response = self._http.post_form( self._browser_url(repository_id, path), data=form_data, - files={"media": (document_name, file, mime_type)}, + files={"media": (document_name, file, mime_type or "application/octet-stream")}, tenant_subdomain=tenant, user_claim=user_claim, ) @@ -965,5 +950,4 @@ def get_children( tenant_subdomain=tenant, user_claim=user_claim, ) - return ChildrenPage.from_dict(response.json()) - logger.info("Config '%s' deleted successfully", config_id) \ No newline at end of file + return ChildrenPage.from_dict(response.json()) \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/config.py b/src/sap_cloud_sdk/dms/config.py index 9f740e5..fff159f 100644 --- a/src/sap_cloud_sdk/dms/config.py +++ b/src/sap_cloud_sdk/dms/config.py @@ -102,7 +102,7 @@ def to_credentials(self) -> DMSCredentials: def load_sdm_config_from_env_or_mount(instance: Optional[str] = None) -> DMSCredentials: - """Load Destination configuration from mount with env fallback and normalize. + """Load DMS configuration from mount with env fallback and normalize. Args: instance: Logical instance name; defaults to "default" if not provided. diff --git a/src/sap_cloud_sdk/dms/exceptions.py b/src/sap_cloud_sdk/dms/exceptions.py index d20d96e..7fe548e 100644 --- a/src/sap_cloud_sdk/dms/exceptions.py +++ b/src/sap_cloud_sdk/dms/exceptions.py @@ -25,4 +25,7 @@ class DMSConnectionError(DMSError): """A network or connection failure occurred.""" class DMSRuntimeException(DMSError): - """Unexpected server-side error.""" \ No newline at end of file + """Unexpected server-side error.""" + +class DMSConflictException(DMSError): + """The request conflicts with the current state of the resource.""" \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/user-guide.md b/src/sap_cloud_sdk/dms/user-guide.md index 055f3a6..163fcd1 100644 --- a/src/sap_cloud_sdk/dms/user-guide.md +++ b/src/sap_cloud_sdk/dms/user-guide.md @@ -1,6 +1,6 @@ -# Document Management Service draft user guide +# DMS (Document Management Service) User Guide -This module provides a Python client for the SAP Document Management Service (DMS). It covers both the **Admin API** (repository and configuration management) and the **CMIS Browser Binding API** (folders, documents, versioning, ACLs). +This module provides a Python SDK for interacting with the SAP Document Management Service (DMS). It supports repository management, document and folder operations via the CMIS Browser Binding protocol, versioning, and access control management. ## Installation @@ -23,7 +23,8 @@ from sap_cloud_sdk.dms.model import ( ) from sap_cloud_sdk.dms.exceptions import ( DMSError, DMSObjectNotFoundException, DMSPermissionDeniedException, - DMSInvalidArgumentException, DMSConnectionError, DMSRuntimeException, + DMSInvalidArgumentException, DMSConflictException, + DMSConnectionError, DMSRuntimeException, ) ``` @@ -205,21 +206,6 @@ folder = client.create_folder( print(f"Created folder: {folder.name} (objectId={folder.object_id})") ``` -### Create a Folder with ACLs - -```python -from sap_cloud_sdk.dms.model import Ace - -folder = client.create_folder( - repository_id="cmis-repo-id", - parent_folder_id="root-folder-object-id", - folder_name="Secured Folder", - add_aces=[ - Ace(principal_id="user@example.com", permissions=["cmis:read", "cmis:write"]), - ], -) -``` - --- ## CMIS API — Document Operations @@ -480,3 +466,127 @@ while True: ``` --- + +## Multi-Tenancy + +All operations support an optional `tenant` parameter for subscriber-scoped requests. The SDK resolves the token URL by replacing the provider's identity zone with the tenant subdomain: + +```python +# Provider context (default) +repos = client.get_all_repositories() + +# Subscriber context +repos = client.get_all_repositories(tenant="subscriber-subdomain") +``` + +--- + +## Error Handling + +The DMS module provides specific exceptions for different error scenarios: + +```python +from sap_cloud_sdk.dms.exceptions import ( + DMSError, + DMSObjectNotFoundException, + DMSPermissionDeniedException, + DMSInvalidArgumentException, + DMSConflictException, + DMSConnectionError, + DMSRuntimeException, +) + +try: + obj = client.get_object("repo-uuid", "missing-id") +except DMSObjectNotFoundException as e: + print(f"Not found ({e.status_code}): {e}") +except DMSConflictException as e: + print(f"Conflict ({e.status_code}): {e}") +except DMSPermissionDeniedException as e: + print(f"Access denied ({e.status_code}): {e}") +except DMSInvalidArgumentException as e: + print(f"Bad request ({e.status_code}): {e}") +except DMSConnectionError as e: + print(f"Network error: {e}") +except DMSRuntimeException as e: + print(f"Server error ({e.status_code}): {e}") +``` + +### Exception Hierarchy + +| Exception | HTTP Status | Description | +|---|---|---| +| `DMSError` | any | Base exception for all DMS errors | +| `DMSObjectNotFoundException` | 404 | Repository, document, or folder not found | +| `DMSPermissionDeniedException` | 401, 403 | Invalid or expired access token | +| `DMSInvalidArgumentException` | 400 | Invalid request payload or parameters | +| `DMSConflictException` | 409 | Resource state conflict (e.g. duplicate name in versioned repo) | +| `DMSConnectionError` | — | Network failure, timeout, or connection refused | +| `DMSRuntimeException` | 500 | Server-side internal error | + +All exceptions carry: +- `status_code`: HTTP status code (when applicable) +- `error_content`: Raw response body for debugging + +The SDK extracts the server's error message from JSON responses (the `"message"` field) when available, providing specific error descriptions rather than generic messages. + +--- + +## Models + +### Repository Models + +- **`InternalRepoRequest`**: Create a new repository — `displayName` (required), `description`, `isVersionEnabled`, `isVirusScanEnabled`, `repositoryCategory`, etc. +- **`UpdateRepoRequest`**: Update repository metadata — `description`, `isVirusScanEnabled`, `isThumbnailEnabled`, `isAIEnabled`, etc. +- **`Repository`**: Repository entity — `id`, `name`, `cmis_repository_id`, `repository_type`, `repository_category`, `repository_params`. Use `repo.get_param("paramName")` for dynamic parameter access. + +### Configuration Models + +- **`CreateConfigRequest`**: `config_name` (use `ConfigName` enum), `config_value` +- **`UpdateConfigRequest`**: `id`, `config_name`, `config_value` +- **`RepositoryConfig`**: `id`, `config_name`, `config_value`, timestamps +- **`ConfigName`** enum: `BLOCKED_FILE_EXTENSIONS`, `TEMPSPACE_MAX_CONTENT_SIZE`, `IS_CROSS_DOMAIN_MAPPING_ALLOWED` + +### CMIS Object Models + +- **`CmisObject`**: Base model — `object_id`, `name`, `base_type_id`, `object_type_id`, `created_by`, `creation_date`, `last_modified_by`, `last_modification_date`, `change_token`, `parent_ids`, `description`, `properties` (full raw dict) +- **`Folder`**: Extends `CmisObject` (no additional fields) +- **`Document`**: Extends `CmisObject` — content fields (`content_stream_length`, `content_stream_mime_type`, `content_stream_file_name`) and versioning fields (`version_label`, `version_series_id`, `is_latest_version`, `is_major_version`, `is_private_working_copy`, `checkin_comment`, `is_version_series_checked_out`, etc.) + +### ACL Models + +- **`Ace`**: Access control entry — `principal_id`, `permissions` (list of strings), `is_direct` +- **`Acl`**: Access control list — `aces` (list of `Ace`), `is_exact` + +### Other Models + +- **`ChildrenPage`**: Paginated result — `objects` (list), `has_more_items`, `num_items` +- **`UserClaim`**: User identity — `x_ecm_user_enc`, `x_ecm_add_principals` +- **`DMSCredentials`**: Service credentials — `instance_name`, `uri`, `client_id`, `client_secret`, `token_url`, `identityzone` + +--- + +## Configuration + +The DMS module automatically resolves credentials from the environment. + +### Cloud Mode + +Reads secrets from mounted files or environment variables: +- **Kubernetes-mounted secret** at `/etc/secrets/appfnd/sdm//` +- **Fallback** to environment variables with pattern `CLOUD_SDK_CFG_SDM__` + +The binding provides: +- `uri`: DMS API base URL +- `uaa`: JSON string with XSUAA credentials (`clientid`, `clientsecret`, `url`, `identityzone`) + +### Service Binding (UAA JSON) + +```json +{ + "clientid": "sb-xxx!bxxx|sdm-di-xxx!bxxx", + "clientsecret": "xxx", + "url": "https://subdomain.authentication.region.hana.ondemand.com", + "identityzone": "subdomain" +} +``` diff --git a/tests/dms/integration/conftest.py b/tests/dms/integration/conftest.py index d1f3135..076bc62 100644 --- a/tests/dms/integration/conftest.py +++ b/tests/dms/integration/conftest.py @@ -13,10 +13,10 @@ def dms_client(): try: # Secret resolver handles configuration automatically from /etc/secrets/appfnd or CLOUD_SDK_CFG - client = create_client() + client = create_client(instance="default") return client except Exception as e: - pytest.fail(f"Failed to create DMS client for cloud integration tests: {e}") # ty: ignore[invalid-argument-type] + pytest.skip(f"DMS integration tests require credentials: {e}") # ty: ignore[invalid-argument-type] diff --git a/tests/dms/integration/dms.feature b/tests/dms/integration/dms.feature new file mode 100644 index 0000000..8067903 --- /dev/null +++ b/tests/dms/integration/dms.feature @@ -0,0 +1,193 @@ +Feature: Document Management Service Integration + As a developer using the SDK + I want to manage repositories, folders, documents, and ACLs + So that I can store and organize documents in the DMS service + + Background: + Given the DMS service is available + And I have a valid DMS client + + # ==================== Repository Management ==================== + + Scenario: List all repositories + When I list all repositories + Then the repository list should be retrieved successfully + And the list should contain at least 1 repository + + Scenario: Get repository details + Given I select the first available repository + When I get repository details + Then the repository details should be retrieved successfully + And the repository should have a CMIS repository ID + And the repository should have a name + + # ==================== Configuration Management ==================== + + Scenario: Create and delete a configuration + Given I have a config named "tempspaceMaxContentSize" with value "1073741824" + When I create the configuration + Then the configuration creation should be successful + And the configuration should have the expected name and value + When I delete the created configuration + Then the configuration deletion should be successful + + Scenario: List all configurations + When I list all configurations + Then the configuration list should be retrieved successfully + + # ==================== Folder Operations ==================== + + Scenario: Create a folder + Given I select the first available repository + And I use the root folder as parent + When I create a folder named "sdk-integration-test-folder" + Then the folder creation should be successful + And the created folder should have the correct name + And I clean up the created folder + + Scenario: Create a folder with description + Given I select the first available repository + And I use the root folder as parent + When I create a folder named "sdk-test-described-folder" with description "Integration test folder" + Then the folder creation should be successful + And the created folder should have the correct name + And I clean up the created folder + + # ==================== Document Operations ==================== + + Scenario: Upload a document + Given I select the first available repository + And I use the root folder as parent + And I have document content "Hello from SAP Cloud SDK Python integration tests!" + When I upload a document named "sdk-integration-test.txt" with mime type "text/plain" + Then the document upload should be successful + And the uploaded document should have the correct name + And the document should have mime type "text/plain" + And I clean up the created document + + Scenario: Upload a document without explicit mime type + Given I select the first available repository + And I use the root folder as parent + And I have document content "Binary content simulation" + When I upload a document named "sdk-test-no-mime.bin" without specifying mime type + Then the document upload should be successful + And the document should have a mime type assigned by the server + And I clean up the created document + + # ==================== Read Operations ==================== + + Scenario: Get object details for a document + Given I select the first available repository + And I use the root folder as parent + And I have document content "Content for get-object test" + And I upload a document named "sdk-get-object-test.txt" with mime type "text/plain" + When I get the object by its ID + Then the object should be retrieved successfully + And the object should be a Document + And the object name should be "sdk-get-object-test.txt" + And I clean up the created document + + Scenario: Get object details for a folder + Given I select the first available repository + And I use the root folder as parent + And I create a folder named "sdk-get-folder-test" + When I get the folder object by its ID + Then the object should be retrieved successfully + And the object should be a Folder + And I clean up the created folder + + Scenario: Get object with ACL included + Given I select the first available repository + And I use the root folder as parent + And I have document content "ACL test content" + And I upload a document named "sdk-acl-object-test.txt" with mime type "text/plain" + When I get the object by its ID with ACL included + Then the object should be retrieved successfully + And I clean up the created document + + Scenario: Download document content + Given I select the first available repository + And I use the root folder as parent + And I have document content "Download me!" + And I upload a document named "sdk-download-test.txt" with mime type "text/plain" + When I download the document content + Then the download should be successful + And the downloaded content should match "Download me!" + And I clean up the created document + + Scenario: List children of a folder + Given I select the first available repository + And I use the root folder as parent + And I create a folder named "sdk-children-parent" + And I create a child document "sdk-child-doc.txt" in the folder + When I list children of the folder + Then the children list should be retrieved successfully + And the children should contain at least 1 item + And I clean up the children folder + + Scenario: List children with pagination + Given I select the first available repository + And I use the root folder as parent + When I list children of the root folder with max items 5 + Then the children list should be retrieved successfully + + # ==================== Update Operations ==================== + + Scenario: Update document properties + Given I select the first available repository + And I use the root folder as parent + And I have document content "Update properties test" + And I upload a document named "sdk-update-props-test.txt" with mime type "text/plain" + When I update the object name to "sdk-updated-name.txt" + Then the update should be successful + And the updated object name should be "sdk-updated-name.txt" + And I clean up the updated document + + # ==================== Versioning ==================== + + Scenario: Check out and cancel check out + Given I select a version-enabled repository + And I use the root folder as parent + And I have document content "Versioning test content" + And I upload a document named "sdk-versioning-test.txt" with mime type "text/plain" + When I check out the document + Then the check out should be successful + And the PWC should be a private working copy + When I cancel the check out + Then the cancel check out should be successful + And I clean up the created document + + Scenario: Check out and check in a new version + Given I select a version-enabled repository + And I use the root folder as parent + And I have document content "Version 1 content" + And I upload a document named "sdk-checkin-test.txt" with mime type "text/plain" + When I check out the document + Then the check out should be successful + When I check in with content "Version 2 content" and comment "Updated via integration test" + Then the check in should be successful + And the new version label should not be empty + And I clean up the created document + + # ==================== ACL Operations ==================== + + Scenario: Get ACL for an object + Given I select the first available repository + And I use the root folder as parent + And I have document content "ACL read test" + And I upload a document named "sdk-acl-read-test.txt" with mime type "text/plain" + When I get the ACL for the document + Then the ACL should be retrieved successfully + And I clean up the created document + + # ==================== Error Handling ==================== + + Scenario: Get non-existent object + Given I select the first available repository + When I attempt to get a non-existent object + Then the operation should fail with a not found error + + Scenario: Download non-existent document + Given I select the first available repository + When I attempt to download a non-existent document + Then the operation should fail with a not found error diff --git a/tests/dms/integration/test_dms_bdd.py b/tests/dms/integration/test_dms_bdd.py new file mode 100644 index 0000000..3aa3fb7 --- /dev/null +++ b/tests/dms/integration/test_dms_bdd.py @@ -0,0 +1,816 @@ +"""BDD step definitions for DMS integration tests.""" + +import io +import logging +import uuid +from typing import List, Optional, Union + +import pytest +from pytest_bdd import scenarios, given, when, then, parsers +from requests import Response + +from sap_cloud_sdk.dms.client import DMSClient +from sap_cloud_sdk.dms.model import ( + Ace, + Acl, + ChildrenPage, + CmisObject, + CreateConfigRequest, + Document, + Folder, + InternalRepoRequest, + Repository, + RepositoryConfig, + UpdateConfigRequest, +) +from sap_cloud_sdk.dms.exceptions import ( + DMSError, + DMSObjectNotFoundException, +) + +logger = logging.getLogger(__name__) + +# Load scenarios from feature file +scenarios("dms.feature") + + +# ==================== CONTEXT CLASS ==================== + + +class DMSTestContext: + """Context to store test state between BDD steps.""" + + def __init__(self): + self.repo: Optional[Repository] = None + self.repos: List[Repository] = [] + self.repo_id: str = "" + self.root_folder_id: str = "" + self.folder: Optional[Folder] = None + self.document: Optional[Document] = None + self.pwc: Optional[Document] = None + self.checked_in_doc: Optional[Document] = None + self.config: Optional[RepositoryConfig] = None + self.configs: List[RepositoryConfig] = [] + self.acl: Optional[Acl] = None + self.children_page: Optional[ChildrenPage] = None + self.content_response: Optional[Response] = None + self.retrieved_object: Optional[Union[Folder, Document, CmisObject]] = None + self.updated_object: Optional[Union[Folder, Document, CmisObject]] = None + self.content_bytes: Optional[bytes] = None + self.operation_success: bool = False + self.operation_error: Optional[Exception] = None + self.cleanup_configs: List[str] = [] # config IDs + self.cleanup_objects: List[tuple] = [] # (repo_id, object_id) + self.child_doc_id: Optional[str] = None + + +@pytest.fixture +def context(dms_client: DMSClient): + """Provide a fresh test context for each scenario.""" + ctx = DMSTestContext() + yield ctx + # Always clean up resources, even if the test fails + if ctx.content_response is not None: + ctx.content_response.close() + for config_id in ctx.cleanup_configs: + try: + dms_client.delete_config(config_id) + except Exception as e: + logger.warning("Cleanup failed for config %s: %s", config_id, e) + for repo_id, object_id in ctx.cleanup_objects: + try: + _delete_cmis_object(dms_client, repo_id, object_id) + except Exception as e: + logger.warning("Cleanup failed for object %s: %s", object_id, e) + + +# ==================== BACKGROUND STEPS ==================== + + +@given("the DMS service is available") +def dms_service_available(dms_client: DMSClient): + """Verify that the DMS client is available.""" + assert dms_client is not None + + +@given("I have a valid DMS client") +def have_valid_client(dms_client: DMSClient): + """Verify the DMS client is properly configured.""" + assert dms_client is not None + + +# ==================== REPOSITORY: GIVEN ==================== + + +@given("I select the first available repository") +def select_first_repo(context: DMSTestContext, dms_client: DMSClient): + """Select the first repository from the list.""" + repos = dms_client.get_all_repositories() + assert len(repos) > 0, "No repositories available for testing" + context.repo = repos[0] + # repo.id (UUID) is used in the CMIS browser URL; cmis_repository_id is the root folder objectId + context.repo_id = repos[0].id + + +@given("I select a version-enabled repository") +def select_version_repo(context: DMSTestContext, dms_client: DMSClient): + """Select a repository that has versioning enabled.""" + repos = dms_client.get_all_repositories() + version_repo = None + for r in repos: + if r.get_param("isVersionEnabled") in (True, "true", "True"): + version_repo = r + break + if version_repo is None: + pytest.skip("No version-enabled repository available") + context.repo = version_repo + context.repo_id = version_repo.id + + +@given("I use the root folder as parent") +def use_root_folder(context: DMSTestContext, dms_client: DMSClient): + """Get the root folder ID for the selected repository.""" + # The CMIS root folder objectId is the cmis_repository_id; repo.id (UUID) is used for the URL + context.root_folder_id = context.repo.cmis_repository_id + + +# ==================== CONFIG: GIVEN ==================== + + +@given(parsers.parse('I have a config named "{name}" with value "{value}"')) +def have_config(context: DMSTestContext, name: str, value: str): + """Prepare a configuration request.""" + context.config = None # Will be set after creation + context._config_request = CreateConfigRequest(config_name=name, config_value=value) + + +# ==================== DOCUMENT CONTENT: GIVEN ==================== + + +@given(parsers.parse('I have document content "{content}"')) +def have_document_content(context: DMSTestContext, content: str): + """Set up content bytes for document upload.""" + context.content_bytes = content.encode("utf-8") + + +# ==================== SETUP STEPS (Given with side effects) ==================== + + +@given(parsers.parse('I upload a document named "{name}" with mime type "{mime_type}"')) +def given_upload_document(context: DMSTestContext, dms_client: DMSClient, name: str, mime_type: str): + """Upload a document as a prerequisite step.""" + unique_name = f"{uuid.uuid4().hex[:8]}-{name}" + doc = dms_client.create_document( + context.repo_id, + context.root_folder_id, + unique_name, + io.BytesIO(context.content_bytes), + mime_type=mime_type, + ) + context.document = doc + context.cleanup_objects.append((context.repo_id, doc.object_id)) + + +@given(parsers.parse('I create a folder named "{name}"')) +def given_create_folder(context: DMSTestContext, dms_client: DMSClient, name: str): + """Create a folder as a prerequisite step.""" + unique_name = f"{uuid.uuid4().hex[:8]}-{name}" + folder = dms_client.create_folder( + context.repo_id, + context.root_folder_id, + unique_name, + ) + context.folder = folder + context.cleanup_objects.append((context.repo_id, folder.object_id)) + + +@given(parsers.parse('I create a child document "{name}" in the folder')) +def create_child_document(context: DMSTestContext, dms_client: DMSClient, name: str): + """Create a child document inside the previously created folder.""" + unique_name = f"{uuid.uuid4().hex[:8]}-{name}" + doc = dms_client.create_document( + context.repo_id, + context.folder.object_id, + unique_name, + io.BytesIO(b"child document content"), + mime_type="text/plain", + ) + context.child_doc_id = doc.object_id + # Child will be cleaned up when its parent folder is deleted + + +# ==================== REPOSITORY: WHEN ==================== + + +@when("I list all repositories") +def list_repos(context: DMSTestContext, dms_client: DMSClient): + """List all repositories.""" + try: + context.repos = dms_client.get_all_repositories() + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I get repository details") +def get_repo_details(context: DMSTestContext, dms_client: DMSClient): + """Get details of the selected repository.""" + try: + context.repo = dms_client.get_repository(context.repo.id) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== CONFIG: WHEN ==================== + + +@when("I create the configuration") +def create_config(context: DMSTestContext, dms_client: DMSClient): + """Create a repository configuration.""" + try: + context.config = dms_client.create_config(context._config_request) + context.cleanup_configs.append(context.config.id) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I delete the created configuration") +def delete_config(context: DMSTestContext, dms_client: DMSClient): + """Delete the previously created configuration.""" + context.operation_error = None + try: + dms_client.delete_config(context.config.id) + if context.config.id in context.cleanup_configs: + context.cleanup_configs.remove(context.config.id) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I list all configurations") +def list_configs(context: DMSTestContext, dms_client: DMSClient): + """List all configurations.""" + try: + context.configs = dms_client.get_configs() + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== FOLDER: WHEN ==================== + + +@when(parsers.parse('I create a folder named "{name}"')) +def when_create_folder(context: DMSTestContext, dms_client: DMSClient, name: str): + """Create a folder.""" + try: + unique_name = f"{uuid.uuid4().hex[:8]}-{name}" + folder = dms_client.create_folder( + context.repo_id, + context.root_folder_id, + unique_name, + ) + context.folder = folder + context.cleanup_objects.append((context.repo_id, folder.object_id)) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when(parsers.parse('I create a folder named "{name}" with description "{desc}"')) +def create_folder_with_desc(context: DMSTestContext, dms_client: DMSClient, name: str, desc: str): + """Create a folder with a description.""" + try: + unique_name = f"{uuid.uuid4().hex[:8]}-{name}" + folder = dms_client.create_folder( + context.repo_id, + context.root_folder_id, + unique_name, + description=desc, + ) + context.folder = folder + context.cleanup_objects.append((context.repo_id, folder.object_id)) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== DOCUMENT: WHEN ==================== + + +@when(parsers.parse('I upload a document named "{name}" with mime type "{mime_type}"')) +def upload_document(context: DMSTestContext, dms_client: DMSClient, name: str, mime_type: str): + """Upload a document with specified mime type.""" + try: + unique_name = f"{uuid.uuid4().hex[:8]}-{name}" + doc = dms_client.create_document( + context.repo_id, + context.root_folder_id, + unique_name, + io.BytesIO(context.content_bytes), + mime_type=mime_type, + ) + context.document = doc + context.cleanup_objects.append((context.repo_id, doc.object_id)) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when(parsers.parse('I upload a document named "{name}" without specifying mime type')) +def upload_document_no_mime(context: DMSTestContext, dms_client: DMSClient, name: str): + """Upload a document without explicit mime type.""" + try: + unique_name = f"{uuid.uuid4().hex[:8]}-{name}" + doc = dms_client.create_document( + context.repo_id, + context.root_folder_id, + unique_name, + io.BytesIO(context.content_bytes), + ) + context.document = doc + context.cleanup_objects.append((context.repo_id, doc.object_id)) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== READ: WHEN ==================== + + +@when("I get the object by its ID") +def get_object_by_id(context: DMSTestContext, dms_client: DMSClient): + """Get an object by its ID (document context).""" + try: + context.retrieved_object = dms_client.get_object( + context.repo_id, context.document.object_id + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I get the folder object by its ID") +def get_folder_by_id(context: DMSTestContext, dms_client: DMSClient): + """Get a folder by its ID.""" + try: + context.retrieved_object = dms_client.get_object( + context.repo_id, context.folder.object_id + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I get the object by its ID with ACL included") +def get_object_with_acl(context: DMSTestContext, dms_client: DMSClient): + """Get an object with ACL data included.""" + try: + context.retrieved_object = dms_client.get_object( + context.repo_id, context.document.object_id, include_acl=True + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I download the document content") +def download_content(context: DMSTestContext, dms_client: DMSClient): + """Download the content of a document.""" + try: + context.content_response = dms_client.get_content( + context.repo_id, context.document.object_id, download="attachment" + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I list children of the folder") +def list_children(context: DMSTestContext, dms_client: DMSClient): + """List children of the created folder.""" + try: + context.children_page = dms_client.get_children( + context.repo_id, context.folder.object_id + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when(parsers.parse("I list children of the root folder with max items {max_items:d}")) +def list_children_paginated(context: DMSTestContext, dms_client: DMSClient, max_items: int): + """List children with pagination.""" + try: + context.children_page = dms_client.get_children( + context.repo_id, context.root_folder_id, max_items=max_items + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== UPDATE: WHEN ==================== + + +@when(parsers.parse('I update the object name to "{new_name}"')) +def update_object_name(context: DMSTestContext, dms_client: DMSClient, new_name: str): + """Update the name of a document.""" + try: + unique_name = f"{uuid.uuid4().hex[:8]}-{new_name}" + context.updated_object = dms_client.update_properties( + context.repo_id, + context.document.object_id, + {"cmis:name": unique_name}, + ) + context._expected_updated_name = unique_name + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== VERSIONING: WHEN ==================== + + +@when("I check out the document") +def check_out_document(context: DMSTestContext, dms_client: DMSClient): + """Check out a document.""" + try: + context.pwc = dms_client.check_out( + context.repo_id, context.document.object_id + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I cancel the check out") +def cancel_check_out(context: DMSTestContext, dms_client: DMSClient): + """Cancel a check out.""" + try: + dms_client.cancel_check_out( + context.repo_id, context.pwc.object_id + ) + context.pwc = None + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when(parsers.parse('I check in with content "{content}" and comment "{comment}"')) +def check_in_document(context: DMSTestContext, dms_client: DMSClient, content: str, comment: str): + """Check in the PWC with new content.""" + try: + context.checked_in_doc = dms_client.check_in( + context.repo_id, + context.pwc.object_id, + major=True, + file=io.BytesIO(content.encode("utf-8")), + file_name=context.document.name, + mime_type="text/plain", + checkin_comment=comment, + ) + context.pwc = None + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== ACL: WHEN ==================== + + +@when("I get the ACL for the document") +def get_acl(context: DMSTestContext, dms_client: DMSClient): + """Get ACL for a document.""" + try: + context.acl = dms_client.apply_acl( + context.repo_id, context.document.object_id + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== ERROR: WHEN ==================== + + +@when("I attempt to get a non-existent object") +def get_nonexistent_object(context: DMSTestContext, dms_client: DMSClient): + """Try to get an object that does not exist.""" + try: + dms_client.get_object(context.repo_id, "nonexistent-object-id-12345") + context.operation_success = True + except DMSObjectNotFoundException as e: + context.operation_error = e + except DMSError as e: + context.operation_error = e + + +@when("I attempt to download a non-existent document") +def download_nonexistent(context: DMSTestContext, dms_client: DMSClient): + """Try to download content of a non-existent document.""" + try: + resp = dms_client.get_content(context.repo_id, "nonexistent-doc-id-12345") + resp.close() + context.operation_success = True + except DMSObjectNotFoundException as e: + context.operation_error = e + except DMSError as e: + context.operation_error = e + + +# ==================== REPOSITORY: THEN ==================== + + +@then("the repository list should be retrieved successfully") +def repo_list_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.operation_success is True + + +@then(parsers.parse("the list should contain at least {count:d} repository")) +def repo_list_count(context: DMSTestContext, count: int): + assert len(context.repos) >= count + + +@then("the repository details should be retrieved successfully") +def repo_details_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.repo is not None + + +@then("the repository should have a CMIS repository ID") +def repo_has_cmis_id(context: DMSTestContext): + assert context.repo.cmis_repository_id + + +@then("the repository should have a name") +def repo_has_name(context: DMSTestContext): + assert context.repo.name + + +# ==================== CONFIG: THEN ==================== + + +@then("the configuration creation should be successful") +def config_created(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.config is not None + + +@then("the configuration should have the expected name and value") +def config_values_match(context: DMSTestContext): + assert context.config.config_name == context._config_request.config_name + assert str(context.config.config_value) == str(context._config_request.config_value) + + +@then("the configuration deletion should be successful") +def config_deleted(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.operation_success is True + + +@then("the configuration list should be retrieved successfully") +def config_list_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert isinstance(context.configs, list) + + +# ==================== FOLDER: THEN ==================== + + +@then("the folder creation should be successful") +def folder_created(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.folder is not None + assert isinstance(context.folder, Folder) + + +@then("the created folder should have the correct name") +def folder_name_correct(context: DMSTestContext): + assert context.folder.name + # Name starts with UUID prefix, just verify it's set + assert len(context.folder.name) > 0 + + +# ==================== DOCUMENT: THEN ==================== + + +@then("the document upload should be successful") +def doc_uploaded(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.document is not None + assert isinstance(context.document, Document) + + +@then("the uploaded document should have the correct name") +def doc_name_correct(context: DMSTestContext): + assert context.document.name + assert len(context.document.name) > 0 + + +@then(parsers.parse('the document should have mime type "{expected_mime}"')) +def doc_mime_type(context: DMSTestContext, expected_mime: str): + assert context.document.content_stream_mime_type == expected_mime + + +@then("the document should have a mime type assigned by the server") +def doc_has_any_mime_type(context: DMSTestContext): + assert context.document.content_stream_mime_type is not None + + +# ==================== READ: THEN ==================== + + +@then("the object should be retrieved successfully") +def object_retrieved(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.retrieved_object is not None + + +@then("the object should be a Document") +def object_is_document(context: DMSTestContext): + assert isinstance(context.retrieved_object, Document) + + +@then("the object should be a Folder") +def object_is_folder(context: DMSTestContext): + assert isinstance(context.retrieved_object, Folder) + + +@then(parsers.parse('the object name should be "{expected_name}"')) +def object_name_matches(context: DMSTestContext, expected_name: str): + # Name has UUID prefix, so check suffix + assert context.retrieved_object.name.endswith(expected_name) + + +@then("the download should be successful") +def download_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.content_response is not None + + +@then(parsers.parse('the downloaded content should match "{expected}"')) +def download_content_match(context: DMSTestContext, expected: str): + actual = context.content_response.content.decode("utf-8") + assert actual == expected + + +@then("the children list should be retrieved successfully") +def children_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.children_page is not None + assert isinstance(context.children_page, ChildrenPage) + + +@then(parsers.parse("the children should contain at least {count:d} item")) +def children_count(context: DMSTestContext, count: int): + assert len(context.children_page.objects) >= count + + +# ==================== UPDATE: THEN ==================== + + +@then("the update should be successful") +def update_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.updated_object is not None + + +@then(parsers.parse('the updated object name should be "{expected_name}"')) +def updated_name_matches(context: DMSTestContext, expected_name: str): + # Actual name has UUID prefix + assert context.updated_object.name == context._expected_updated_name + + +# ==================== VERSIONING: THEN ==================== + + +@then("the check out should be successful") +def checkout_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.pwc is not None + + +@then("the PWC should be a private working copy") +def pwc_is_private(context: DMSTestContext): + assert context.pwc.is_private_working_copy is True + + +@then("the cancel check out should be successful") +def cancel_checkout_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.pwc is None + + +@then("the check in should be successful") +def checkin_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.checked_in_doc is not None + + +@then("the new version label should not be empty") +def version_label_set(context: DMSTestContext): + assert context.checked_in_doc.version_label + + +# ==================== ACL: THEN ==================== + + +@then("the ACL should be retrieved successfully") +def acl_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.acl is not None + assert isinstance(context.acl, Acl) + + +# ==================== ERROR: THEN ==================== + + +@then("the operation should fail with a not found error") +def not_found_error(context: DMSTestContext): + assert context.operation_error is not None, "Expected an error but none occurred" + assert isinstance(context.operation_error, (DMSObjectNotFoundException, DMSError)) + + +# ==================== CLEANUP STEPS ==================== + + +@then("I clean up the created folder") +def cleanup_folder(context: DMSTestContext, dms_client: DMSClient): + """Delete the folder created during the test.""" + if context.folder: + try: + _delete_cmis_object(dms_client, context.repo_id, context.folder.object_id) + _remove_from_cleanup(context, context.folder.object_id) + except Exception as e: + logger.warning("Cleanup failed for folder %s: %s", context.folder.object_id, e) + + +@then("I clean up the created document") +def cleanup_document(context: DMSTestContext, dms_client: DMSClient): + """Delete the document created during the test.""" + if context.document: + try: + _delete_cmis_object(dms_client, context.repo_id, context.document.object_id) + _remove_from_cleanup(context, context.document.object_id) + except Exception as e: + logger.warning("Cleanup failed for document %s: %s", context.document.object_id, e) + + +@then("I clean up the updated document") +def cleanup_updated_document(context: DMSTestContext, dms_client: DMSClient): + """Delete the updated document.""" + obj_id = context.updated_object.object_id if context.updated_object else ( + context.document.object_id if context.document else None + ) + if obj_id: + try: + _delete_cmis_object(dms_client, context.repo_id, obj_id) + _remove_from_cleanup(context, obj_id) + except Exception as e: + logger.warning("Cleanup failed for document %s: %s", obj_id, e) + + +@then("I clean up the children folder") +def cleanup_children_folder(context: DMSTestContext, dms_client: DMSClient): + """Delete the folder and its children.""" + # Delete child document first + if context.child_doc_id: + try: + _delete_cmis_object(dms_client, context.repo_id, context.child_doc_id) + except Exception as e: + logger.warning("Cleanup failed for child doc %s: %s", context.child_doc_id, e) + # Then delete the parent folder + if context.folder: + try: + _delete_cmis_object(dms_client, context.repo_id, context.folder.object_id) + _remove_from_cleanup(context, context.folder.object_id) + except Exception as e: + logger.warning("Cleanup failed for folder %s: %s", context.folder.object_id, e) + + +# ==================== HELPERS ==================== + + +def _delete_cmis_object(client: DMSClient, repo_id: str, object_id: str): + """Delete a CMIS object using the update properties endpoint (CMIS delete action).""" + # Use the HTTP invoker directly to perform a CMIS delete + form_data = { + "cmisaction": "delete", + "objectId": object_id, + "_charset_": "UTF-8", + } + client._http.post_form( + client._browser_url(repo_id), + data=form_data, + ) + + +def _remove_from_cleanup(context: DMSTestContext, object_id: str): + """Remove an object from the cleanup list after successful deletion.""" + context.cleanup_objects = [ + (r, o) for r, o in context.cleanup_objects if o != object_id + ] diff --git a/tests/dms/unit/test_client_admin.py b/tests/dms/unit/test_client_admin.py new file mode 100644 index 0000000..e02448e --- /dev/null +++ b/tests/dms/unit/test_client_admin.py @@ -0,0 +1,417 @@ +"""Unit tests for DMSClient admin operations (repositories & configs). + +Tests mock HttpInvoker to verify: +- Correct endpoint paths +- Correct payload construction +- Correct response parsing into typed models +- Tenant and user_claim forwarding +- Input validation +""" + +from unittest.mock import Mock, patch + +import pytest + +from sap_cloud_sdk.dms.client import DMSClient +from sap_cloud_sdk.dms.model import ( + CreateConfigRequest, + DMSCredentials, + InternalRepoRequest, + Repository, + RepositoryConfig, + UpdateConfigRequest, + UpdateRepoRequest, + UserClaim, +) + + +# --------------------------------------------------------------- +# Helper +# --------------------------------------------------------------- + +def _mock_response(data, status_code=200): + resp = Mock() + resp.json.return_value = data + resp.status_code = status_code + return resp + + +# --------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------- + +_REPO_RESPONSE = { + "cmisRepositoryId": "cmis-repo-1", + "createdTime": "2025-06-01T10:00:00Z", + "id": "repo-uuid-1", + "lastUpdatedTime": "2025-06-01T12:00:00Z", + "name": "TestRepo", + "repositoryCategory": "Collaboration", + "repositoryParams": [ + {"paramName": "isVersionEnabled", "paramValue": "true"}, + ], + "repositorySubType": "internal", + "repositoryType": "internal", +} + +_CONFIG_RESPONSE = { + "id": "cfg-uuid-1", + "configName": "blockedFileExtensions", + "configValue": "exe,bat", + "createdTime": "2025-06-01T10:00:00Z", + "lastUpdatedTime": "2025-06-01T12:00:00Z", + "serviceInstanceId": "svc-inst-1", +} + + +@pytest.fixture +def client(): + with patch("sap_cloud_sdk.dms.client.Auth"): + with patch("sap_cloud_sdk.dms.client.HttpInvoker") as MockHttp: + mock_http = Mock() + MockHttp.return_value = mock_http + creds = DMSCredentials( + instance_name="test-instance", + uri="https://api.example.com", + client_id="test-client", + client_secret="test-secret", + token_url="https://auth.example.com/oauth/token", + identityzone="test-zone", + ) + c = DMSClient(creds) + c._mock_http = mock_http + yield c + + +# --------------------------------------------------------------- +# onboard_repository +# --------------------------------------------------------------- + +class TestOnboardRepository: + def test_basic(self, client): + client._mock_http.post.return_value = _mock_response(_REPO_RESPONSE) + request = InternalRepoRequest(displayName="TestRepo") + + repo = client.onboard_repository(request) + + assert isinstance(repo, Repository) + assert repo.id == "repo-uuid-1" + assert repo.name == "TestRepo" + + call_args = client._mock_http.post.call_args + assert call_args[1]["path"] == "/rest/v2/repositories" + payload = call_args[1]["payload"] + assert "repository" in payload + assert payload["repository"]["displayName"] == "TestRepo" + + def test_with_tenant_and_user_claim(self, client): + client._mock_http.post.return_value = _mock_response(_REPO_RESPONSE) + request = InternalRepoRequest(displayName="TestRepo") + claim = UserClaim(x_ecm_user_enc="alice@sap.com") + + client.onboard_repository(request, tenant="t1", user_claim=claim) + + call_args = client._mock_http.post.call_args + assert call_args[1]["tenant_subdomain"] == "t1" + assert call_args[1]["user_claim"] is claim + + def test_with_versioning_enabled(self, client): + client._mock_http.post.return_value = _mock_response(_REPO_RESPONSE) + request = InternalRepoRequest(displayName="VersRepo", isVersionEnabled=True) + + repo = client.onboard_repository(request) + + payload = client._mock_http.post.call_args[1]["payload"] + assert payload["repository"]["isVersionEnabled"] is True + + +# --------------------------------------------------------------- +# get_all_repositories +# --------------------------------------------------------------- + +class TestGetAllRepositories: + def test_basic(self, client): + client._mock_http.get.return_value = _mock_response({ + "repoAndConnectionInfos": [ + {"repository": _REPO_RESPONSE}, + ] + }) + + repos = client.get_all_repositories() + + assert len(repos) == 1 + assert isinstance(repos[0], Repository) + assert repos[0].id == "repo-uuid-1" + + call_args = client._mock_http.get.call_args + assert call_args[1]["path"] == "/rest/v2/repositories" + assert call_args[1]["headers"]["Accept"] == "application/vnd.sap.sdm.repositories+json;version=3" + + def test_empty_list(self, client): + client._mock_http.get.return_value = _mock_response({ + "repoAndConnectionInfos": [] + }) + + repos = client.get_all_repositories() + + assert repos == [] + + def test_multiple_repos(self, client): + repo2 = {**_REPO_RESPONSE, "id": "repo-uuid-2", "name": "Repo2"} + client._mock_http.get.return_value = _mock_response({ + "repoAndConnectionInfos": [ + {"repository": _REPO_RESPONSE}, + {"repository": repo2}, + ] + }) + + repos = client.get_all_repositories() + + assert len(repos) == 2 + assert repos[1].id == "repo-uuid-2" + + def test_with_tenant(self, client): + client._mock_http.get.return_value = _mock_response({ + "repoAndConnectionInfos": [] + }) + + client.get_all_repositories(tenant="sub1") + + assert client._mock_http.get.call_args[1]["tenant_subdomain"] == "sub1" + + +# --------------------------------------------------------------- +# get_repository +# --------------------------------------------------------------- + +class TestGetRepository: + def test_basic(self, client): + client._mock_http.get.return_value = _mock_response({ + "repository": _REPO_RESPONSE + }) + + repo = client.get_repository("repo-uuid-1") + + assert isinstance(repo, Repository) + assert repo.name == "TestRepo" + assert client._mock_http.get.call_args[1]["path"] == "/rest/v2/repositories/repo-uuid-1" + + def test_with_tenant_and_user_claim(self, client): + client._mock_http.get.return_value = _mock_response({ + "repository": _REPO_RESPONSE + }) + claim = UserClaim(x_ecm_user_enc="bob@sap.com") + + client.get_repository("repo-uuid-1", tenant="t1", user_claim=claim) + + call_args = client._mock_http.get.call_args + assert call_args[1]["tenant_subdomain"] == "t1" + assert call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# update_repository +# --------------------------------------------------------------- + +class TestUpdateRepository: + def test_basic(self, client): + client._mock_http.put.return_value = _mock_response(_REPO_RESPONSE) + request = UpdateRepoRequest(description="Updated desc") + + repo = client.update_repository("repo-uuid-1", request) + + assert isinstance(repo, Repository) + call_args = client._mock_http.put.call_args + assert call_args[1]["path"] == "/rest/v2/repositories/repo-uuid-1" + payload = call_args[1]["payload"] + assert "repository" in payload + assert payload["repository"]["description"] == "Updated desc" + + def test_empty_repo_id_raises_value_error(self, client): + request = UpdateRepoRequest(description="x") + + with pytest.raises(ValueError, match="repo_id must not be empty"): + client.update_repository("", request) + + def test_whitespace_repo_id_raises_value_error(self, client): + request = UpdateRepoRequest(description="x") + + with pytest.raises(ValueError, match="repo_id must not be empty"): + client.update_repository(" ", request) + + def test_with_tenant(self, client): + client._mock_http.put.return_value = _mock_response(_REPO_RESPONSE) + request = UpdateRepoRequest(description="d") + + client.update_repository("repo-uuid-1", request, tenant="t1") + + assert client._mock_http.put.call_args[1]["tenant_subdomain"] == "t1" + + +# --------------------------------------------------------------- +# delete_repository +# --------------------------------------------------------------- + +class TestDeleteRepository: + def test_basic(self, client): + client._mock_http.delete.return_value = _mock_response(None, status_code=204) + + client.delete_repository("repo-uuid-1") + + call_args = client._mock_http.delete.call_args + assert call_args[1]["path"] == "/rest/v2/repositories/repo-uuid-1" + + def test_with_tenant_and_user_claim(self, client): + client._mock_http.delete.return_value = _mock_response(None, status_code=204) + claim = UserClaim(x_ecm_user_enc="admin@sap.com") + + client.delete_repository("repo-uuid-1", tenant="t1", user_claim=claim) + + call_args = client._mock_http.delete.call_args + assert call_args[1]["tenant_subdomain"] == "t1" + assert call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# create_config +# --------------------------------------------------------------- + +class TestCreateConfig: + def test_basic(self, client): + client._mock_http.post.return_value = _mock_response(_CONFIG_RESPONSE) + request = CreateConfigRequest( + config_name="blockedFileExtensions", + config_value="exe,bat", + ) + + config = client.create_config(request) + + assert isinstance(config, RepositoryConfig) + assert config.id == "cfg-uuid-1" + assert config.config_name == "blockedFileExtensions" + assert config.config_value == "exe,bat" + + call_args = client._mock_http.post.call_args + assert call_args[1]["path"] == "/rest/v2/configs" + payload = call_args[1]["payload"] + assert payload["configName"] == "blockedFileExtensions" + assert payload["configValue"] == "exe,bat" + + def test_with_tenant(self, client): + client._mock_http.post.return_value = _mock_response(_CONFIG_RESPONSE) + request = CreateConfigRequest( + config_name="blockedFileExtensions", config_value="exe", + ) + + client.create_config(request, tenant="sub1") + + assert client._mock_http.post.call_args[1]["tenant_subdomain"] == "sub1" + + +# --------------------------------------------------------------- +# get_configs +# --------------------------------------------------------------- + +class TestGetConfigs: + def test_basic(self, client): + client._mock_http.get.return_value = _mock_response([_CONFIG_RESPONSE]) + + configs = client.get_configs() + + assert len(configs) == 1 + assert isinstance(configs[0], RepositoryConfig) + assert configs[0].config_name == "blockedFileExtensions" + assert client._mock_http.get.call_args[1]["path"] == "/rest/v2/configs" + + def test_empty_list(self, client): + client._mock_http.get.return_value = _mock_response([]) + + configs = client.get_configs() + + assert configs == [] + + def test_multiple_configs(self, client): + cfg2 = {**_CONFIG_RESPONSE, "id": "cfg-uuid-2", "configName": "tempspaceMaxContentSize"} + client._mock_http.get.return_value = _mock_response([_CONFIG_RESPONSE, cfg2]) + + configs = client.get_configs() + + assert len(configs) == 2 + assert configs[1].config_name == "tempspaceMaxContentSize" + + def test_with_user_claim(self, client): + client._mock_http.get.return_value = _mock_response([]) + claim = UserClaim(x_ecm_user_enc="admin@sap.com") + + client.get_configs(user_claim=claim) + + assert client._mock_http.get.call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# update_config +# --------------------------------------------------------------- + +class TestUpdateConfig: + def test_basic(self, client): + updated = {**_CONFIG_RESPONSE, "configValue": "exe,bat,sh"} + client._mock_http.put.return_value = _mock_response(updated) + request = UpdateConfigRequest( + id="cfg-uuid-1", + config_name="blockedFileExtensions", + config_value="exe,bat,sh", + ) + + config = client.update_config("cfg-uuid-1", request) + + assert isinstance(config, RepositoryConfig) + assert config.config_value == "exe,bat,sh" + + call_args = client._mock_http.put.call_args + assert call_args[1]["path"] == "/rest/v2/configs/cfg-uuid-1" + + def test_empty_config_id_raises_value_error(self, client): + request = UpdateConfigRequest( + id="x", config_name="n", config_value="v", + ) + + with pytest.raises(ValueError, match="config_id must not be empty"): + client.update_config("", request) + + def test_with_tenant(self, client): + client._mock_http.put.return_value = _mock_response(_CONFIG_RESPONSE) + request = UpdateConfigRequest( + id="cfg-uuid-1", config_name="n", config_value="v", + ) + + client.update_config("cfg-uuid-1", request, tenant="t1") + + assert client._mock_http.put.call_args[1]["tenant_subdomain"] == "t1" + + +# --------------------------------------------------------------- +# delete_config +# --------------------------------------------------------------- + +class TestDeleteConfig: + def test_basic(self, client): + client._mock_http.delete.return_value = _mock_response(None, status_code=204) + + client.delete_config("cfg-uuid-1") + + call_args = client._mock_http.delete.call_args + assert call_args[1]["path"] == "/rest/v2/configs/cfg-uuid-1" + + def test_empty_config_id_raises_value_error(self, client): + with pytest.raises(ValueError, match="config_id must not be empty"): + client.delete_config("") + + def test_with_tenant_and_user_claim(self, client): + client._mock_http.delete.return_value = _mock_response(None, status_code=204) + claim = UserClaim(x_ecm_user_enc="admin@sap.com") + + client.delete_config("cfg-uuid-1", tenant="t1", user_claim=claim) + + call_args = client._mock_http.delete.call_args + assert call_args[1]["tenant_subdomain"] == "t1" + assert call_args[1]["user_claim"] is claim diff --git a/tests/dms/unit/test_client_cmis.py b/tests/dms/unit/test_client_cmis.py index e762e5e..52a1bef 100644 --- a/tests/dms/unit/test_client_cmis.py +++ b/tests/dms/unit/test_client_cmis.py @@ -238,27 +238,6 @@ def test_with_user_claim(self, client): assert client._mock_http.post_form.call_args[1]["user_claim"] is claim - def test_with_inline_aces(self, client): - client._mock_http.post_form.return_value = _mock_response(_FOLDER_RESPONSE) - add = [Ace(principal_id="u1", permissions=["cmis:read", "cmis:write"])] - remove = [Ace(principal_id="u2", permissions=["cmis:all"])] - - client.create_folder("repo1", "parent-id", "F", add_aces=add, remove_aces=remove) - - data = client._mock_http.post_form.call_args[1]["data"] - assert data["addACEPrincipal[0]"] == "u1" - assert data["addACEPermission[0][0]"] == "cmis:read" - assert data["addACEPermission[0][1]"] == "cmis:write" - assert data["removeACEPrincipal[0]"] == "u2" - assert data["removeACEPermission[0][0]"] == "cmis:all" - - def test_no_aces_by_default(self, client): - client._mock_http.post_form.return_value = _mock_response(_FOLDER_RESPONSE) - - client.create_folder("repo1", "parent-id", "F") - - data = client._mock_http.post_form.call_args[1]["data"] - assert not any(k.startswith("addACE") or k.startswith("removeACE") for k in data) # --------------------------------------------------------------- @@ -270,7 +249,7 @@ def test_basic(self, client): client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) stream = BytesIO(b"hello world") - doc = client.create_document("repo1", "folder-id", "report.pdf", stream, "application/pdf") + doc = client.create_document("repo1", "folder-id", "report.pdf", stream, mime_type="application/pdf") assert isinstance(doc, Document) assert doc.object_id == "doc-xyz" @@ -294,7 +273,7 @@ def test_basic(self, client): def test_with_description(self, client): client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) - client.create_document("repo1", "folder-id", "f.txt", BytesIO(b""), "text/plain", description="D") + client.create_document("repo1", "folder-id", "f.txt", BytesIO(b""), mime_type="text/plain", description="D") data = client._mock_http.post_form.call_args[1]["data"] assert data["propertyId[2]"] == "cmis:description" @@ -303,7 +282,7 @@ def test_with_description(self, client): def test_with_tenant(self, client): client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) - client.create_document("repo1", "fid", "f.txt", BytesIO(b""), "text/plain", tenant="sub1") + client.create_document("repo1", "fid", "f.txt", BytesIO(b""), mime_type="text/plain", tenant="sub1") assert client._mock_http.post_form.call_args[1]["tenant_subdomain"] == "sub1" @@ -311,20 +290,18 @@ def test_with_user_claim(self, client): client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) claim = UserClaim(x_ecm_user_enc="bob@sap.com") - client.create_document("repo1", "fid", "f.txt", BytesIO(b""), "text/plain", user_claim=claim) + client.create_document("repo1", "fid", "f.txt", BytesIO(b""), mime_type="text/plain", user_claim=claim) assert client._mock_http.post_form.call_args[1]["user_claim"] is claim - def test_with_inline_aces(self, client): + def test_without_mime_type_uses_default(self, client): client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) - add = [Ace(principal_id="reader@sap.com", permissions=["cmis:read"])] + stream = BytesIO(b"binary data") - client.create_document("repo1", "fid", "f.txt", BytesIO(b""), "text/plain", add_aces=add) + client.create_document("repo1", "fid", "data.bin", stream) - data = client._mock_http.post_form.call_args[1]["data"] - assert data["addACEPrincipal[0]"] == "reader@sap.com" - assert data["addACEPermission[0][0]"] == "cmis:read" - assert not any(k.startswith("removeACE") for k in data) + files_arg = client._mock_http.post_form.call_args[1]["files"] + assert files_arg["media"][2] == "application/octet-stream" # --------------------------------------------------------------- diff --git a/tests/dms/unit/test_http_invoker.py b/tests/dms/unit/test_http_invoker.py index 4e0b66f..5695e18 100644 --- a/tests/dms/unit/test_http_invoker.py +++ b/tests/dms/unit/test_http_invoker.py @@ -7,6 +7,7 @@ from sap_cloud_sdk.dms._http import HttpInvoker from sap_cloud_sdk.dms.exceptions import ( + DMSConflictException, DMSConnectionError, DMSInvalidArgumentException, DMSObjectNotFoundException, @@ -125,6 +126,7 @@ def test_get_404_raises_not_found(self, mock_get, invoker): mock_resp = Mock() mock_resp.status_code = 404 mock_resp.text = "Not Found" + mock_resp.json.side_effect = ValueError("No JSON") mock_get.return_value = mock_resp with pytest.raises(DMSObjectNotFoundException) as exc_info: @@ -136,6 +138,7 @@ def test_get_400_raises_invalid_argument(self, mock_get, invoker): mock_resp = Mock() mock_resp.status_code = 400 mock_resp.text = "Bad Request" + mock_resp.json.side_effect = ValueError("No JSON") mock_get.return_value = mock_resp with pytest.raises(DMSInvalidArgumentException) as exc_info: @@ -147,6 +150,7 @@ def test_get_401_raises_permission_denied(self, mock_get, invoker): mock_resp = Mock() mock_resp.status_code = 401 mock_resp.text = "Unauthorized" + mock_resp.json.side_effect = ValueError("No JSON") mock_get.return_value = mock_resp with pytest.raises(DMSPermissionDeniedException) as exc_info: @@ -158,6 +162,7 @@ def test_get_500_raises_runtime(self, mock_get, invoker): mock_resp = Mock() mock_resp.status_code = 500 mock_resp.text = "Internal Server Error" + mock_resp.json.side_effect = ValueError("No JSON") mock_get.return_value = mock_resp with pytest.raises(DMSRuntimeException) as exc_info: @@ -179,6 +184,79 @@ def test_get_timeout_error(self, mock_get, invoker): invoker.get("/slow") +# --------------------------------------------------------------- +# Error message extraction +# --------------------------------------------------------------- + +class TestErrorMessageExtraction: + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_400_extracts_json_message(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 400 + mock_resp.text = '{"exception": "versioning", "message": "The object is not the latest version"}' + mock_resp.json.return_value = { + "exception": "versioning", + "message": "The object is not the latest version", + } + mock_get.return_value = mock_resp + + with pytest.raises(DMSInvalidArgumentException) as exc_info: + invoker.get("/bad") + assert "The object is not the latest version" in str(exc_info.value) + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_400_fallback_when_no_json(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 400 + mock_resp.text = "Bad Request" + mock_resp.json.side_effect = ValueError("No JSON") + mock_get.return_value = mock_resp + + with pytest.raises(DMSInvalidArgumentException) as exc_info: + invoker.get("/bad") + assert "Request contains invalid or disallowed parameters" in str(exc_info.value) + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_404_extracts_json_message(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 404 + mock_resp.text = '{"message": "Document abc-123 not found"}' + mock_resp.json.return_value = {"message": "Document abc-123 not found"} + mock_get.return_value = mock_resp + + with pytest.raises(DMSObjectNotFoundException) as exc_info: + invoker.get("/missing") + assert "Document abc-123 not found" in str(exc_info.value) + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_409_raises_conflict(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 409 + mock_resp.text = '{"exception": "versioning", "message": "Object already exists with name test.txt"}' + mock_resp.json.return_value = { + "exception": "versioning", + "message": "Object already exists with name test.txt", + } + mock_get.return_value = mock_resp + + with pytest.raises(DMSConflictException) as exc_info: + invoker.get("/conflict") + assert exc_info.value.status_code == 409 + assert "Object already exists with name test.txt" in str(exc_info.value) + + @patch("sap_cloud_sdk.dms._http.requests.get") + def test_409_fallback_when_no_json(self, mock_get, invoker): + mock_resp = Mock() + mock_resp.status_code = 409 + mock_resp.text = "Conflict" + mock_resp.json.side_effect = ValueError("No JSON") + mock_get.return_value = mock_resp + + with pytest.raises(DMSConflictException) as exc_info: + invoker.get("/conflict") + assert "conflicts with the current state" in str(exc_info.value) + + # --------------------------------------------------------------- # POST (form-encoded) # --------------------------------------------------------------- @@ -244,6 +322,7 @@ def test_post_form_500_raises_runtime(self, mock_post, invoker): mock_resp = Mock() mock_resp.status_code = 500 mock_resp.text = "Internal Server Error" + mock_resp.json.side_effect = ValueError("No JSON") mock_post.return_value = mock_resp with pytest.raises(DMSRuntimeException) as exc_info: @@ -299,6 +378,7 @@ def test_raises_on_error(self, mock_get, invoker): mock_resp = Mock() mock_resp.status_code = 404 mock_resp.text = "Not found" + mock_resp.json.side_effect = ValueError("No JSON") mock_get.return_value = mock_resp with pytest.raises(DMSObjectNotFoundException) as exc_info: From d4b9ccf9925971657eeeb59b76eeeb0b02f37fd0 Mon Sep 17 00:00:00 2001 From: Karan Shukla Date: Tue, 31 Mar 2026 19:43:23 +0700 Subject: [PATCH 13/24] fix the issues with ruff and test imports --- src/sap_cloud_sdk/dms/__init__.py | 4 ++-- src/sap_cloud_sdk/dms/_auth.py | 2 +- src/sap_cloud_sdk/dms/_endpoints.py | 2 +- src/sap_cloud_sdk/dms/_http.py | 2 +- src/sap_cloud_sdk/dms/client.py | 10 +++++----- src/sap_cloud_sdk/dms/config.py | 18 +++++++++--------- src/sap_cloud_sdk/dms/exceptions.py | 2 +- src/sap_cloud_sdk/dms/model.py | 4 ++-- tests/dms/integration/conftest.py | 1 - 9 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/sap_cloud_sdk/dms/__init__.py b/src/sap_cloud_sdk/dms/__init__.py index c361345..8b0ab72 100644 --- a/src/sap_cloud_sdk/dms/__init__.py +++ b/src/sap_cloud_sdk/dms/__init__.py @@ -13,7 +13,7 @@ def create_client( return DMSClient(dms_cred) if instance is not None: return DMSClient(load_sdm_config_from_env_or_mount(instance)) - + raise ValueError("No configuration provided. Please provide either instance name, config, or dms_cred.") -__all__ = ["create_client"] \ No newline at end of file +__all__ = ["create_client"] diff --git a/src/sap_cloud_sdk/dms/_auth.py b/src/sap_cloud_sdk/dms/_auth.py index cfbc800..9261d56 100644 --- a/src/sap_cloud_sdk/dms/_auth.py +++ b/src/sap_cloud_sdk/dms/_auth.py @@ -90,4 +90,4 @@ def _fetch_token(self, token_url: str) -> _TokenResponse: raise DMSError("Token response missing access_token") logger.debug("Token fetched successfully") - return payload \ No newline at end of file + return payload diff --git a/src/sap_cloud_sdk/dms/_endpoints.py b/src/sap_cloud_sdk/dms/_endpoints.py index aef2cce..08407dc 100644 --- a/src/sap_cloud_sdk/dms/_endpoints.py +++ b/src/sap_cloud_sdk/dms/_endpoints.py @@ -1,2 +1,2 @@ REPOSITORIES = "/rest/v2/repositories" -CONFIGS = "/rest/v2/configs" \ No newline at end of file +CONFIGS = "/rest/v2/configs" diff --git a/src/sap_cloud_sdk/dms/_http.py b/src/sap_cloud_sdk/dms/_http.py index 7e0da2b..e114bdc 100644 --- a/src/sap_cloud_sdk/dms/_http.py +++ b/src/sap_cloud_sdk/dms/_http.py @@ -246,4 +246,4 @@ def _handle(self, response: Response) -> Response: case _: raise DMSError( f"Unexpected response from DMS service: {error_content}", response.status_code, error_content - ) \ No newline at end of file + ) diff --git a/src/sap_cloud_sdk/dms/client.py b/src/sap_cloud_sdk/dms/client.py index 3f0b25a..5731246 100644 --- a/src/sap_cloud_sdk/dms/client.py +++ b/src/sap_cloud_sdk/dms/client.py @@ -1,5 +1,5 @@ import logging -from typing import Any, BinaryIO, Dict, List, Optional, Union +from typing import BinaryIO, Dict, List, Optional, Union from requests import Response from sap_cloud_sdk.dms.model import ( DMSCredentials, InternalRepoRequest, Repository, UserClaim, @@ -126,7 +126,7 @@ def get_all_repositories( repos = [Repository.from_dict(item["repository"]) for item in infos] logger.info("Fetched %d repositories", len(repos)) return repos - + @record_metrics(Module.DMS, Operation.DMS_GET_REPOSITORY) def get_repository( @@ -196,7 +196,7 @@ def update_repository( repo = Repository.from_dict(response.json()) logger.info("Repository '%s' updated successfully", repo_id) return repo - + @record_metrics(Module.DMS, Operation.DMS_DELETE_REPOSITORY) def delete_repository( @@ -211,7 +211,7 @@ def delete_repository( repo_id: The repository UUID. tenant: Optional tenant subdomain. user_claim: Optional user identity claims. - + Raises: DMSObjectNotFoundException: If the repository does not exist. DMSInvalidArgumentException: If the request payload is invalid. @@ -950,4 +950,4 @@ def get_children( tenant_subdomain=tenant, user_claim=user_claim, ) - return ChildrenPage.from_dict(response.json()) \ No newline at end of file + return ChildrenPage.from_dict(response.json()) diff --git a/src/sap_cloud_sdk/dms/config.py b/src/sap_cloud_sdk/dms/config.py index fff159f..16809d4 100644 --- a/src/sap_cloud_sdk/dms/config.py +++ b/src/sap_cloud_sdk/dms/config.py @@ -10,22 +10,22 @@ @dataclass class BindingData: """Dataclass for DMS binding data with URI and UAA credentials. - + Attributes: uri: The URI endpoint for the DMS service uaa: JSON string containing XSUAA authentication credentials """ - instance_name: str + instance_name: str uri: str uaa: str def validate(self) -> None: """Validate the binding data. - + Validates that: - uri is a valid URI - uaa is valid JSON and contains required credential fields - + Raises: ValueError: If uri is not a valid URI json.JSONDecodeError: If uaa is not valid JSON @@ -36,7 +36,7 @@ def validate(self) -> None: def _validate_uri(self) -> None: """Validate that uri is a valid URI. - + Raises: ValueError: If uri is not a valid URI """ @@ -52,7 +52,7 @@ def _validate_uri(self) -> None: def _validate_uaa(self) -> None: """Validate that uaa is valid JSON with required credential fields. - + Raises: json.JSONDecodeError: If uaa is not valid JSON ValueError: If required fields are missing from UAA credentials @@ -78,13 +78,13 @@ def _validate_uaa(self) -> None: raise ValueError( f"UAA credentials missing required fields: {', '.join(sorted(missing_fields))}" ) - + def to_credentials(self) -> DMSCredentials: """Convert the binding data to DMSCredentials. - + Parses the UAA JSON and constructs a DMSCredentials object with the necessary information for authenticating and connecting to the DMS service. - + Returns: DMSCredentials: The credentials extracted from the binding data """ diff --git a/src/sap_cloud_sdk/dms/exceptions.py b/src/sap_cloud_sdk/dms/exceptions.py index 7fe548e..3fec225 100644 --- a/src/sap_cloud_sdk/dms/exceptions.py +++ b/src/sap_cloud_sdk/dms/exceptions.py @@ -28,4 +28,4 @@ class DMSRuntimeException(DMSError): """Unexpected server-side error.""" class DMSConflictException(DMSError): - """The request conflicts with the current state of the resource.""" \ No newline at end of file + """The request conflicts with the current state of the resource.""" diff --git a/src/sap_cloud_sdk/dms/model.py b/src/sap_cloud_sdk/dms/model.py index 18bb385..01387ee 100644 --- a/src/sap_cloud_sdk/dms/model.py +++ b/src/sap_cloud_sdk/dms/model.py @@ -11,7 +11,7 @@ def _serialize(v: Any) -> Any: if isinstance(v, Enum): return v.value if isinstance(v, dict): - d: dict[str, Any] = cast(dict[str, Any],v) + d: dict[str, Any] = cast(dict[str, Any],v) return {str(k): _serialize(val) for k, val in d.items()} if isinstance(v, list): lst: list[Any] = cast(list[Any],v) @@ -499,4 +499,4 @@ def from_dict(cls, data: Dict[str, Any]) -> "ChildrenPage": objects=parsed, has_more_items=data.get("hasMoreItems", False), num_items=data.get("numItems"), - ) \ No newline at end of file + ) diff --git a/tests/dms/integration/conftest.py b/tests/dms/integration/conftest.py index 076bc62..a543119 100644 --- a/tests/dms/integration/conftest.py +++ b/tests/dms/integration/conftest.py @@ -1,5 +1,4 @@ from sap_cloud_sdk.dms import create_client -from tests.destination.integration.conftest import _setup_cloud_mode import pytest from pathlib import Path from dotenv import load_dotenv From 63f9491cb3fc20448089cd37f6bc8b8ab90aa6f6 Mon Sep 17 00:00:00 2001 From: Karan Shukla Date: Tue, 31 Mar 2026 19:55:16 +0700 Subject: [PATCH 14/24] applied the fix ruff, import collision in tests --- src/sap_cloud_sdk/core/telemetry/operation.py | 1 - src/sap_cloud_sdk/dms/__init__.py | 9 +- src/sap_cloud_sdk/dms/_auth.py | 15 +- src/sap_cloud_sdk/dms/_endpoints.py | 4 +- src/sap_cloud_sdk/dms/_http.py | 128 +++++++++++------- src/sap_cloud_sdk/dms/client.py | 76 ++++++++--- src/sap_cloud_sdk/dms/config.py | 21 +-- src/sap_cloud_sdk/dms/exceptions.py | 13 +- src/sap_cloud_sdk/dms/model.py | 76 ++++++++--- tests/dms/__init__.py | 0 10 files changed, 231 insertions(+), 112 deletions(-) create mode 100644 tests/dms/__init__.py diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index 260abde..db6c781 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -52,7 +52,6 @@ class Operation(str, Enum): AICORE_SET_CONFIG = "set_aicore_config" AICORE_AUTO_INSTRUMENT = "auto_instrument" - # DMS Operations DMS_ONBOARD_REPOSITORY = "onboard_repository" DMS_GET_REPOSITORY = "get_repository" diff --git a/src/sap_cloud_sdk/dms/__init__.py b/src/sap_cloud_sdk/dms/__init__.py index 8b0ab72..51f22dd 100644 --- a/src/sap_cloud_sdk/dms/__init__.py +++ b/src/sap_cloud_sdk/dms/__init__.py @@ -5,15 +5,16 @@ def create_client( - *, - instance: Optional[str] = None, - dms_cred: Optional[DMSCredentials] = None + *, instance: Optional[str] = None, dms_cred: Optional[DMSCredentials] = None ): if dms_cred is not None: return DMSClient(dms_cred) if instance is not None: return DMSClient(load_sdm_config_from_env_or_mount(instance)) - raise ValueError("No configuration provided. Please provide either instance name, config, or dms_cred.") + raise ValueError( + "No configuration provided. Please provide either instance name, config, or dms_cred." + ) + __all__ = ["create_client"] diff --git a/src/sap_cloud_sdk/dms/_auth.py b/src/sap_cloud_sdk/dms/_auth.py index 9261d56..f6de34a 100644 --- a/src/sap_cloud_sdk/dms/_auth.py +++ b/src/sap_cloud_sdk/dms/_auth.py @@ -3,7 +3,11 @@ import requests from requests.exceptions import RequestException from typing import Optional, TypedDict -from sap_cloud_sdk.dms.exceptions import DMSError, DMSConnectionError, DMSPermissionDeniedException +from sap_cloud_sdk.dms.exceptions import ( + DMSError, + DMSConnectionError, + DMSPermissionDeniedException, +) from sap_cloud_sdk.dms.model import DMSCredentials logger = logging.getLogger(__name__) @@ -13,6 +17,7 @@ class _TokenResponse(TypedDict): access_token: str expires_in: int + # TODO: limit number of access tokens in cache to 10 class _CachedToken: def __init__(self, token: str, expires_at: float) -> None: @@ -74,12 +79,16 @@ def _fetch_token(self, token_url: str) -> _TokenResponse: response.raise_for_status() except requests.exceptions.ConnectionError as e: logger.error("Failed to connect to token endpoint") - raise DMSConnectionError("Failed to connect to the authentication server") from e + raise DMSConnectionError( + "Failed to connect to the authentication server" + ) from e except requests.exceptions.HTTPError as e: status = e.response.status_code if e.response is not None else None logger.error("Token request failed with status %s", status) if status in (401, 403): - raise DMSPermissionDeniedException("Authentication failed — invalid client credentials", status) from e + raise DMSPermissionDeniedException( + "Authentication failed — invalid client credentials", status + ) from e raise DMSError("Failed to obtain access token", status) from e except RequestException as e: logger.error("Unexpected error during token fetch") diff --git a/src/sap_cloud_sdk/dms/_endpoints.py b/src/sap_cloud_sdk/dms/_endpoints.py index 08407dc..8c1d91c 100644 --- a/src/sap_cloud_sdk/dms/_endpoints.py +++ b/src/sap_cloud_sdk/dms/_endpoints.py @@ -1,2 +1,2 @@ -REPOSITORIES = "/rest/v2/repositories" -CONFIGS = "/rest/v2/configs" +REPOSITORIES = "/rest/v2/repositories" +CONFIGS = "/rest/v2/configs" diff --git a/src/sap_cloud_sdk/dms/_http.py b/src/sap_cloud_sdk/dms/_http.py index e114bdc..9b4f877 100644 --- a/src/sap_cloud_sdk/dms/_http.py +++ b/src/sap_cloud_sdk/dms/_http.py @@ -42,14 +42,16 @@ def get( params: Optional[dict[str, str]] = None, ) -> Response: logger.debug("GET %s", path) - return self._handle(self._execute( - lambda: requests.get( - f"{self._base_url}{path}", - headers=self._merged_headers(tenant_subdomain, headers, user_claim), - params=params, - timeout=(self._connect_timeout, self._read_timeout), + return self._handle( + self._execute( + lambda: requests.get( + f"{self._base_url}{path}", + headers=self._merged_headers(tenant_subdomain, headers, user_claim), + params=params, + timeout=(self._connect_timeout, self._read_timeout), + ) ) - )) + ) def post( self, @@ -60,14 +62,16 @@ def post( user_claim: Optional[UserClaim] = None, ) -> Response: logger.debug("POST %s", path) - return self._handle(self._execute( - lambda: requests.post( - f"{self._base_url}{path}", - headers=self._merged_headers(tenant_subdomain, headers, user_claim), - json=payload, - timeout=(self._connect_timeout, self._read_timeout), + return self._handle( + self._execute( + lambda: requests.post( + f"{self._base_url}{path}", + headers=self._merged_headers(tenant_subdomain, headers, user_claim), + json=payload, + timeout=(self._connect_timeout, self._read_timeout), + ) ) - )) + ) def put( self, @@ -78,14 +82,16 @@ def put( user_claim: Optional[UserClaim] = None, ) -> Response: logger.debug("PUT %s", path) - return self._handle(self._execute( - lambda: requests.put( - f"{self._base_url}{path}", - headers=self._merged_headers(tenant_subdomain, headers, user_claim), - json=payload, - timeout=(self._connect_timeout, self._read_timeout), + return self._handle( + self._execute( + lambda: requests.put( + f"{self._base_url}{path}", + headers=self._merged_headers(tenant_subdomain, headers, user_claim), + json=payload, + timeout=(self._connect_timeout, self._read_timeout), + ) ) - )) + ) def delete( self, @@ -95,13 +101,15 @@ def delete( user_claim: Optional[UserClaim] = None, ) -> Response: logger.debug("DELETE %s", path) - return self._handle(self._execute( - lambda: requests.delete( - f"{self._base_url}{path}", - headers=self._merged_headers(tenant_subdomain, headers, user_claim), - timeout=(self._connect_timeout, self._read_timeout), + return self._handle( + self._execute( + lambda: requests.delete( + f"{self._base_url}{path}", + headers=self._merged_headers(tenant_subdomain, headers, user_claim), + timeout=(self._connect_timeout, self._read_timeout), + ) ) - )) + ) def post_form( self, @@ -118,15 +126,17 @@ def post_form( to ``application/x-www-form-urlencoded`` or ``multipart/form-data``. """ logger.debug("POST_FORM %s", path) - return self._handle(self._execute( - lambda: requests.post( - f"{self._base_url}{path}", - headers=self._auth_header(tenant_subdomain, user_claim), - data=data, - files=files, - timeout=(self._connect_timeout, self._read_timeout), + return self._handle( + self._execute( + lambda: requests.post( + f"{self._base_url}{path}", + headers=self._auth_header(tenant_subdomain, user_claim), + data=data, + files=files, + timeout=(self._connect_timeout, self._read_timeout), + ) ) - )) + ) def get_stream( self, @@ -142,15 +152,17 @@ def get_stream( On non-2xx status the usual typed exception is raised. """ logger.debug("GET_STREAM %s", path) - return self._handle(self._execute( - lambda: requests.get( - f"{self._base_url}{path}", - headers=self._merged_headers(tenant_subdomain, None, user_claim), - params=params, - stream=True, - timeout=(self._connect_timeout, self._read_timeout), + return self._handle( + self._execute( + lambda: requests.get( + f"{self._base_url}{path}", + headers=self._merged_headers(tenant_subdomain, None, user_claim), + params=params, + stream=True, + timeout=(self._connect_timeout, self._read_timeout), + ) ) - )) + ) def _execute(self, fn: Any) -> Response: """Execute an HTTP call, wrapping network errors into DMSConnectionError.""" @@ -177,7 +189,9 @@ def _auth_header( **self._user_claim_headers(user_claim), } - def _default_headers(self, tenant_subdomain: Optional[str] = None) -> dict[str, str]: + def _default_headers( + self, tenant_subdomain: Optional[str] = None + ) -> dict[str, str]: return { "Authorization": f"Bearer {self._auth.get_token(tenant_subdomain)}", "Content-Type": "application/json", @@ -225,25 +239,39 @@ def _handle(self, response: Response) -> Response: match response.status_code: case 400: raise DMSInvalidArgumentException( - server_message or "Request contains invalid or disallowed parameters", 400, error_content + server_message + or "Request contains invalid or disallowed parameters", + 400, + error_content, ) case 401 | 403: raise DMSPermissionDeniedException( - server_message or "Access denied — invalid or expired token", response.status_code, error_content + server_message or "Access denied — invalid or expired token", + response.status_code, + error_content, ) case 404: raise DMSObjectNotFoundException( - server_message or "The requested resource was not found", 404, error_content + server_message or "The requested resource was not found", + 404, + error_content, ) case 409: raise DMSConflictException( - server_message or "The request conflicts with the current state of the resource", 409, error_content + server_message + or "The request conflicts with the current state of the resource", + 409, + error_content, ) case 500: raise DMSRuntimeException( - server_message or "The DMS service encountered an internal error", 500, error_content + server_message or "The DMS service encountered an internal error", + 500, + error_content, ) case _: raise DMSError( - f"Unexpected response from DMS service: {error_content}", response.status_code, error_content + f"Unexpected response from DMS service: {error_content}", + response.status_code, + error_content, ) diff --git a/src/sap_cloud_sdk/dms/client.py b/src/sap_cloud_sdk/dms/client.py index 5731246..541205f 100644 --- a/src/sap_cloud_sdk/dms/client.py +++ b/src/sap_cloud_sdk/dms/client.py @@ -2,9 +2,21 @@ from typing import BinaryIO, Dict, List, Optional, Union from requests import Response from sap_cloud_sdk.dms.model import ( - DMSCredentials, InternalRepoRequest, Repository, UserClaim, - UpdateRepoRequest, CreateConfigRequest, RepositoryConfig, UpdateConfigRequest, - Ace, Acl, ChildrenPage, CmisObject, Document, Folder, _prop_val, + DMSCredentials, + InternalRepoRequest, + Repository, + UserClaim, + UpdateRepoRequest, + CreateConfigRequest, + RepositoryConfig, + UpdateConfigRequest, + Ace, + Acl, + ChildrenPage, + CmisObject, + Document, + Folder, + _prop_val, ) from sap_cloud_sdk.dms._auth import Auth from sap_cloud_sdk.dms._http import HttpInvoker @@ -18,6 +30,7 @@ # CMIS property helpers # --------------------------------------------------------------------------- + def _build_properties(props: Dict[str, str]) -> Dict[str, str]: """Encode CMIS properties into indexed form fields. @@ -60,7 +73,9 @@ def __init__( connect_timeout=connect_timeout, read_timeout=read_timeout, ) - logger.debug("DMSClient initialized for instance '%s'", credentials.instance_name) + logger.debug( + "DMSClient initialized for instance '%s'", credentials.instance_name + ) @record_metrics(Module.DMS, Operation.DMS_ONBOARD_REPOSITORY) def onboard_repository( @@ -127,7 +142,6 @@ def get_all_repositories( logger.info("Fetched %d repositories", len(repos)) return repos - @record_metrics(Module.DMS, Operation.DMS_GET_REPOSITORY) def get_repository( self, @@ -158,7 +172,6 @@ def get_repository( ) return Repository.from_dict(response.json()["repository"]) - @record_metrics(Module.DMS, Operation.DMS_UPDATE_REPOSITORY) def update_repository( self, @@ -197,7 +210,6 @@ def update_repository( logger.info("Repository '%s' updated successfully", repo_id) return repo - @record_metrics(Module.DMS, Operation.DMS_DELETE_REPOSITORY) def delete_repository( self, @@ -224,7 +236,6 @@ def delete_repository( user_claim=user_claim, ) - @record_metrics(Module.DMS, Operation.DMS_CREATE_CONFIG) def create_config( self, @@ -258,7 +269,6 @@ def create_config( logger.info("Config created successfully with id '%s'", config.id) return config - @record_metrics(Module.DMS, Operation.DMS_GET_CONFIGS) def get_configs( self, @@ -482,7 +492,9 @@ def create_document( response = self._http.post_form( self._browser_url(repository_id, path), data=form_data, - files={"media": (document_name, file, mime_type or "application/octet-stream")}, + files={ + "media": (document_name, file, mime_type or "application/octet-stream") + }, tenant_subdomain=tenant, user_claim=user_claim, ) @@ -522,7 +534,9 @@ def check_out( "objectId": document_id, "_charset_": "UTF-8", } - logger.info("Checking out document '%s' in repo '%s'", document_id, repository_id) + logger.info( + "Checking out document '%s' in repo '%s'", document_id, repository_id + ) response = self._http.post_form( self._browser_url(repository_id), data=form_data, @@ -577,9 +591,17 @@ def check_in( files = None if file is not None: - files = {"content": (file_name or "content", file, mime_type or "application/octet-stream")} - - logger.info("Checking in document '%s' in repo '%s'", document_id, repository_id) + files = { + "content": ( + file_name or "content", + file, + mime_type or "application/octet-stream", + ) + } + + logger.info( + "Checking in document '%s' in repo '%s'", document_id, repository_id + ) response = self._http.post_form( self._browser_url(repository_id), data=form_data, @@ -616,7 +638,9 @@ def cancel_check_out( "objectId": document_id, "_charset_": "UTF-8", } - logger.info("Cancelling check-out for '%s' in repo '%s'", document_id, repository_id) + logger.info( + "Cancelling check-out for '%s' in repo '%s'", document_id, repository_id + ) self._http.post_form( self._browser_url(repository_id), data=form_data, @@ -666,7 +690,9 @@ def apply_acl( """ if not add_aces and not remove_aces: # Read-only: fetch current ACL - logger.info("Fetching ACL for object '%s' in repo '%s'", object_id, repository_id) + logger.info( + "Fetching ACL for object '%s' in repo '%s'", object_id, repository_id + ) response = self._http.get( self._browser_url(repository_id), params={"objectId": object_id, "cmisselector": "acl"}, @@ -686,7 +712,9 @@ def apply_acl( if remove_aces: form_data.update(_build_aces(remove_aces, prefix="removeACEPrincipal")) - logger.info("Applying ACL to object '%s' in repo '%s'", object_id, repository_id) + logger.info( + "Applying ACL to object '%s' in repo '%s'", object_id, repository_id + ) response = self._http.post_form( self._browser_url(repository_id), data=form_data, @@ -821,7 +849,11 @@ def get_content( if filename: params["filename"] = filename - logger.info("Getting content for document '%s' from repo '%s'", document_id, repository_id) + logger.info( + "Getting content for document '%s' from repo '%s'", + document_id, + repository_id, + ) return self._http.get_stream( self._browser_url(repository_id), params=params, @@ -868,7 +900,9 @@ def update_properties( form_data["changeToken"] = change_token form_data.update(_build_properties(properties)) - logger.info("Updating properties for object '%s' in repo '%s'", object_id, repository_id) + logger.info( + "Updating properties for object '%s' in repo '%s'", object_id, repository_id + ) response = self._http.post_form( self._browser_url(repository_id), data=form_data, @@ -943,7 +977,9 @@ def get_children( if succinct: params["succinct"] = "true" - logger.info("Getting children of folder '%s' in repo '%s'", folder_id, repository_id) + logger.info( + "Getting children of folder '%s' in repo '%s'", folder_id, repository_id + ) response = self._http.get( self._browser_url(repository_id), params=params, diff --git a/src/sap_cloud_sdk/dms/config.py b/src/sap_cloud_sdk/dms/config.py index 16809d4..a971fac 100644 --- a/src/sap_cloud_sdk/dms/config.py +++ b/src/sap_cloud_sdk/dms/config.py @@ -3,10 +3,13 @@ from typing import Any, Dict, Optional from urllib.parse import urlparse -from sap_cloud_sdk.core.secret_resolver.resolver import read_from_mount_and_fallback_to_env_var +from sap_cloud_sdk.core.secret_resolver.resolver import ( + read_from_mount_and_fallback_to_env_var, +) from sap_cloud_sdk.destination.exceptions import ConfigError from sap_cloud_sdk.dms.model import DMSCredentials + @dataclass class BindingData: """Dataclass for DMS binding data with URI and UAA credentials. @@ -15,6 +18,7 @@ class BindingData: uri: The URI endpoint for the DMS service uaa: JSON string containing XSUAA authentication credentials """ + instance_name: str uri: str uaa: str @@ -57,12 +61,7 @@ def _validate_uaa(self) -> None: json.JSONDecodeError: If uaa is not valid JSON ValueError: If required fields are missing from UAA credentials """ - required_fields = { - "clientid", - "clientsecret", - "url", - "identityzone" - } + required_fields = {"clientid", "clientsecret", "url", "identityzone"} try: uaa_data: Dict[str, Any] = json.loads(self.uaa) @@ -97,7 +96,7 @@ def to_credentials(self) -> DMSCredentials: client_id=uaa_data["clientid"], client_secret=uaa_data["clientsecret"], token_url=token_url, - identityzone=uaa_data["identityzone"] + identityzone=uaa_data["identityzone"], ) @@ -114,7 +113,9 @@ def load_sdm_config_from_env_or_mount(instance: Optional[str] = None) -> DMSCred ConfigError: If loading or validation fails. """ inst = instance or "default" - binding = BindingData(uri="", uaa="", instance_name="") # Initialize with empty values; will be populated by resolver + binding = BindingData( + uri="", uaa="", instance_name="" + ) # Initialize with empty values; will be populated by resolver try: # 1) Try mount at /etc/secrets/appfnd/destination/{instance}/... @@ -122,7 +123,7 @@ def load_sdm_config_from_env_or_mount(instance: Optional[str] = None) -> DMSCred read_from_mount_and_fallback_to_env_var( base_volume_mount="/etc/secrets/appfnd", base_var_name="CLOUD_SDK_CFG", - module="sdm", #TODO check if this should be "dms" or "sdm" + module="sdm", # TODO check if this should be "dms" or "sdm" instance=inst, target=binding, ) diff --git a/src/sap_cloud_sdk/dms/exceptions.py b/src/sap_cloud_sdk/dms/exceptions.py index 3fec225..e524bdd 100644 --- a/src/sap_cloud_sdk/dms/exceptions.py +++ b/src/sap_cloud_sdk/dms/exceptions.py @@ -6,7 +6,13 @@ class DMSError(Exception): """Base exception for all DMS errors.""" - def __init__(self, message: Optional[str] = None, status_code: Optional[int] = None, error_content: str = "") -> None: + + def __init__( + self, + message: Optional[str] = None, + status_code: Optional[int] = None, + error_content: str = "", + ) -> None: self.status_code = status_code self.error_content = error_content super().__init__(message) @@ -15,17 +21,22 @@ def __init__(self, message: Optional[str] = None, status_code: Optional[int] = N class DMSObjectNotFoundException(DMSError): """The specified repository or resource does not exist.""" + class DMSPermissionDeniedException(DMSError): """Access token is invalid, expired, or lacks required permissions.""" + class DMSInvalidArgumentException(DMSError): """The request payload contains invalid or disallowed parameters.""" + class DMSConnectionError(DMSError): """A network or connection failure occurred.""" + class DMSRuntimeException(DMSError): """Unexpected server-side error.""" + class DMSConflictException(DMSError): """The request conflicts with the current state of the resource.""" diff --git a/src/sap_cloud_sdk/dms/model.py b/src/sap_cloud_sdk/dms/model.py index 01387ee..da763e6 100644 --- a/src/sap_cloud_sdk/dms/model.py +++ b/src/sap_cloud_sdk/dms/model.py @@ -6,15 +6,16 @@ from typing import Any, Dict, List, Optional, TypedDict, cast from urllib.parse import urlparse + def _serialize(v: Any) -> Any: """Recursively serialize values — converts Enums to their values, handles nested dicts/lists.""" if isinstance(v, Enum): return v.value if isinstance(v, dict): - d: dict[str, Any] = cast(dict[str, Any],v) + d: dict[str, Any] = cast(dict[str, Any], v) return {str(k): _serialize(val) for k, val in d.items()} if isinstance(v, list): - lst: list[Any] = cast(list[Any],v) + lst: list[Any] = cast(list[Any], v) return [_serialize(i) for i in lst] return v @@ -25,10 +26,10 @@ def _to_dict_drop_none(obj: Any) -> dict[str, Any]: return {k: _serialize(v) for k, v in raw.items() if v is not None} - @dataclass class DMSCredentials: """Credentials for authenticating with the DMS service.""" + instance_name: str uri: str client_id: str @@ -41,7 +42,8 @@ def __post_init__(self) -> None: def _validate(self) -> None: placeholders = { - k: v for k, v in { + k: v + for k, v in { "uri": self.uri, "token_url": self.token_url, "client_id": self.client_id, @@ -58,7 +60,9 @@ def _validate(self) -> None: for fname, value in {"uri": self.uri, "token_url": self.token_url}.items(): parsed = urlparse(value) if not parsed.scheme or not parsed.netloc: - raise ValueError(f"DMSCredentials.{fname} is not a valid URL: '{value}'") + raise ValueError( + f"DMSCredentials.{fname} is not a valid URL: '{value}'" + ) class RepositoryType(str, Enum): @@ -88,8 +92,9 @@ class UserClaim: - Groups: prefix with ``~`` (e.g. ``~group1``) - Extra users: plain username or email """ + x_ecm_user_enc: Optional[str] = None - x_ecm_add_principals: Optional[List[str]] = field(default_factory=lambda:[]) + x_ecm_add_principals: Optional[List[str]] = field(default_factory=lambda: []) @dataclass @@ -112,13 +117,13 @@ class InternalRepoRequest: isVersionEnabled: Optional[bool] = None isVirusScanEnabled: Optional[bool] = None skipVirusScanForLargeFile: Optional[bool] = None - hashAlgorithms: Optional[str] = None # TODO provide enum + hashAlgorithms: Optional[str] = None # TODO provide enum isThumbnailEnabled: Optional[bool] = None isEncryptionEnabled: Optional[bool] = None externalId: Optional[str] = None isContentBridgeEnabled: Optional[bool] = None isAIEnabled: Optional[bool] = None - repositoryParams: List[RepositoryParam] = field(default_factory=lambda:[]) + repositoryParams: List[RepositoryParam] = field(default_factory=lambda: []) def to_dict(self) -> dict[str, Any]: return _to_dict_drop_none(self) @@ -134,7 +139,7 @@ class UpdateRepoRequest: isThumbnailEnabled: Optional[bool] = None isClientCacheEnabled: Optional[bool] = None isAIEnabled: Optional[bool] = None - repositoryParams: List[RepositoryParam] = field(default_factory=lambda:[]) + repositoryParams: List[RepositoryParam] = field(default_factory=lambda: []) def to_dict(self) -> dict[str, Any]: return {"repository": _to_dict_drop_none(self)} @@ -146,6 +151,7 @@ class RepositoryParams(TypedDict, total=False): All keys are optional since the API may not always return every param. Unknown params can still be accessed via get_param() on the Repository object. """ + changeLogDuration: int isVersionEnabled: bool isThumbnailEnabled: bool @@ -173,6 +179,7 @@ class Repository: repository_sub_type: Repository sub-type (e.g. "SAP Document Management Service"). repository_type: Repository type (e.g. "internal"). """ + cmis_repository_id: str created_time: datetime id: str @@ -213,9 +220,13 @@ def from_dict(cls, data: dict[str, Any]) -> "Repository": """ return cls( cmis_repository_id=data["cmisRepositoryId"], - created_time=datetime.fromisoformat(data["createdTime"].replace("Z", "+00:00")), + created_time=datetime.fromisoformat( + data["createdTime"].replace("Z", "+00:00") + ), id=data["id"], - last_updated_time=datetime.fromisoformat(data["lastUpdatedTime"].replace("Z", "+00:00")), + last_updated_time=datetime.fromisoformat( + data["lastUpdatedTime"].replace("Z", "+00:00") + ), name=data["name"], repository_category=data["repositoryCategory"], repository_params=cls._parse_repo_params(data.get("repositoryParams")), @@ -233,7 +244,9 @@ def to_dict(self) -> dict[str, Any]: "cmisRepositoryId": self.cmis_repository_id, "createdTime": self.created_time.isoformat().replace("+00:00", "Z"), "id": self.id, - "lastUpdatedTime": self.last_updated_time.isoformat().replace("+00:00", "Z"), + "lastUpdatedTime": self.last_updated_time.isoformat().replace( + "+00:00", "Z" + ), "name": self.name, "repositoryCategory": self.repository_category, "repositoryParams": [ @@ -271,6 +284,7 @@ class CreateConfigRequest: CreateConfigRequest(ConfigName.BLOCKED_FILE_EXTENSIONS, "bat,dmg,txt") CreateConfigRequest("someCustomConfig", "value") """ + config_name: ConfigName | str config_value: str @@ -291,6 +305,7 @@ class UpdateConfigRequest: config_value: Value for the given config name. service_instance_id: Optional service instance id. """ + id: str config_name: ConfigName | str config_value: str @@ -310,6 +325,7 @@ def to_dict(self) -> dict[str, Any]: @dataclass class RepositoryConfig: """Represents a repository configuration entry.""" + id: str config_name: str config_value: str @@ -323,8 +339,12 @@ def from_dict(cls, data: dict[str, Any]) -> "RepositoryConfig": id=data["id"], config_name=data["configName"], config_value=data["configValue"], - created_time=datetime.fromisoformat(data["createdTime"].replace("Z", "+00:00")), - last_updated_time=datetime.fromisoformat(data["lastUpdatedTime"].replace("Z", "+00:00")), + created_time=datetime.fromisoformat( + data["createdTime"].replace("Z", "+00:00") + ), + last_updated_time=datetime.fromisoformat( + data["lastUpdatedTime"].replace("Z", "+00:00") + ), service_instance_id=data["serviceInstanceId"], ) @@ -333,6 +353,7 @@ def from_dict(cls, data: dict[str, Any]) -> "RepositoryConfig": # CMIS browser-binding response models # --------------------------------------------------------------------------- + def _parse_datetime(val: Any) -> Optional[datetime]: """Parse a CMIS timestamp (epoch millis or ISO string) into a datetime.""" if val is None: @@ -382,7 +403,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "CmisObject": created_by=_prop_val(props, "cmis:createdBy"), creation_date=_parse_datetime(_prop_val(props, "cmis:creationDate")), last_modified_by=_prop_val(props, "cmis:lastModifiedBy"), - last_modification_date=_parse_datetime(_prop_val(props, "cmis:lastModificationDate")), + last_modification_date=_parse_datetime( + _prop_val(props, "cmis:lastModificationDate") + ), change_token=_prop_val(props, "cmis:changeToken"), parent_ids=_prop_val(props, "sap:parentIds"), description=_prop_val(props, "cmis:description"), @@ -397,7 +420,9 @@ class Folder(CmisObject): @classmethod def from_dict(cls, data: Dict[str, Any]) -> "Folder": base = CmisObject.from_dict(data) - return cls(**{k: v for k, v in base.__dict__.items() if k in cls.__dataclass_fields__}) + return cls( + **{k: v for k, v in base.__dict__.items() if k in cls.__dataclass_fields__} + ) @dataclass @@ -422,7 +447,11 @@ def from_dict(cls, data: Dict[str, Any]) -> "Document": base = CmisObject.from_dict(data) props = base.properties return cls( - **{k: v for k, v in base.__dict__.items() if k in CmisObject.__dataclass_fields__}, + **{ + k: v + for k, v in base.__dict__.items() + if k in CmisObject.__dataclass_fields__ + }, content_stream_length=_prop_val(props, "cmis:contentStreamLength"), content_stream_mime_type=_prop_val(props, "cmis:contentStreamMimeType"), content_stream_file_name=_prop_val(props, "cmis:contentStreamFileName"), @@ -433,8 +462,12 @@ def from_dict(cls, data: Dict[str, Any]) -> "Document": is_latest_major_version=_prop_val(props, "cmis:isLatestMajorVersion"), is_private_working_copy=_prop_val(props, "cmis:isPrivateWorkingCopy"), checkin_comment=_prop_val(props, "cmis:checkinComment"), - is_version_series_checked_out=_prop_val(props, "cmis:isVersionSeriesCheckedOut"), - version_series_checked_out_id=_prop_val(props, "cmis:versionSeriesCheckedOutId"), + is_version_series_checked_out=_prop_val( + props, "cmis:isVersionSeriesCheckedOut" + ), + version_series_checked_out_id=_prop_val( + props, "cmis:versionSeriesCheckedOutId" + ), ) @@ -486,8 +519,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "ChildrenPage": parsed: List[CmisObject] = [] for entry in raw_objects: obj_data = entry.get("object") or entry - props = (obj_data.get("succinctProperties") - or obj_data.get("properties") or {}) + props = ( + obj_data.get("succinctProperties") or obj_data.get("properties") or {} + ) base_type = _prop_val(props, "cmis:baseTypeId") or "" if base_type == "cmis:document": parsed.append(Document.from_dict(obj_data)) diff --git a/tests/dms/__init__.py b/tests/dms/__init__.py new file mode 100644 index 0000000..e69de29 From 07c434f676ea186cac0a5fe2e0e5a756b5ef06c8 Mon Sep 17 00:00:00 2001 From: Karan Shukla Date: Tue, 31 Mar 2026 20:13:05 +0700 Subject: [PATCH 15/24] fix(dms): add ty type checker ignore comments for test files --- tests/dms/integration/conftest.py | 2 +- tests/dms/integration/test_dms_bdd.py | 78 ++++++++++++++------------- tests/dms/unit/test_client_admin.py | 2 +- tests/dms/unit/test_client_cmis.py | 2 +- 4 files changed, 43 insertions(+), 41 deletions(-) diff --git a/tests/dms/integration/conftest.py b/tests/dms/integration/conftest.py index a543119..b6b0218 100644 --- a/tests/dms/integration/conftest.py +++ b/tests/dms/integration/conftest.py @@ -15,7 +15,7 @@ def dms_client(): client = create_client(instance="default") return client except Exception as e: - pytest.skip(f"DMS integration tests require credentials: {e}") # ty: ignore[invalid-argument-type] + pytest.skip(f"DMS integration tests require credentials: {e}") # ty: ignore[invalid-argument-type, too-many-positional-arguments] diff --git a/tests/dms/integration/test_dms_bdd.py b/tests/dms/integration/test_dms_bdd.py index 3aa3fb7..ababd24 100644 --- a/tests/dms/integration/test_dms_bdd.py +++ b/tests/dms/integration/test_dms_bdd.py @@ -62,6 +62,8 @@ def __init__(self): self.cleanup_configs: List[str] = [] # config IDs self.cleanup_objects: List[tuple] = [] # (repo_id, object_id) self.child_doc_id: Optional[str] = None + self._config_request: Optional[CreateConfigRequest] = None + self._expected_updated_name: str = "" @pytest.fixture @@ -122,7 +124,7 @@ def select_version_repo(context: DMSTestContext, dms_client: DMSClient): version_repo = r break if version_repo is None: - pytest.skip("No version-enabled repository available") + pytest.skip("No version-enabled repository available") # ty: ignore[invalid-argument-type, too-many-positional-arguments] context.repo = version_repo context.repo_id = version_repo.id @@ -131,7 +133,7 @@ def select_version_repo(context: DMSTestContext, dms_client: DMSClient): def use_root_folder(context: DMSTestContext, dms_client: DMSClient): """Get the root folder ID for the selected repository.""" # The CMIS root folder objectId is the cmis_repository_id; repo.id (UUID) is used for the URL - context.root_folder_id = context.repo.cmis_repository_id + context.root_folder_id = context.repo.cmis_repository_id # ty: ignore[unresolved-attribute] # ==================== CONFIG: GIVEN ==================== @@ -164,7 +166,7 @@ def given_upload_document(context: DMSTestContext, dms_client: DMSClient, name: context.repo_id, context.root_folder_id, unique_name, - io.BytesIO(context.content_bytes), + io.BytesIO(context.content_bytes), # ty: ignore[invalid-argument-type] mime_type=mime_type, ) context.document = doc @@ -190,7 +192,7 @@ def create_child_document(context: DMSTestContext, dms_client: DMSClient, name: unique_name = f"{uuid.uuid4().hex[:8]}-{name}" doc = dms_client.create_document( context.repo_id, - context.folder.object_id, + context.folder.object_id, # ty: ignore[unresolved-attribute] unique_name, io.BytesIO(b"child document content"), mime_type="text/plain", @@ -216,7 +218,7 @@ def list_repos(context: DMSTestContext, dms_client: DMSClient): def get_repo_details(context: DMSTestContext, dms_client: DMSClient): """Get details of the selected repository.""" try: - context.repo = dms_client.get_repository(context.repo.id) + context.repo = dms_client.get_repository(context.repo.id) # ty: ignore[unresolved-attribute] context.operation_success = True except Exception as e: context.operation_error = e @@ -229,7 +231,7 @@ def get_repo_details(context: DMSTestContext, dms_client: DMSClient): def create_config(context: DMSTestContext, dms_client: DMSClient): """Create a repository configuration.""" try: - context.config = dms_client.create_config(context._config_request) + context.config = dms_client.create_config(context._config_request) # ty: ignore[invalid-argument-type] context.cleanup_configs.append(context.config.id) context.operation_success = True except Exception as e: @@ -241,9 +243,9 @@ def delete_config(context: DMSTestContext, dms_client: DMSClient): """Delete the previously created configuration.""" context.operation_error = None try: - dms_client.delete_config(context.config.id) - if context.config.id in context.cleanup_configs: - context.cleanup_configs.remove(context.config.id) + dms_client.delete_config(context.config.id) # ty: ignore[unresolved-attribute] + if context.config.id in context.cleanup_configs: # ty: ignore[unresolved-attribute] + context.cleanup_configs.remove(context.config.id) # ty: ignore[unresolved-attribute] context.operation_success = True except Exception as e: context.operation_error = e @@ -309,7 +311,7 @@ def upload_document(context: DMSTestContext, dms_client: DMSClient, name: str, m context.repo_id, context.root_folder_id, unique_name, - io.BytesIO(context.content_bytes), + io.BytesIO(context.content_bytes), # ty: ignore[invalid-argument-type] mime_type=mime_type, ) context.document = doc @@ -328,7 +330,7 @@ def upload_document_no_mime(context: DMSTestContext, dms_client: DMSClient, name context.repo_id, context.root_folder_id, unique_name, - io.BytesIO(context.content_bytes), + io.BytesIO(context.content_bytes), # ty: ignore[invalid-argument-type] ) context.document = doc context.cleanup_objects.append((context.repo_id, doc.object_id)) @@ -345,7 +347,7 @@ def get_object_by_id(context: DMSTestContext, dms_client: DMSClient): """Get an object by its ID (document context).""" try: context.retrieved_object = dms_client.get_object( - context.repo_id, context.document.object_id + context.repo_id, context.document.object_id # ty: ignore[unresolved-attribute] ) context.operation_success = True except Exception as e: @@ -357,7 +359,7 @@ def get_folder_by_id(context: DMSTestContext, dms_client: DMSClient): """Get a folder by its ID.""" try: context.retrieved_object = dms_client.get_object( - context.repo_id, context.folder.object_id + context.repo_id, context.folder.object_id # ty: ignore[unresolved-attribute] ) context.operation_success = True except Exception as e: @@ -369,7 +371,7 @@ def get_object_with_acl(context: DMSTestContext, dms_client: DMSClient): """Get an object with ACL data included.""" try: context.retrieved_object = dms_client.get_object( - context.repo_id, context.document.object_id, include_acl=True + context.repo_id, context.document.object_id, include_acl=True # ty: ignore[unresolved-attribute] ) context.operation_success = True except Exception as e: @@ -381,7 +383,7 @@ def download_content(context: DMSTestContext, dms_client: DMSClient): """Download the content of a document.""" try: context.content_response = dms_client.get_content( - context.repo_id, context.document.object_id, download="attachment" + context.repo_id, context.document.object_id, download="attachment" # ty: ignore[unresolved-attribute] ) context.operation_success = True except Exception as e: @@ -393,7 +395,7 @@ def list_children(context: DMSTestContext, dms_client: DMSClient): """List children of the created folder.""" try: context.children_page = dms_client.get_children( - context.repo_id, context.folder.object_id + context.repo_id, context.folder.object_id # ty: ignore[unresolved-attribute] ) context.operation_success = True except Exception as e: @@ -422,7 +424,7 @@ def update_object_name(context: DMSTestContext, dms_client: DMSClient, new_name: unique_name = f"{uuid.uuid4().hex[:8]}-{new_name}" context.updated_object = dms_client.update_properties( context.repo_id, - context.document.object_id, + context.document.object_id, # ty: ignore[unresolved-attribute] {"cmis:name": unique_name}, ) context._expected_updated_name = unique_name @@ -439,7 +441,7 @@ def check_out_document(context: DMSTestContext, dms_client: DMSClient): """Check out a document.""" try: context.pwc = dms_client.check_out( - context.repo_id, context.document.object_id + context.repo_id, context.document.object_id # ty: ignore[unresolved-attribute] ) context.operation_success = True except Exception as e: @@ -451,7 +453,7 @@ def cancel_check_out(context: DMSTestContext, dms_client: DMSClient): """Cancel a check out.""" try: dms_client.cancel_check_out( - context.repo_id, context.pwc.object_id + context.repo_id, context.pwc.object_id # ty: ignore[unresolved-attribute] ) context.pwc = None context.operation_success = True @@ -465,10 +467,10 @@ def check_in_document(context: DMSTestContext, dms_client: DMSClient, content: s try: context.checked_in_doc = dms_client.check_in( context.repo_id, - context.pwc.object_id, + context.pwc.object_id, # ty: ignore[unresolved-attribute] major=True, file=io.BytesIO(content.encode("utf-8")), - file_name=context.document.name, + file_name=context.document.name, # ty: ignore[unresolved-attribute] mime_type="text/plain", checkin_comment=comment, ) @@ -486,7 +488,7 @@ def get_acl(context: DMSTestContext, dms_client: DMSClient): """Get ACL for a document.""" try: context.acl = dms_client.apply_acl( - context.repo_id, context.document.object_id + context.repo_id, context.document.object_id # ty: ignore[unresolved-attribute] ) context.operation_success = True except Exception as e: @@ -543,12 +545,12 @@ def repo_details_success(context: DMSTestContext): @then("the repository should have a CMIS repository ID") def repo_has_cmis_id(context: DMSTestContext): - assert context.repo.cmis_repository_id + assert context.repo.cmis_repository_id # ty: ignore[unresolved-attribute] @then("the repository should have a name") def repo_has_name(context: DMSTestContext): - assert context.repo.name + assert context.repo.name # ty: ignore[unresolved-attribute] # ==================== CONFIG: THEN ==================== @@ -562,8 +564,8 @@ def config_created(context: DMSTestContext): @then("the configuration should have the expected name and value") def config_values_match(context: DMSTestContext): - assert context.config.config_name == context._config_request.config_name - assert str(context.config.config_value) == str(context._config_request.config_value) + assert context.config.config_name == context._config_request.config_name # ty: ignore[unresolved-attribute] + assert str(context.config.config_value) == str(context._config_request.config_value) # ty: ignore[unresolved-attribute] @then("the configuration deletion should be successful") @@ -590,9 +592,9 @@ def folder_created(context: DMSTestContext): @then("the created folder should have the correct name") def folder_name_correct(context: DMSTestContext): - assert context.folder.name + assert context.folder.name # ty: ignore[unresolved-attribute] # Name starts with UUID prefix, just verify it's set - assert len(context.folder.name) > 0 + assert len(context.folder.name) > 0 # ty: ignore[unresolved-attribute] # ==================== DOCUMENT: THEN ==================== @@ -607,18 +609,18 @@ def doc_uploaded(context: DMSTestContext): @then("the uploaded document should have the correct name") def doc_name_correct(context: DMSTestContext): - assert context.document.name - assert len(context.document.name) > 0 + assert context.document.name # ty: ignore[unresolved-attribute] + assert len(context.document.name) > 0 # ty: ignore[unresolved-attribute] @then(parsers.parse('the document should have mime type "{expected_mime}"')) def doc_mime_type(context: DMSTestContext, expected_mime: str): - assert context.document.content_stream_mime_type == expected_mime + assert context.document.content_stream_mime_type == expected_mime # ty: ignore[unresolved-attribute] @then("the document should have a mime type assigned by the server") def doc_has_any_mime_type(context: DMSTestContext): - assert context.document.content_stream_mime_type is not None + assert context.document.content_stream_mime_type is not None # ty: ignore[unresolved-attribute] # ==================== READ: THEN ==================== @@ -643,7 +645,7 @@ def object_is_folder(context: DMSTestContext): @then(parsers.parse('the object name should be "{expected_name}"')) def object_name_matches(context: DMSTestContext, expected_name: str): # Name has UUID prefix, so check suffix - assert context.retrieved_object.name.endswith(expected_name) + assert context.retrieved_object.name.endswith(expected_name) # ty: ignore[unresolved-attribute] @then("the download should be successful") @@ -654,7 +656,7 @@ def download_success(context: DMSTestContext): @then(parsers.parse('the downloaded content should match "{expected}"')) def download_content_match(context: DMSTestContext, expected: str): - actual = context.content_response.content.decode("utf-8") + actual = context.content_response.content.decode("utf-8") # ty: ignore[unresolved-attribute] assert actual == expected @@ -667,7 +669,7 @@ def children_success(context: DMSTestContext): @then(parsers.parse("the children should contain at least {count:d} item")) def children_count(context: DMSTestContext, count: int): - assert len(context.children_page.objects) >= count + assert len(context.children_page.objects) >= count # ty: ignore[unresolved-attribute] # ==================== UPDATE: THEN ==================== @@ -682,7 +684,7 @@ def update_success(context: DMSTestContext): @then(parsers.parse('the updated object name should be "{expected_name}"')) def updated_name_matches(context: DMSTestContext, expected_name: str): # Actual name has UUID prefix - assert context.updated_object.name == context._expected_updated_name + assert context.updated_object.name == context._expected_updated_name # ty: ignore[unresolved-attribute] # ==================== VERSIONING: THEN ==================== @@ -696,7 +698,7 @@ def checkout_success(context: DMSTestContext): @then("the PWC should be a private working copy") def pwc_is_private(context: DMSTestContext): - assert context.pwc.is_private_working_copy is True + assert context.pwc.is_private_working_copy is True # ty: ignore[unresolved-attribute] @then("the cancel check out should be successful") @@ -713,7 +715,7 @@ def checkin_success(context: DMSTestContext): @then("the new version label should not be empty") def version_label_set(context: DMSTestContext): - assert context.checked_in_doc.version_label + assert context.checked_in_doc.version_label # ty: ignore[unresolved-attribute] # ==================== ACL: THEN ==================== diff --git a/tests/dms/unit/test_client_admin.py b/tests/dms/unit/test_client_admin.py index e02448e..6120c5e 100644 --- a/tests/dms/unit/test_client_admin.py +++ b/tests/dms/unit/test_client_admin.py @@ -79,7 +79,7 @@ def client(): identityzone="test-zone", ) c = DMSClient(creds) - c._mock_http = mock_http + c._mock_http = mock_http # ty: ignore[unresolved-attribute] yield c diff --git a/tests/dms/unit/test_client_cmis.py b/tests/dms/unit/test_client_cmis.py index 52a1bef..d71afa7 100644 --- a/tests/dms/unit/test_client_cmis.py +++ b/tests/dms/unit/test_client_cmis.py @@ -102,7 +102,7 @@ def client(): mock_http_cls.return_value = mock_http c = DMSClient(creds) # Expose the mock for assertions - c._mock_http = mock_http + c._mock_http = mock_http # ty: ignore[unresolved-attribute] yield c From 1a009b587f34dfccf2df82465d22ffbc304c7a8d Mon Sep 17 00:00:00 2001 From: Karan Shukla Date: Wed, 1 Apr 2026 10:53:08 +0700 Subject: [PATCH 16/24] fix(dms): fix parent_folder_id description in user guide --- src/sap_cloud_sdk/dms/user-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/dms/user-guide.md b/src/sap_cloud_sdk/dms/user-guide.md index 163fcd1..81910de 100644 --- a/src/sap_cloud_sdk/dms/user-guide.md +++ b/src/sap_cloud_sdk/dms/user-guide.md @@ -198,7 +198,7 @@ All CMIS operations require a `repository_id` (the CMIS repository ID from `repo ```python folder = client.create_folder( repository_id="cmis-repo-id", - parent_folder_id="root-folder-object-id", + parent_folder_id="parent-folder-object-id", folder_name="My Folder", description="Optional description", ) From b1904ebe07b309cfb9c21a50c7de6d5b313adf65 Mon Sep 17 00:00:00 2001 From: Jagnath Reddy Date: Thu, 2 Apr 2026 16:42:53 +0530 Subject: [PATCH 17/24] todo complete --- src/sap_cloud_sdk/dms/_auth.py | 15 +++++++++++---- src/sap_cloud_sdk/dms/config.py | 2 +- src/sap_cloud_sdk/dms/model.py | 10 ++++++++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/sap_cloud_sdk/dms/_auth.py b/src/sap_cloud_sdk/dms/_auth.py index f6de34a..6d8d62f 100644 --- a/src/sap_cloud_sdk/dms/_auth.py +++ b/src/sap_cloud_sdk/dms/_auth.py @@ -1,6 +1,7 @@ import logging import time import requests +from collections import OrderedDict from requests.exceptions import RequestException from typing import Optional, TypedDict from sap_cloud_sdk.dms.exceptions import ( @@ -18,7 +19,6 @@ class _TokenResponse(TypedDict): expires_in: int -# TODO: limit number of access tokens in cache to 10 class _CachedToken: def __init__(self, token: str, expires_at: float) -> None: self.token = token @@ -28,19 +28,22 @@ def is_valid(self) -> bool: return time.monotonic() < self.expires_at - 30 -# TODO: limit number of access tokens in cache to 10 +_MAX_CACHE_SIZE = 10 + + class Auth: """Fetches and caches OAuth2 access tokens for DMS service requests.""" def __init__(self, credentials: DMSCredentials) -> None: self._credentials = credentials - self._cache: dict[str, _CachedToken] = {} + self._cache: OrderedDict[str, _CachedToken] = OrderedDict() def get_token(self, tenant_subdomain: Optional[str] = None) -> str: cache_key = tenant_subdomain or "technical" cached = self._cache.get(cache_key) if cached and cached.is_valid(): + self._cache.move_to_end(cache_key) # Mark as recently used by moving to end logger.debug("Using cached token for key '%s'", cache_key) return cached.token @@ -48,6 +51,10 @@ def get_token(self, tenant_subdomain: Optional[str] = None) -> str: token_url = self._resolve_token_url(tenant_subdomain) token = self._fetch_token(token_url) + if len(self._cache) >= _MAX_CACHE_SIZE: + evicted, _ = self._cache.popitem(last=False) + logger.debug("Cache full — evicted token for key '%s'", evicted) + self._cache[cache_key] = _CachedToken( token=token["access_token"], expires_at=time.monotonic() + token.get("expires_in", 3600), @@ -99,4 +106,4 @@ def _fetch_token(self, token_url: str) -> _TokenResponse: raise DMSError("Token response missing access_token") logger.debug("Token fetched successfully") - return payload + return payload \ No newline at end of file diff --git a/src/sap_cloud_sdk/dms/config.py b/src/sap_cloud_sdk/dms/config.py index a971fac..f7ad14c 100644 --- a/src/sap_cloud_sdk/dms/config.py +++ b/src/sap_cloud_sdk/dms/config.py @@ -123,7 +123,7 @@ def load_sdm_config_from_env_or_mount(instance: Optional[str] = None) -> DMSCred read_from_mount_and_fallback_to_env_var( base_volume_mount="/etc/secrets/appfnd", base_var_name="CLOUD_SDK_CFG", - module="sdm", # TODO check if this should be "dms" or "sdm" + module="sdm", instance=inst, target=binding, ) diff --git a/src/sap_cloud_sdk/dms/model.py b/src/sap_cloud_sdk/dms/model.py index da763e6..ab35cdc 100644 --- a/src/sap_cloud_sdk/dms/model.py +++ b/src/sap_cloud_sdk/dms/model.py @@ -82,6 +82,12 @@ class ConfigName(str, Enum): IS_CROSS_DOMAIN_MAPPING_ALLOWED = "isCrossDomainMappingAllowed" +class HashAlgorithm(str, Enum): + MD5 = "MD5" + SHA1 = "SHA-1" + SHA256 = "SHA-256" + + @dataclass class UserClaim: """User identity claims forwarded to the DMS service. @@ -117,7 +123,7 @@ class InternalRepoRequest: isVersionEnabled: Optional[bool] = None isVirusScanEnabled: Optional[bool] = None skipVirusScanForLargeFile: Optional[bool] = None - hashAlgorithms: Optional[str] = None # TODO provide enum + hashAlgorithms: Optional[HashAlgorithm] = None isThumbnailEnabled: Optional[bool] = None isEncryptionEnabled: Optional[bool] = None externalId: Optional[str] = None @@ -533,4 +539,4 @@ def from_dict(cls, data: Dict[str, Any]) -> "ChildrenPage": objects=parsed, has_more_items=data.get("hasMoreItems", False), num_items=data.get("numItems"), - ) + ) \ No newline at end of file From a37d6666dda630868846e8f98943bb36acbdb336 Mon Sep 17 00:00:00 2001 From: Karan Shukla Date: Thu, 2 Apr 2026 18:31:37 +0700 Subject: [PATCH 18/24] fix ruff: missing newlines at end of file --- src/sap_cloud_sdk/dms/_auth.py | 4 ++-- src/sap_cloud_sdk/dms/model.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sap_cloud_sdk/dms/_auth.py b/src/sap_cloud_sdk/dms/_auth.py index 6d8d62f..767f775 100644 --- a/src/sap_cloud_sdk/dms/_auth.py +++ b/src/sap_cloud_sdk/dms/_auth.py @@ -43,7 +43,7 @@ def get_token(self, tenant_subdomain: Optional[str] = None) -> str: cached = self._cache.get(cache_key) if cached and cached.is_valid(): - self._cache.move_to_end(cache_key) # Mark as recently used by moving to end + self._cache.move_to_end(cache_key) # Mark as recently used by moving to end logger.debug("Using cached token for key '%s'", cache_key) return cached.token @@ -106,4 +106,4 @@ def _fetch_token(self, token_url: str) -> _TokenResponse: raise DMSError("Token response missing access_token") logger.debug("Token fetched successfully") - return payload \ No newline at end of file + return payload diff --git a/src/sap_cloud_sdk/dms/model.py b/src/sap_cloud_sdk/dms/model.py index ab35cdc..9a1fb7c 100644 --- a/src/sap_cloud_sdk/dms/model.py +++ b/src/sap_cloud_sdk/dms/model.py @@ -539,4 +539,4 @@ def from_dict(cls, data: Dict[str, Any]) -> "ChildrenPage": objects=parsed, has_more_items=data.get("hasMoreItems", False), num_items=data.get("numItems"), - ) \ No newline at end of file + ) From af0a684d0c774e63a1a0254db63be13d0d403458 Mon Sep 17 00:00:00 2001 From: Karan Shukla Date: Wed, 8 Apr 2026 22:07:27 +0700 Subject: [PATCH 19/24] fix(dms): refactored the pagination and added more documentations in accordance with other modules. --- .env_integration_tests.example | 3 + README.md | 2 + src/sap_cloud_sdk/dms/__init__.py | 50 ++++++-- src/sap_cloud_sdk/dms/_endpoints.py | 2 - src/sap_cloud_sdk/dms/client.py | 97 ++++++++------- src/sap_cloud_sdk/dms/model.py | 61 ++++++++++ tests/dms/integration/conftest.py | 3 - tests/dms/integration/test_dms_bdd.py | 75 ++++++++---- tests/dms/unit/test_client_admin.py | 89 +++++++++----- tests/dms/unit/test_client_cmis.py | 166 +++++++++++++++++++------- tests/dms/unit/test_cmis_models.py | 6 +- tests/dms/unit/test_http_invoker.py | 19 ++- 12 files changed, 414 insertions(+), 159 deletions(-) delete mode 100644 src/sap_cloud_sdk/dms/_endpoints.py diff --git a/.env_integration_tests.example b/.env_integration_tests.example index d0a1678..b73fe86 100644 --- a/.env_integration_tests.example +++ b/.env_integration_tests.example @@ -13,3 +13,6 @@ CLOUD_SDK_CFG_DESTINATION_DEFAULT_CLIENTSECRET=your-destination-client-secret-he CLOUD_SDK_CFG_DESTINATION_DEFAULT_URL=https://your-destination-auth-url-here CLOUD_SDK_CFG_DESTINATION_DEFAULT_URI=https://your-destination-configuration-uri-here CLOUD_SDK_CFG_DESTINATION_DEFAULT_IDENTITYZONE=your-identity-zone-here + +CLOUD_SDK_CFG_SDM_DEFAULT_URI=https://your-sdm-api-uri-here +CLOUD_SDK_CFG_SDM_DEFAULT_UAA='{"url":"https://your-auth-url","clientid":"your-client-id","clientsecret":"your-client-secret","identityzone":"your-identity-zone"}' diff --git a/README.md b/README.md index 60f083e..f416656 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The Python SDK offers a clean, type-safe API following Python best practices whi - **AI Core Integration** - **Audit Log Service** - **Destination Service** +- **Document Management Service** - **ObjectStore Service** - **Secret Resolver** - **Telemetry & Observability** @@ -49,6 +50,7 @@ Each module has comprehensive usage guides: - [AuditLog](src/sap_cloud_sdk/core/auditlog/user-guide.md) - [Destination](src/sap_cloud_sdk/destination/user-guide.md) +- [DMS](src/sap_cloud_sdk/dms/user-guide.md) - [ObjectStore](src/sap_cloud_sdk/objectstore/user-guide.md) - [Secret Resolver](src/sap_cloud_sdk/core/secret_resolver/user-guide.md) - [Telemetry](src/sap_cloud_sdk/core/telemetry/user-guide.md) diff --git a/src/sap_cloud_sdk/dms/__init__.py b/src/sap_cloud_sdk/dms/__init__.py index 51f22dd..3b6991b 100644 --- a/src/sap_cloud_sdk/dms/__init__.py +++ b/src/sap_cloud_sdk/dms/__init__.py @@ -1,20 +1,52 @@ +"""SAP Cloud SDK for Python - Document Management Service (DMS) module + +The create_client() function loads credentials from mounts/env vars and points +to an instance in the cloud. + +Usage: + from sap_cloud_sdk.dms import create_client + + # Recommended: use the factory which configures OAuth/HTTP from environment + client = create_client() + + # Or specify a named instance + client = create_client(instance="my-sdm-instance") + + # List all onboarded repositories + repos = client.get_all_repositories() + + # Create a folder + folder = client.create_folder(repo.id, root_folder_id, "MyFolder") + + # Upload a document + with open("file.pdf", "rb") as f: + doc = client.create_document( + repo.id, folder.object_id, "file.pdf", f, mime_type="application/pdf" + ) +""" + from typing import Optional + +from sap_cloud_sdk.core.telemetry import Module from sap_cloud_sdk.dms.model import DMSCredentials from sap_cloud_sdk.dms.client import DMSClient from sap_cloud_sdk.dms.config import load_sdm_config_from_env_or_mount +from sap_cloud_sdk.dms.exceptions import DMSError def create_client( - *, instance: Optional[str] = None, dms_cred: Optional[DMSCredentials] = None + *, + instance: Optional[str] = None, + dms_cred: Optional[DMSCredentials] = None, + _telemetry_source: Optional[Module] = None, ): - if dms_cred is not None: - return DMSClient(dms_cred) - if instance is not None: - return DMSClient(load_sdm_config_from_env_or_mount(instance)) - - raise ValueError( - "No configuration provided. Please provide either instance name, config, or dms_cred." - ) + try: + credentials = dms_cred or load_sdm_config_from_env_or_mount(instance) + client = DMSClient(credentials) + client._telemetry_source = _telemetry_source + return client + except Exception as e: + raise DMSError(f"Failed to create DMS client: {e}") from e __all__ = ["create_client"] diff --git a/src/sap_cloud_sdk/dms/_endpoints.py b/src/sap_cloud_sdk/dms/_endpoints.py deleted file mode 100644 index 8c1d91c..0000000 --- a/src/sap_cloud_sdk/dms/_endpoints.py +++ /dev/null @@ -1,2 +0,0 @@ -REPOSITORIES = "/rest/v2/repositories" -CONFIGS = "/rest/v2/configs" diff --git a/src/sap_cloud_sdk/dms/client.py b/src/sap_cloud_sdk/dms/client.py index 541205f..13c99b9 100644 --- a/src/sap_cloud_sdk/dms/client.py +++ b/src/sap_cloud_sdk/dms/client.py @@ -12,6 +12,7 @@ UpdateConfigRequest, Ace, Acl, + ChildrenOptions, ChildrenPage, CmisObject, Document, @@ -20,14 +21,15 @@ ) from sap_cloud_sdk.dms._auth import Auth from sap_cloud_sdk.dms._http import HttpInvoker -from sap_cloud_sdk.dms import _endpoints as endpoints from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics logger = logging.getLogger(__name__) - # --------------------------------------------------------------------------- -# CMIS property helpers +# Admin API endpoint paths +# --------------------------------------------------------------------------- +_REPOSITORIES = "/rest/v2/repositories" +_CONFIGS = "/rest/v2/configs" # --------------------------------------------------------------------------- @@ -58,7 +60,26 @@ def _build_aces(aces: List[Ace], prefix: str) -> Dict[str, str]: class DMSClient: - """Client for interacting with the SAP Document Management Service Admin API.""" + """Client for the SAP Document Management Service (DMS). + + Provides methods for: + - **Admin API**: Onboard, list, update, and delete repositories; + manage repository configurations. + - **CMIS Browser Binding**: Create folders and documents, manage + versions (check-out / check-in), apply ACLs, browse folder + contents, and download document content. + + Use :func:`sap_cloud_sdk.dms.create_client` to obtain an instance + with automatic credential resolution, or construct directly with + explicit :class:`DMSCredentials`. + + Example:: + + from sap_cloud_sdk.dms import create_client + + client = create_client() # resolves from env / mounted secrets + repos = client.get_all_repositories() + """ def __init__( self, @@ -73,6 +94,7 @@ def __init__( connect_timeout=connect_timeout, read_timeout=read_timeout, ) + self._telemetry_source: Optional[Module] = None logger.debug( "DMSClient initialized for instance '%s'", credentials.instance_name ) @@ -101,7 +123,7 @@ def onboard_repository( """ logger.info("Onboarding repository '%s'", request.to_dict()) response = self._http.post( - path=endpoints.REPOSITORIES, + path=_REPOSITORIES, payload={"repository": request.to_dict()}, tenant_subdomain=tenant, user_claim=user_claim, @@ -131,7 +153,7 @@ def get_all_repositories( """ logger.info("Fetching all repositories") response = self._http.get( - path=endpoints.REPOSITORIES, + path=_REPOSITORIES, tenant_subdomain=tenant, user_claim=user_claim, headers={"Accept": "application/vnd.sap.sdm.repositories+json;version=3"}, @@ -166,7 +188,7 @@ def get_repository( """ logger.info("Fetching repository '%s'", repo_id) response = self._http.get( - path=f"{endpoints.REPOSITORIES}/{repo_id}", + path=f"{_REPOSITORIES}/{repo_id}", tenant_subdomain=tenant, user_claim=user_claim, ) @@ -201,7 +223,7 @@ def update_repository( raise ValueError("repo_id must not be empty") logger.info("Updating repository '%s'", repo_id) response = self._http.put( - path=f"{endpoints.REPOSITORIES}/{repo_id}", + path=f"{_REPOSITORIES}/{repo_id}", payload=request.to_dict(), tenant_subdomain=tenant, user_claim=user_claim, @@ -231,7 +253,7 @@ def delete_repository( DMSRuntimeException: If the server encounters an internal error. """ self._http.delete( - path=f"{endpoints.REPOSITORIES}/{repo_id}", + path=f"{_REPOSITORIES}/{repo_id}", tenant_subdomain=tenant, user_claim=user_claim, ) @@ -260,7 +282,7 @@ def create_config( """ logger.info("Creating config '%s'", request.config_name) response = self._http.post( - path=endpoints.CONFIGS, + path=_CONFIGS, payload=request.to_dict(), tenant_subdomain=tenant, user_claim=user_claim, @@ -290,7 +312,7 @@ def get_configs( """ logger.info("Fetching all configs") response = self._http.get( - path=endpoints.CONFIGS, + path=_CONFIGS, tenant_subdomain=tenant, user_claim=user_claim, ) @@ -325,7 +347,7 @@ def update_config( raise ValueError("config_id must not be empty") logger.info("Updating config '%s'", config_id) response = self._http.put( - path=f"{endpoints.CONFIGS}/{config_id}", + path=f"{_CONFIGS}/{config_id}", payload=request.to_dict(), tenant_subdomain=tenant, user_claim=user_claim, @@ -358,7 +380,7 @@ def delete_config( raise ValueError("config_id must not be empty") logger.info("Deleting config '%s'", config_id) self._http.delete( - path=f"{endpoints.CONFIGS}/{config_id}", + path=f"{_CONFIGS}/{config_id}", tenant_subdomain=tenant, user_claim=user_claim, ) @@ -924,31 +946,21 @@ def get_children( repository_id: str, folder_id: str, *, - max_items: int = 100, - skip_count: int = 0, - order_by: Optional[str] = None, - filter: Optional[str] = None, - include_allowable_actions: bool = False, - include_path_segment: bool = False, - succinct: bool = True, + options: Optional[ChildrenOptions] = None, tenant: Optional[str] = None, user_claim: Optional[UserClaim] = None, ) -> ChildrenPage: """List children of a folder (one page). - Use *skip_count* and *max_items* for pagination. The returned - :class:`ChildrenPage` has a ``has_more_items`` flag. + Use :class:`ChildrenOptions` to control pagination, sorting, and + filtering. The returned :class:`ChildrenPage` has a + ``has_more_items`` flag and ``num_items`` count. Args: repository_id: Target repository ID. folder_id: Parent folder CMIS objectId. - max_items: Maximum number of items to return (default 100). - skip_count: Number of items to skip (pagination offset). - order_by: Sort order (e.g. ``"cmis:creationDate ASC"``). - filter: Comma-separated property list. - include_allowable_actions: Include allowable actions per child. - include_path_segment: Include the path segment per child. - succinct: Use succinct property format. + options: Pagination and query options. Defaults to + ``ChildrenOptions()`` (max 100 items, no skip). tenant: Optional subscriber subdomain. user_claim: Optional user identity claims. @@ -959,23 +971,18 @@ def get_children( DMSObjectNotFoundException: If the folder is not found. DMSPermissionDeniedException: If the access token is invalid. DMSRuntimeException: If the server encounters an internal error. + + Example:: + + from sap_cloud_sdk.dms import create_client, ChildrenOptions + + client = create_client() + opts = ChildrenOptions(max_items=50, order_by="cmis:creationDate ASC") + page = client.get_children(repo_id, folder_id, options=opts) """ - params: Dict[str, str] = { - "objectId": folder_id, - "cmisselector": "children", - "maxItems": str(max_items), - "skipCount": str(skip_count), - } - if order_by: - params["orderBy"] = order_by - if filter: - params["filter"] = filter - if include_allowable_actions: - params["includeAllowableActions"] = "true" - if include_path_segment: - params["includePathSegment"] = "true" - if succinct: - params["succinct"] = "true" + opts = options or ChildrenOptions() + params: Dict[str, str] = {"objectId": folder_id, "cmisselector": "children"} + params.update(opts.to_query_params()) logger.info( "Getting children of folder '%s' in repo '%s'", folder_id, repository_id diff --git a/src/sap_cloud_sdk/dms/model.py b/src/sap_cloud_sdk/dms/model.py index 9a1fb7c..c628f1f 100644 --- a/src/sap_cloud_sdk/dms/model.py +++ b/src/sap_cloud_sdk/dms/model.py @@ -511,6 +511,67 @@ def from_dict(cls, data: Dict[str, Any]) -> "Acl": ) +@dataclass +class ChildrenOptions: + """Pagination and query options for :meth:`DMSClient.get_children`. + + Encapsulates all pagination and filter parameters for listing folder + children, following the same pattern as the Destination module's + ``ListOptions``. + + Example: + ```python + from sap_cloud_sdk.dms import create_client, ChildrenOptions + + client = create_client() + options = ChildrenOptions(max_items=50, skip_count=100, order_by="cmis:creationDate ASC") + page = client.get_children(repo_id, folder_id, options=options) + while page.has_more_items: + options.skip_count += options.max_items + page = client.get_children(repo_id, folder_id, options=options) + ``` + + Attributes: + max_items: Maximum number of items to return per page (default 100). + skip_count: Number of items to skip (pagination offset, default 0). + order_by: Sort order (e.g. ``"cmis:creationDate ASC"``). + filter: Comma-separated CMIS property filter list. + include_allowable_actions: Include allowable actions per child. + include_path_segment: Include the path segment per child. + succinct: Use succinct property format (default True). + """ + + max_items: int = 100 + skip_count: int = 0 + order_by: Optional[str] = None + filter: Optional[str] = None + include_allowable_actions: bool = False + include_path_segment: bool = False + succinct: bool = True + + def to_query_params(self) -> Dict[str, str]: + """Convert options to CMIS query parameters. + + Returns: + Dict[str, str]: Query parameters for the HTTP request. + """ + params: Dict[str, str] = { + "maxItems": str(self.max_items), + "skipCount": str(self.skip_count), + } + if self.order_by: + params["orderBy"] = self.order_by + if self.filter: + params["filter"] = self.filter + if self.include_allowable_actions: + params["includeAllowableActions"] = "true" + if self.include_path_segment: + params["includePathSegment"] = "true" + if self.succinct: + params["succinct"] = "true" + return params + + @dataclass class ChildrenPage: """Paginated result from a CMIS ``getChildren`` request.""" diff --git a/tests/dms/integration/conftest.py b/tests/dms/integration/conftest.py index b6b0218..254a83b 100644 --- a/tests/dms/integration/conftest.py +++ b/tests/dms/integration/conftest.py @@ -4,7 +4,6 @@ from dotenv import load_dotenv - @pytest.fixture(scope="session") def dms_client(): """Create a DMS client for cloud testing using secret resolver.""" @@ -18,10 +17,8 @@ def dms_client(): pytest.skip(f"DMS integration tests require credentials: {e}") # ty: ignore[invalid-argument-type, too-many-positional-arguments] - def _setup_cloud_mode(): """Common setup for cloud mode integration tests.""" env_file = Path(__file__).parents[3] / ".env_integration_tests" if env_file.exists(): load_dotenv(env_file) - \ No newline at end of file diff --git a/tests/dms/integration/test_dms_bdd.py b/tests/dms/integration/test_dms_bdd.py index ababd24..5177e20 100644 --- a/tests/dms/integration/test_dms_bdd.py +++ b/tests/dms/integration/test_dms_bdd.py @@ -11,17 +11,15 @@ from sap_cloud_sdk.dms.client import DMSClient from sap_cloud_sdk.dms.model import ( - Ace, Acl, + ChildrenOptions, ChildrenPage, CmisObject, CreateConfigRequest, Document, Folder, - InternalRepoRequest, Repository, RepositoryConfig, - UpdateConfigRequest, ) from sap_cloud_sdk.dms.exceptions import ( DMSError, @@ -159,7 +157,9 @@ def have_document_content(context: DMSTestContext, content: str): @given(parsers.parse('I upload a document named "{name}" with mime type "{mime_type}"')) -def given_upload_document(context: DMSTestContext, dms_client: DMSClient, name: str, mime_type: str): +def given_upload_document( + context: DMSTestContext, dms_client: DMSClient, name: str, mime_type: str +): """Upload a document as a prerequisite step.""" unique_name = f"{uuid.uuid4().hex[:8]}-{name}" doc = dms_client.create_document( @@ -282,7 +282,9 @@ def when_create_folder(context: DMSTestContext, dms_client: DMSClient, name: str @when(parsers.parse('I create a folder named "{name}" with description "{desc}"')) -def create_folder_with_desc(context: DMSTestContext, dms_client: DMSClient, name: str, desc: str): +def create_folder_with_desc( + context: DMSTestContext, dms_client: DMSClient, name: str, desc: str +): """Create a folder with a description.""" try: unique_name = f"{uuid.uuid4().hex[:8]}-{name}" @@ -303,7 +305,9 @@ def create_folder_with_desc(context: DMSTestContext, dms_client: DMSClient, name @when(parsers.parse('I upload a document named "{name}" with mime type "{mime_type}"')) -def upload_document(context: DMSTestContext, dms_client: DMSClient, name: str, mime_type: str): +def upload_document( + context: DMSTestContext, dms_client: DMSClient, name: str, mime_type: str +): """Upload a document with specified mime type.""" try: unique_name = f"{uuid.uuid4().hex[:8]}-{name}" @@ -347,7 +351,8 @@ def get_object_by_id(context: DMSTestContext, dms_client: DMSClient): """Get an object by its ID (document context).""" try: context.retrieved_object = dms_client.get_object( - context.repo_id, context.document.object_id # ty: ignore[unresolved-attribute] + context.repo_id, + context.document.object_id, # ty: ignore[unresolved-attribute] ) context.operation_success = True except Exception as e: @@ -359,7 +364,8 @@ def get_folder_by_id(context: DMSTestContext, dms_client: DMSClient): """Get a folder by its ID.""" try: context.retrieved_object = dms_client.get_object( - context.repo_id, context.folder.object_id # ty: ignore[unresolved-attribute] + context.repo_id, + context.folder.object_id, # ty: ignore[unresolved-attribute] ) context.operation_success = True except Exception as e: @@ -371,7 +377,9 @@ def get_object_with_acl(context: DMSTestContext, dms_client: DMSClient): """Get an object with ACL data included.""" try: context.retrieved_object = dms_client.get_object( - context.repo_id, context.document.object_id, include_acl=True # ty: ignore[unresolved-attribute] + context.repo_id, + context.document.object_id, + include_acl=True, # ty: ignore[unresolved-attribute] ) context.operation_success = True except Exception as e: @@ -383,7 +391,9 @@ def download_content(context: DMSTestContext, dms_client: DMSClient): """Download the content of a document.""" try: context.content_response = dms_client.get_content( - context.repo_id, context.document.object_id, download="attachment" # ty: ignore[unresolved-attribute] + context.repo_id, + context.document.object_id, + download="attachment", # ty: ignore[unresolved-attribute] ) context.operation_success = True except Exception as e: @@ -395,7 +405,8 @@ def list_children(context: DMSTestContext, dms_client: DMSClient): """List children of the created folder.""" try: context.children_page = dms_client.get_children( - context.repo_id, context.folder.object_id # ty: ignore[unresolved-attribute] + context.repo_id, + context.folder.object_id, # ty: ignore[unresolved-attribute] ) context.operation_success = True except Exception as e: @@ -403,11 +414,14 @@ def list_children(context: DMSTestContext, dms_client: DMSClient): @when(parsers.parse("I list children of the root folder with max items {max_items:d}")) -def list_children_paginated(context: DMSTestContext, dms_client: DMSClient, max_items: int): +def list_children_paginated( + context: DMSTestContext, dms_client: DMSClient, max_items: int +): """List children with pagination.""" try: + opts = ChildrenOptions(max_items=max_items) context.children_page = dms_client.get_children( - context.repo_id, context.root_folder_id, max_items=max_items + context.repo_id, context.root_folder_id, options=opts ) context.operation_success = True except Exception as e: @@ -441,7 +455,8 @@ def check_out_document(context: DMSTestContext, dms_client: DMSClient): """Check out a document.""" try: context.pwc = dms_client.check_out( - context.repo_id, context.document.object_id # ty: ignore[unresolved-attribute] + context.repo_id, + context.document.object_id, # ty: ignore[unresolved-attribute] ) context.operation_success = True except Exception as e: @@ -453,7 +468,8 @@ def cancel_check_out(context: DMSTestContext, dms_client: DMSClient): """Cancel a check out.""" try: dms_client.cancel_check_out( - context.repo_id, context.pwc.object_id # ty: ignore[unresolved-attribute] + context.repo_id, + context.pwc.object_id, # ty: ignore[unresolved-attribute] ) context.pwc = None context.operation_success = True @@ -462,7 +478,9 @@ def cancel_check_out(context: DMSTestContext, dms_client: DMSClient): @when(parsers.parse('I check in with content "{content}" and comment "{comment}"')) -def check_in_document(context: DMSTestContext, dms_client: DMSClient, content: str, comment: str): +def check_in_document( + context: DMSTestContext, dms_client: DMSClient, content: str, comment: str +): """Check in the PWC with new content.""" try: context.checked_in_doc = dms_client.check_in( @@ -488,7 +506,8 @@ def get_acl(context: DMSTestContext, dms_client: DMSClient): """Get ACL for a document.""" try: context.acl = dms_client.apply_acl( - context.repo_id, context.document.object_id # ty: ignore[unresolved-attribute] + context.repo_id, + context.document.object_id, # ty: ignore[unresolved-attribute] ) context.operation_success = True except Exception as e: @@ -748,7 +767,9 @@ def cleanup_folder(context: DMSTestContext, dms_client: DMSClient): _delete_cmis_object(dms_client, context.repo_id, context.folder.object_id) _remove_from_cleanup(context, context.folder.object_id) except Exception as e: - logger.warning("Cleanup failed for folder %s: %s", context.folder.object_id, e) + logger.warning( + "Cleanup failed for folder %s: %s", context.folder.object_id, e + ) @then("I clean up the created document") @@ -759,14 +780,18 @@ def cleanup_document(context: DMSTestContext, dms_client: DMSClient): _delete_cmis_object(dms_client, context.repo_id, context.document.object_id) _remove_from_cleanup(context, context.document.object_id) except Exception as e: - logger.warning("Cleanup failed for document %s: %s", context.document.object_id, e) + logger.warning( + "Cleanup failed for document %s: %s", context.document.object_id, e + ) @then("I clean up the updated document") def cleanup_updated_document(context: DMSTestContext, dms_client: DMSClient): """Delete the updated document.""" - obj_id = context.updated_object.object_id if context.updated_object else ( - context.document.object_id if context.document else None + obj_id = ( + context.updated_object.object_id + if context.updated_object + else (context.document.object_id if context.document else None) ) if obj_id: try: @@ -784,14 +809,18 @@ def cleanup_children_folder(context: DMSTestContext, dms_client: DMSClient): try: _delete_cmis_object(dms_client, context.repo_id, context.child_doc_id) except Exception as e: - logger.warning("Cleanup failed for child doc %s: %s", context.child_doc_id, e) + logger.warning( + "Cleanup failed for child doc %s: %s", context.child_doc_id, e + ) # Then delete the parent folder if context.folder: try: _delete_cmis_object(dms_client, context.repo_id, context.folder.object_id) _remove_from_cleanup(context, context.folder.object_id) except Exception as e: - logger.warning("Cleanup failed for folder %s: %s", context.folder.object_id, e) + logger.warning( + "Cleanup failed for folder %s: %s", context.folder.object_id, e + ) # ==================== HELPERS ==================== diff --git a/tests/dms/unit/test_client_admin.py b/tests/dms/unit/test_client_admin.py index 6120c5e..5b783c2 100644 --- a/tests/dms/unit/test_client_admin.py +++ b/tests/dms/unit/test_client_admin.py @@ -29,6 +29,7 @@ # Helper # --------------------------------------------------------------- + def _mock_response(data, status_code=200): resp = Mock() resp.json.return_value = data @@ -87,6 +88,7 @@ def client(): # onboard_repository # --------------------------------------------------------------- + class TestOnboardRepository: def test_basic(self, client): client._mock_http.post.return_value = _mock_response(_REPO_RESPONSE) @@ -119,7 +121,7 @@ def test_with_versioning_enabled(self, client): client._mock_http.post.return_value = _mock_response(_REPO_RESPONSE) request = InternalRepoRequest(displayName="VersRepo", isVersionEnabled=True) - repo = client.onboard_repository(request) + client.onboard_repository(request) payload = client._mock_http.post.call_args[1]["payload"] assert payload["repository"]["isVersionEnabled"] is True @@ -129,13 +131,16 @@ def test_with_versioning_enabled(self, client): # get_all_repositories # --------------------------------------------------------------- + class TestGetAllRepositories: def test_basic(self, client): - client._mock_http.get.return_value = _mock_response({ - "repoAndConnectionInfos": [ - {"repository": _REPO_RESPONSE}, - ] - }) + client._mock_http.get.return_value = _mock_response( + { + "repoAndConnectionInfos": [ + {"repository": _REPO_RESPONSE}, + ] + } + ) repos = client.get_all_repositories() @@ -145,12 +150,15 @@ def test_basic(self, client): call_args = client._mock_http.get.call_args assert call_args[1]["path"] == "/rest/v2/repositories" - assert call_args[1]["headers"]["Accept"] == "application/vnd.sap.sdm.repositories+json;version=3" + assert ( + call_args[1]["headers"]["Accept"] + == "application/vnd.sap.sdm.repositories+json;version=3" + ) def test_empty_list(self, client): - client._mock_http.get.return_value = _mock_response({ - "repoAndConnectionInfos": [] - }) + client._mock_http.get.return_value = _mock_response( + {"repoAndConnectionInfos": []} + ) repos = client.get_all_repositories() @@ -158,12 +166,14 @@ def test_empty_list(self, client): def test_multiple_repos(self, client): repo2 = {**_REPO_RESPONSE, "id": "repo-uuid-2", "name": "Repo2"} - client._mock_http.get.return_value = _mock_response({ - "repoAndConnectionInfos": [ - {"repository": _REPO_RESPONSE}, - {"repository": repo2}, - ] - }) + client._mock_http.get.return_value = _mock_response( + { + "repoAndConnectionInfos": [ + {"repository": _REPO_RESPONSE}, + {"repository": repo2}, + ] + } + ) repos = client.get_all_repositories() @@ -171,9 +181,9 @@ def test_multiple_repos(self, client): assert repos[1].id == "repo-uuid-2" def test_with_tenant(self, client): - client._mock_http.get.return_value = _mock_response({ - "repoAndConnectionInfos": [] - }) + client._mock_http.get.return_value = _mock_response( + {"repoAndConnectionInfos": []} + ) client.get_all_repositories(tenant="sub1") @@ -184,22 +194,26 @@ def test_with_tenant(self, client): # get_repository # --------------------------------------------------------------- + class TestGetRepository: def test_basic(self, client): - client._mock_http.get.return_value = _mock_response({ - "repository": _REPO_RESPONSE - }) + client._mock_http.get.return_value = _mock_response( + {"repository": _REPO_RESPONSE} + ) repo = client.get_repository("repo-uuid-1") assert isinstance(repo, Repository) assert repo.name == "TestRepo" - assert client._mock_http.get.call_args[1]["path"] == "/rest/v2/repositories/repo-uuid-1" + assert ( + client._mock_http.get.call_args[1]["path"] + == "/rest/v2/repositories/repo-uuid-1" + ) def test_with_tenant_and_user_claim(self, client): - client._mock_http.get.return_value = _mock_response({ - "repository": _REPO_RESPONSE - }) + client._mock_http.get.return_value = _mock_response( + {"repository": _REPO_RESPONSE} + ) claim = UserClaim(x_ecm_user_enc="bob@sap.com") client.get_repository("repo-uuid-1", tenant="t1", user_claim=claim) @@ -213,6 +227,7 @@ def test_with_tenant_and_user_claim(self, client): # update_repository # --------------------------------------------------------------- + class TestUpdateRepository: def test_basic(self, client): client._mock_http.put.return_value = _mock_response(_REPO_RESPONSE) @@ -252,6 +267,7 @@ def test_with_tenant(self, client): # delete_repository # --------------------------------------------------------------- + class TestDeleteRepository: def test_basic(self, client): client._mock_http.delete.return_value = _mock_response(None, status_code=204) @@ -276,6 +292,7 @@ def test_with_tenant_and_user_claim(self, client): # create_config # --------------------------------------------------------------- + class TestCreateConfig: def test_basic(self, client): client._mock_http.post.return_value = _mock_response(_CONFIG_RESPONSE) @@ -300,7 +317,8 @@ def test_basic(self, client): def test_with_tenant(self, client): client._mock_http.post.return_value = _mock_response(_CONFIG_RESPONSE) request = CreateConfigRequest( - config_name="blockedFileExtensions", config_value="exe", + config_name="blockedFileExtensions", + config_value="exe", ) client.create_config(request, tenant="sub1") @@ -312,6 +330,7 @@ def test_with_tenant(self, client): # get_configs # --------------------------------------------------------------- + class TestGetConfigs: def test_basic(self, client): client._mock_http.get.return_value = _mock_response([_CONFIG_RESPONSE]) @@ -331,7 +350,11 @@ def test_empty_list(self, client): assert configs == [] def test_multiple_configs(self, client): - cfg2 = {**_CONFIG_RESPONSE, "id": "cfg-uuid-2", "configName": "tempspaceMaxContentSize"} + cfg2 = { + **_CONFIG_RESPONSE, + "id": "cfg-uuid-2", + "configName": "tempspaceMaxContentSize", + } client._mock_http.get.return_value = _mock_response([_CONFIG_RESPONSE, cfg2]) configs = client.get_configs() @@ -352,6 +375,7 @@ def test_with_user_claim(self, client): # update_config # --------------------------------------------------------------- + class TestUpdateConfig: def test_basic(self, client): updated = {**_CONFIG_RESPONSE, "configValue": "exe,bat,sh"} @@ -372,7 +396,9 @@ def test_basic(self, client): def test_empty_config_id_raises_value_error(self, client): request = UpdateConfigRequest( - id="x", config_name="n", config_value="v", + id="x", + config_name="n", + config_value="v", ) with pytest.raises(ValueError, match="config_id must not be empty"): @@ -381,7 +407,9 @@ def test_empty_config_id_raises_value_error(self, client): def test_with_tenant(self, client): client._mock_http.put.return_value = _mock_response(_CONFIG_RESPONSE) request = UpdateConfigRequest( - id="cfg-uuid-1", config_name="n", config_value="v", + id="cfg-uuid-1", + config_name="n", + config_value="v", ) client.update_config("cfg-uuid-1", request, tenant="t1") @@ -393,6 +421,7 @@ def test_with_tenant(self, client): # delete_config # --------------------------------------------------------------- + class TestDeleteConfig: def test_basic(self, client): client._mock_http.delete.return_value = _mock_response(None, status_code=204) diff --git a/tests/dms/unit/test_client_cmis.py b/tests/dms/unit/test_client_cmis.py index d71afa7..e814eba 100644 --- a/tests/dms/unit/test_client_cmis.py +++ b/tests/dms/unit/test_client_cmis.py @@ -14,7 +14,15 @@ from sap_cloud_sdk.dms.client import DMSClient, _build_properties, _build_aces from sap_cloud_sdk.dms.model import ( - Ace, Acl, ChildrenPage, CmisObject, DMSCredentials, Document, Folder, UserClaim, + Ace, + Acl, + ChildrenPage, + ChildrenOptions, + CmisObject, + DMSCredentials, + Document, + Folder, + UserClaim, ) @@ -22,6 +30,7 @@ # Helper to wrap a dict in a mock Response with .json() # --------------------------------------------------------------- + def _mock_response(data): """Create a Mock that behaves like requests.Response with .json() returning *data*.""" resp = Mock() @@ -96,8 +105,10 @@ def client(): token_url="https://auth.example.com/oauth/token", identityzone="test-zone", ) - with patch("sap_cloud_sdk.dms.client.Auth"), \ - patch("sap_cloud_sdk.dms.client.HttpInvoker") as mock_http_cls: + with ( + patch("sap_cloud_sdk.dms.client.Auth"), + patch("sap_cloud_sdk.dms.client.HttpInvoker") as mock_http_cls, + ): mock_http = Mock() mock_http_cls.return_value = mock_http c = DMSClient(creds) @@ -110,6 +121,7 @@ def client(): # Helpers # --------------------------------------------------------------- + class TestBuildProperties: def test_single_property(self): result = _build_properties({"cmis:name": "Doc"}) @@ -119,11 +131,13 @@ def test_single_property(self): } def test_multiple_properties(self): - result = _build_properties({ - "cmis:name": "Doc", - "cmis:objectTypeId": "cmis:document", - "cmis:description": "A doc", - }) + result = _build_properties( + { + "cmis:name": "Doc", + "cmis:objectTypeId": "cmis:document", + "cmis:description": "A doc", + } + ) assert result["propertyId[0]"] == "cmis:name" assert result["propertyValue[0]"] == "Doc" assert result["propertyId[1]"] == "cmis:objectTypeId" @@ -177,7 +191,10 @@ def test_no_path(self): assert DMSClient._browser_url("repo1") == "/browser/repo1/root" def test_with_path(self): - assert DMSClient._browser_url("repo1", "sub/folder") == "/browser/repo1/root/sub/folder" + assert ( + DMSClient._browser_url("repo1", "sub/folder") + == "/browser/repo1/root/sub/folder" + ) def test_strips_leading_slash(self): assert DMSClient._browser_url("repo1", "/sub") == "/browser/repo1/root/sub" @@ -190,6 +207,7 @@ def test_none_path_same_as_no_path(self): # create_folder # --------------------------------------------------------------- + class TestCreateFolder: def test_basic(self, client): client._mock_http.post_form.return_value = _mock_response(_FOLDER_RESPONSE) @@ -232,24 +250,28 @@ def test_with_path_and_tenant(self, client): def test_with_user_claim(self, client): client._mock_http.post_form.return_value = _mock_response(_FOLDER_RESPONSE) - claim = UserClaim(x_ecm_user_enc="alice@sap.com", x_ecm_add_principals=["~editors"]) + claim = UserClaim( + x_ecm_user_enc="alice@sap.com", x_ecm_add_principals=["~editors"] + ) client.create_folder("repo1", "parent-id", "F", user_claim=claim) assert client._mock_http.post_form.call_args[1]["user_claim"] is claim - # --------------------------------------------------------------- # create_document # --------------------------------------------------------------- + class TestCreateDocument: def test_basic(self, client): client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) stream = BytesIO(b"hello world") - doc = client.create_document("repo1", "folder-id", "report.pdf", stream, mime_type="application/pdf") + doc = client.create_document( + "repo1", "folder-id", "report.pdf", stream, mime_type="application/pdf" + ) assert isinstance(doc, Document) assert doc.object_id == "doc-xyz" @@ -273,7 +295,14 @@ def test_basic(self, client): def test_with_description(self, client): client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) - client.create_document("repo1", "folder-id", "f.txt", BytesIO(b""), mime_type="text/plain", description="D") + client.create_document( + "repo1", + "folder-id", + "f.txt", + BytesIO(b""), + mime_type="text/plain", + description="D", + ) data = client._mock_http.post_form.call_args[1]["data"] assert data["propertyId[2]"] == "cmis:description" @@ -282,7 +311,9 @@ def test_with_description(self, client): def test_with_tenant(self, client): client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) - client.create_document("repo1", "fid", "f.txt", BytesIO(b""), mime_type="text/plain", tenant="sub1") + client.create_document( + "repo1", "fid", "f.txt", BytesIO(b""), mime_type="text/plain", tenant="sub1" + ) assert client._mock_http.post_form.call_args[1]["tenant_subdomain"] == "sub1" @@ -290,7 +321,14 @@ def test_with_user_claim(self, client): client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) claim = UserClaim(x_ecm_user_enc="bob@sap.com") - client.create_document("repo1", "fid", "f.txt", BytesIO(b""), mime_type="text/plain", user_claim=claim) + client.create_document( + "repo1", + "fid", + "f.txt", + BytesIO(b""), + mime_type="text/plain", + user_claim=claim, + ) assert client._mock_http.post_form.call_args[1]["user_claim"] is claim @@ -308,6 +346,7 @@ def test_without_mime_type_uses_default(self, client): # check_out # --------------------------------------------------------------- + class TestCheckOut: def test_basic(self, client): client._mock_http.post_form.return_value = _mock_response(_PWC_RESPONSE) @@ -342,6 +381,7 @@ def test_with_user_claim(self, client): # check_in # --------------------------------------------------------------- + class TestCheckIn: def test_major_version_no_file(self, client): client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) @@ -368,7 +408,8 @@ def test_with_file_and_comment(self, client): stream = BytesIO(b"updated content") client.check_in( - "repo1", "pwc-001", + "repo1", + "pwc-001", file=stream, file_name="report_v2.pdf", mime_type="application/pdf", @@ -395,7 +436,9 @@ def test_file_without_name_uses_default(self, client): def test_with_user_claim(self, client): client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) - claim = UserClaim(x_ecm_user_enc="eve@sap.com", x_ecm_add_principals=["~reviewers"]) + claim = UserClaim( + x_ecm_user_enc="eve@sap.com", x_ecm_add_principals=["~reviewers"] + ) client.check_in("repo1", "pwc-001", user_claim=claim) @@ -406,6 +449,7 @@ def test_with_user_claim(self, client): # cancel_check_out # --------------------------------------------------------------- + class TestCancelCheckOut: def test_basic(self, client): client._mock_http.post_form.return_value = _mock_response({}) @@ -437,6 +481,7 @@ def test_with_user_claim(self, client): # apply_acl # --------------------------------------------------------------- + class TestApplyAcl: def test_get_acl_when_no_aces(self, client): """No add/remove => GET current ACL.""" @@ -554,6 +599,7 @@ def test_custom_acl_propagation(self, client): # get_object # --------------------------------------------------------------- + class TestGetObject: def test_returns_folder_for_folder_type(self, client): client._mock_http.get.return_value = _mock_response(_FOLDER_RESPONSE) @@ -581,14 +627,16 @@ def test_returns_document_for_document_type(self, client): assert obj.content_stream_mime_type == "application/pdf" def test_returns_cmis_object_for_unknown_type(self, client): - client._mock_http.get.return_value = _mock_response({ - "succinctProperties": { - "cmis:objectId": "item-1", - "cmis:name": "Item", - "cmis:baseTypeId": "cmis:item", - "cmis:objectTypeId": "cmis:item", + client._mock_http.get.return_value = _mock_response( + { + "succinctProperties": { + "cmis:objectId": "item-1", + "cmis:name": "Item", + "cmis:baseTypeId": "cmis:item", + "cmis:objectTypeId": "cmis:item", + } } - }) + ) obj = client.get_object("repo1", "item-1") @@ -645,6 +693,7 @@ def test_with_tenant_and_user_claim(self, client): # get_content # --------------------------------------------------------------- + class TestGetContent: def test_basic(self, client): mock_resp = Mock() @@ -729,6 +778,7 @@ def test_no_stream_id_or_filename_by_default(self, client): # update_properties # --------------------------------------------------------------- + class TestUpdateProperties: def test_basic_rename(self, client): resp = { @@ -760,14 +810,18 @@ def test_basic_rename(self, client): def test_returns_folder_for_folder_type(self, client): client._mock_http.post_form.return_value = _mock_response(_FOLDER_RESPONSE) - obj = client.update_properties("repo1", "folder-abc", {"cmis:description": "Updated"}) + obj = client.update_properties( + "repo1", "folder-abc", {"cmis:description": "Updated"} + ) assert isinstance(obj, Folder) def test_with_change_token(self, client): client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) - client.update_properties("repo1", "doc-xyz", {"cmis:name": "X"}, change_token="tok-123") + client.update_properties( + "repo1", "doc-xyz", {"cmis:name": "X"}, change_token="tok-123" + ) data = client._mock_http.post_form.call_args[1]["data"] assert data["changeToken"] == "tok-123" @@ -783,10 +837,14 @@ def test_no_change_token_by_default(self, client): def test_multiple_properties(self, client): client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) - client.update_properties("repo1", "doc-xyz", { - "cmis:name": "New", - "cmis:description": "Desc", - }) + client.update_properties( + "repo1", + "doc-xyz", + { + "cmis:name": "New", + "cmis:description": "Desc", + }, + ) data = client._mock_http.post_form.call_args[1]["data"] assert data["propertyId[0]"] == "cmis:name" @@ -798,7 +856,9 @@ def test_with_tenant_and_user_claim(self, client): client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) claim = UserClaim(x_ecm_user_enc="charlie@sap.com") - client.update_properties("repo1", "doc-xyz", {"cmis:name": "X"}, tenant="t1", user_claim=claim) + client.update_properties( + "repo1", "doc-xyz", {"cmis:name": "X"}, tenant="t1", user_claim=claim + ) call_args = client._mock_http.post_form.call_args assert call_args[1]["tenant_subdomain"] == "t1" @@ -867,42 +927,58 @@ def test_basic(self, client): assert params["skipCount"] == "0" assert params["succinct"] == "true" - def test_pagination_params(self, client): - client._mock_http.get.return_value = _mock_response({"objects": [], "hasMoreItems": False}) + def test_pagination_params_via_options(self, client): + client._mock_http.get.return_value = _mock_response( + {"objects": [], "hasMoreItems": False} + ) - client.get_children("repo1", "fid", max_items=50, skip_count=200) + opts = ChildrenOptions(max_items=50, skip_count=200) + client.get_children("repo1", "fid", options=opts) params = client._mock_http.get.call_args[1]["params"] assert params["maxItems"] == "50" assert params["skipCount"] == "200" def test_with_order_by(self, client): - client._mock_http.get.return_value = _mock_response({"objects": [], "hasMoreItems": False}) + client._mock_http.get.return_value = _mock_response( + {"objects": [], "hasMoreItems": False} + ) - client.get_children("repo1", "fid", order_by="cmis:creationDate ASC") + opts = ChildrenOptions(order_by="cmis:creationDate ASC") + client.get_children("repo1", "fid", options=opts) params = client._mock_http.get.call_args[1]["params"] assert params["orderBy"] == "cmis:creationDate ASC" def test_with_filter(self, client): - client._mock_http.get.return_value = _mock_response({"objects": [], "hasMoreItems": False}) + client._mock_http.get.return_value = _mock_response( + {"objects": [], "hasMoreItems": False} + ) - client.get_children("repo1", "fid", filter="cmis:name,cmis:objectId") + opts = ChildrenOptions(filter="cmis:name,cmis:objectId") + client.get_children("repo1", "fid", options=opts) params = client._mock_http.get.call_args[1]["params"] assert params["filter"] == "cmis:name,cmis:objectId" def test_with_include_allowable_and_path_segment(self, client): - client._mock_http.get.return_value = _mock_response({"objects": [], "hasMoreItems": False}) + client._mock_http.get.return_value = _mock_response( + {"objects": [], "hasMoreItems": False} + ) - client.get_children("repo1", "fid", include_allowable_actions=True, include_path_segment=True) + opts = ChildrenOptions( + include_allowable_actions=True, include_path_segment=True + ) + client.get_children("repo1", "fid", options=opts) params = client._mock_http.get.call_args[1]["params"] assert params["includeAllowableActions"] == "true" assert params["includePathSegment"] == "true" def test_no_optional_params_omitted(self, client): - client._mock_http.get.return_value = _mock_response({"objects": [], "hasMoreItems": False}) + client._mock_http.get.return_value = _mock_response( + {"objects": [], "hasMoreItems": False} + ) client.get_children("repo1", "fid") @@ -913,7 +989,9 @@ def test_no_optional_params_omitted(self, client): assert "includePathSegment" not in params def test_empty_children(self, client): - client._mock_http.get.return_value = _mock_response({"objects": [], "hasMoreItems": False, "numItems": 0}) + client._mock_http.get.return_value = _mock_response( + {"objects": [], "hasMoreItems": False, "numItems": 0} + ) page = client.get_children("repo1", "fid") @@ -922,7 +1000,9 @@ def test_empty_children(self, client): assert page.num_items == 0 def test_with_tenant_and_user_claim(self, client): - client._mock_http.get.return_value = _mock_response({"objects": [], "hasMoreItems": False}) + client._mock_http.get.return_value = _mock_response( + {"objects": [], "hasMoreItems": False} + ) claim = UserClaim(x_ecm_user_enc="dana@sap.com") client.get_children("repo1", "fid", tenant="t1", user_claim=claim) diff --git a/tests/dms/unit/test_cmis_models.py b/tests/dms/unit/test_cmis_models.py index 87daebb..a5716eb 100644 --- a/tests/dms/unit/test_cmis_models.py +++ b/tests/dms/unit/test_cmis_models.py @@ -2,7 +2,6 @@ from datetime import datetime, timezone -import pytest from sap_cloud_sdk.dms.model import ( Ace, @@ -20,6 +19,7 @@ # Helpers # --------------------------------------------------------------- + class TestParseCmisDatetime: def test_none_returns_none(self): assert _parse_cmis_datetime(None) is None @@ -157,6 +157,7 @@ def test_from_dict_prefers_succinct_over_properties(self): # Folder # --------------------------------------------------------------- + class TestFolder: def test_from_dict(self): folder = Folder.from_dict(_SUCCINCT_FOLDER) @@ -174,6 +175,7 @@ def test_from_dict_empty(self): # Document # --------------------------------------------------------------- + class TestDocument: def test_from_dict_full(self): doc = Document.from_dict(_SUCCINCT_DOCUMENT) @@ -219,6 +221,7 @@ def test_from_dict_empty(self): # Ace # --------------------------------------------------------------- + class TestAce: def test_from_dict(self): data = { @@ -256,6 +259,7 @@ def test_constructor(self): # Acl # --------------------------------------------------------------- + class TestAcl: def test_from_dict(self): data = { diff --git a/tests/dms/unit/test_http_invoker.py b/tests/dms/unit/test_http_invoker.py index 5695e18..9620300 100644 --- a/tests/dms/unit/test_http_invoker.py +++ b/tests/dms/unit/test_http_invoker.py @@ -37,6 +37,7 @@ def invoker(mock_auth): # Header helpers # --------------------------------------------------------------- + class TestHeaders: def test_auth_header(self, invoker): headers = invoker._auth_header() @@ -62,6 +63,7 @@ def test_merged_headers_applies_overrides(self, invoker): # GET # --------------------------------------------------------------- + class TestGet: @patch("sap_cloud_sdk.dms._http.requests.get") def test_get_basic(self, mock_get, invoker): @@ -188,6 +190,7 @@ def test_get_timeout_error(self, mock_get, invoker): # Error message extraction # --------------------------------------------------------------- + class TestErrorMessageExtraction: @patch("sap_cloud_sdk.dms._http.requests.get") def test_400_extracts_json_message(self, mock_get, invoker): @@ -214,7 +217,9 @@ def test_400_fallback_when_no_json(self, mock_get, invoker): with pytest.raises(DMSInvalidArgumentException) as exc_info: invoker.get("/bad") - assert "Request contains invalid or disallowed parameters" in str(exc_info.value) + assert "Request contains invalid or disallowed parameters" in str( + exc_info.value + ) @patch("sap_cloud_sdk.dms._http.requests.get") def test_404_extracts_json_message(self, mock_get, invoker): @@ -261,6 +266,7 @@ def test_409_fallback_when_no_json(self, mock_get, invoker): # POST (form-encoded) # --------------------------------------------------------------- + class TestPostForm: @patch("sap_cloud_sdk.dms._http.requests.post") def test_post_form_basic(self, mock_post, invoker): @@ -344,6 +350,7 @@ def test_post_form_204_returns_response(self, mock_post, invoker): # Base URL stripping # --------------------------------------------------------------- + class TestBaseUrl: def test_trailing_slash_stripped(self, mock_auth): inv = HttpInvoker( @@ -357,6 +364,7 @@ def test_trailing_slash_stripped(self, mock_auth): # get_stream # --------------------------------------------------------------- + class TestGetStream: @patch("sap_cloud_sdk.dms._http.requests.get") def test_returns_raw_response(self, mock_get, invoker): @@ -365,7 +373,9 @@ def test_returns_raw_response(self, mock_get, invoker): mock_resp.content = b"binary content" mock_get.return_value = mock_resp - result = invoker.get_stream("/browser/repo1/root", params={"objectId": "d1", "cmisselector": "content"}) + result = invoker.get_stream( + "/browser/repo1/root", params={"objectId": "d1", "cmisselector": "content"} + ) assert result is mock_resp mock_get.assert_called_once() @@ -382,7 +392,10 @@ def test_raises_on_error(self, mock_get, invoker): mock_get.return_value = mock_resp with pytest.raises(DMSObjectNotFoundException) as exc_info: - invoker.get_stream("/browser/repo1/root", params={"objectId": "d1", "cmisselector": "content"}) + invoker.get_stream( + "/browser/repo1/root", + params={"objectId": "d1", "cmisselector": "content"}, + ) assert exc_info.value.status_code == 404 @patch("sap_cloud_sdk.dms._http.requests.get") From 80e0dff58671aacb36b13f7e66fea11fac1122c3 Mon Sep 17 00:00:00 2001 From: Karan Shukla Date: Wed, 8 Apr 2026 22:22:14 +0700 Subject: [PATCH 20/24] fix(dms): move ty:ignore comments to correct lines in test_dms_bdd --- tests/dms/integration/test_dms_bdd.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/dms/integration/test_dms_bdd.py b/tests/dms/integration/test_dms_bdd.py index 5177e20..15f0468 100644 --- a/tests/dms/integration/test_dms_bdd.py +++ b/tests/dms/integration/test_dms_bdd.py @@ -378,8 +378,8 @@ def get_object_with_acl(context: DMSTestContext, dms_client: DMSClient): try: context.retrieved_object = dms_client.get_object( context.repo_id, - context.document.object_id, - include_acl=True, # ty: ignore[unresolved-attribute] + context.document.object_id, # ty: ignore[unresolved-attribute] + include_acl=True, ) context.operation_success = True except Exception as e: @@ -392,8 +392,8 @@ def download_content(context: DMSTestContext, dms_client: DMSClient): try: context.content_response = dms_client.get_content( context.repo_id, - context.document.object_id, - download="attachment", # ty: ignore[unresolved-attribute] + context.document.object_id, # ty: ignore[unresolved-attribute] + download="attachment", ) context.operation_success = True except Exception as e: From 3c8d970e1b2b62129f30ae40db471384bef2f3ae Mon Sep 17 00:00:00 2001 From: Karan Shukla Date: Wed, 8 Apr 2026 23:14:00 +0700 Subject: [PATCH 21/24] docs(dms): add docstrings to create_client() and DMSClient.__init__() --- src/sap_cloud_sdk/dms/__init__.py | 13 +++++++++++++ src/sap_cloud_sdk/dms/client.py | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/src/sap_cloud_sdk/dms/__init__.py b/src/sap_cloud_sdk/dms/__init__.py index 3b6991b..53119d1 100644 --- a/src/sap_cloud_sdk/dms/__init__.py +++ b/src/sap_cloud_sdk/dms/__init__.py @@ -40,6 +40,19 @@ def create_client( dms_cred: Optional[DMSCredentials] = None, _telemetry_source: Optional[Module] = None, ): + """Create a DMS client with automatic credential resolution. + + Args: + instance: Logical instance name for secret resolution. Defaults to ``"default"``. + dms_cred: Explicit credentials. If provided, skips secret resolution. + _telemetry_source: Internal telemetry source identifier. Not intended for external use. + + Returns: + DMSClient: Configured client ready to use. + + Raises: + DMSError: If client creation fails due to configuration or initialization issues. + """ try: credentials = dms_cred or load_sdm_config_from_env_or_mount(instance) client = DMSClient(credentials) diff --git a/src/sap_cloud_sdk/dms/client.py b/src/sap_cloud_sdk/dms/client.py index 13c99b9..a1da63b 100644 --- a/src/sap_cloud_sdk/dms/client.py +++ b/src/sap_cloud_sdk/dms/client.py @@ -87,6 +87,13 @@ def __init__( connect_timeout: Optional[int] = None, read_timeout: Optional[int] = None, ) -> None: + """Initialise a DMSClient. + + Args: + credentials: OAuth2 credentials and service URI for the DMS instance. + connect_timeout: TCP connection timeout in seconds. Defaults to 10. + read_timeout: Response read timeout in seconds. Defaults to 30. + """ auth = Auth(credentials) self._http: HttpInvoker = HttpInvoker( auth=auth, From d4e31cb63437a8a70f68d5ccb6cc73dba174343e Mon Sep 17 00:00:00 2001 From: Jagnath Reddy Date: Thu, 9 Apr 2026 03:24:03 +0530 Subject: [PATCH 22/24] FIX: added warning for direct client creation and completed flow for timeouts --- src/sap_cloud_sdk/dms/__init__.py | 10 +++++++++- src/sap_cloud_sdk/dms/client.py | 5 +++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/sap_cloud_sdk/dms/__init__.py b/src/sap_cloud_sdk/dms/__init__.py index 53119d1..425ca24 100644 --- a/src/sap_cloud_sdk/dms/__init__.py +++ b/src/sap_cloud_sdk/dms/__init__.py @@ -38,6 +38,8 @@ def create_client( *, instance: Optional[str] = None, dms_cred: Optional[DMSCredentials] = None, + connect_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, _telemetry_source: Optional[Module] = None, ): """Create a DMS client with automatic credential resolution. @@ -45,6 +47,8 @@ def create_client( Args: instance: Logical instance name for secret resolution. Defaults to ``"default"``. dms_cred: Explicit credentials. If provided, skips secret resolution. + connect_timeout: TCP connection timeout in seconds. Defaults to 10. + read_timeout: Response read timeout in seconds. Defaults to 30. _telemetry_source: Internal telemetry source identifier. Not intended for external use. Returns: @@ -55,7 +59,11 @@ def create_client( """ try: credentials = dms_cred or load_sdm_config_from_env_or_mount(instance) - client = DMSClient(credentials) + client = DMSClient( + credentials, + connect_timeout=connect_timeout, + read_timeout=read_timeout, + ) client._telemetry_source = _telemetry_source return client except Exception as e: diff --git a/src/sap_cloud_sdk/dms/client.py b/src/sap_cloud_sdk/dms/client.py index a1da63b..2c9a6ea 100644 --- a/src/sap_cloud_sdk/dms/client.py +++ b/src/sap_cloud_sdk/dms/client.py @@ -89,6 +89,11 @@ def __init__( ) -> None: """Initialise a DMSClient. + Note: + Do not call this constructor directly. Use create_client() from + sap_cloud_sdk.dms instead, which properly configures + authentication and handles environment detection. + Args: credentials: OAuth2 credentials and service URI for the DMS instance. connect_timeout: TCP connection timeout in seconds. Defaults to 10. From aa0ee551e0b10890456227373b78c61f3ea57fef Mon Sep 17 00:00:00 2001 From: Karan Shukla Date: Fri, 10 Apr 2026 14:15:47 +0700 Subject: [PATCH 23/24] feat(dms): add delete_object, restore_object, append_content_stream, cmis_query and refactored the init.py similar to destinations --- src/sap_cloud_sdk/core/telemetry/operation.py | 6 + src/sap_cloud_sdk/dms/__init__.py | 31 +- src/sap_cloud_sdk/dms/client.py | 210 +++++++++++++- src/sap_cloud_sdk/dms/model.py | 73 +++++ tests/core/unit/telemetry/test_operation.py | 30 +- tests/dms/integration/dms.feature | 61 ++++ tests/dms/integration/test_dms_bdd.py | 174 +++++++++++- tests/dms/unit/test_client_cmis.py | 268 ++++++++++++++++++ tests/dms/unit/test_cmis_models.py | 121 ++++++++ 9 files changed, 958 insertions(+), 16 deletions(-) diff --git a/src/sap_cloud_sdk/core/telemetry/operation.py b/src/sap_cloud_sdk/core/telemetry/operation.py index db6c781..09c64da 100644 --- a/src/sap_cloud_sdk/core/telemetry/operation.py +++ b/src/sap_cloud_sdk/core/telemetry/operation.py @@ -74,6 +74,12 @@ class Operation(str, Enum): DMS_GET_CONTENT = "get_content" DMS_UPDATE_PROPERTIES = "update_properties" DMS_GET_CHILDREN = "get_children" + # Value is "delete_cmis_object" (not "delete_object") to avoid collision + # with OBJECTSTORE_DELETE_OBJECT which already uses "delete_object". + DMS_DELETE_OBJECT = "delete_cmis_object" + DMS_RESTORE_OBJECT = "restore_object" + DMS_APPEND_CONTENT_STREAM = "append_content_stream" + DMS_CMIS_QUERY = "cmis_query" def __str__(self) -> str: return self.value diff --git a/src/sap_cloud_sdk/dms/__init__.py b/src/sap_cloud_sdk/dms/__init__.py index 425ca24..33fdf2f 100644 --- a/src/sap_cloud_sdk/dms/__init__.py +++ b/src/sap_cloud_sdk/dms/__init__.py @@ -28,7 +28,19 @@ from typing import Optional from sap_cloud_sdk.core.telemetry import Module -from sap_cloud_sdk.dms.model import DMSCredentials +from sap_cloud_sdk.dms.model import ( + Ace, + Acl, + ChildrenOptions, + ChildrenPage, + CmisObject, + DMSCredentials, + Document, + Folder, + QueryOptions, + QueryResultPage, + UserClaim, +) from sap_cloud_sdk.dms.client import DMSClient from sap_cloud_sdk.dms.config import load_sdm_config_from_env_or_mount from sap_cloud_sdk.dms.exceptions import DMSError @@ -70,4 +82,19 @@ def create_client( raise DMSError(f"Failed to create DMS client: {e}") from e -__all__ = ["create_client"] +__all__ = [ + "create_client", + "Ace", + "Acl", + "ChildrenOptions", + "ChildrenPage", + "CmisObject", + "DMSClient", + "DMSCredentials", + "DMSError", + "Document", + "Folder", + "QueryOptions", + "QueryResultPage", + "UserClaim", +] diff --git a/src/sap_cloud_sdk/dms/client.py b/src/sap_cloud_sdk/dms/client.py index 2c9a6ea..f6f69ce 100644 --- a/src/sap_cloud_sdk/dms/client.py +++ b/src/sap_cloud_sdk/dms/client.py @@ -17,6 +17,8 @@ CmisObject, Document, Folder, + QueryOptions, + QueryResultPage, _prop_val, ) from sap_cloud_sdk.dms._auth import Auth @@ -67,7 +69,8 @@ class DMSClient: manage repository configurations. - **CMIS Browser Binding**: Create folders and documents, manage versions (check-out / check-in), apply ACLs, browse folder - contents, and download document content. + contents, download document content, delete and restore objects, + append content streams, and execute CMIS queries. Use :func:`sap_cloud_sdk.dms.create_client` to obtain an instance with automatic credential resolution, or construct directly with @@ -1006,3 +1009,208 @@ def get_children( user_claim=user_claim, ) return ChildrenPage.from_dict(response.json()) + + # ================================================================== + # CMIS — delete / restore operations + # ================================================================== + + @record_metrics(Module.DMS, Operation.DMS_DELETE_OBJECT) + def delete_object( + self, + repository_id: str, + object_id: str, + *, + all_versions: bool = True, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> None: + """Delete a CMIS object (document or folder). + + Args: + repository_id: Target repository ID. + object_id: CMIS objectId of the object to delete. + all_versions: If True, delete all versions of the document. + Defaults to True. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims forwarded to DMS. + + Raises: + DMSObjectNotFoundException: If the object is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + form_data: Dict[str, str] = { + "cmisaction": "delete", + "objectId": object_id, + "allVersions": str(all_versions).lower(), + "_charset_": "UTF-8", + } + logger.info( + "Deleting object '%s' from repo '%s'", object_id, repository_id + ) + self._http.post_form( + self._browser_url(repository_id), + data=form_data, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + + @record_metrics(Module.DMS, Operation.DMS_RESTORE_OBJECT) + def restore_object( + self, + repository_id: str, + object_id: str, + *, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> str: + """Restore a previously deleted object. + + Args: + repository_id: Target repository ID. + object_id: CMIS objectId of the deleted object. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims forwarded to DMS. + + Returns: + str: The server message confirming the restore. + + Raises: + DMSObjectNotFoundException: If the object is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + path = f"{_REPOSITORIES}/{repository_id}/deleted/objects/{object_id}/restore" + logger.info( + "Restoring object '%s' in repo '%s'", object_id, repository_id + ) + response = self._http.post( + path=path, + payload={}, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return response.json().get("message", "") + + # ================================================================== + # CMIS — content stream operations + # ================================================================== + + @record_metrics(Module.DMS, Operation.DMS_APPEND_CONTENT_STREAM) + def append_content_stream( + self, + repository_id: str, + document_id: str, + file: BinaryIO, + *, + is_last_chunk: bool = False, + filename: Optional[str] = None, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> Document: + """Append content to an existing document's content stream. + + Args: + repository_id: Target repository ID. + document_id: Document CMIS objectId. + file: Readable binary stream with the content to append. + is_last_chunk: True if this is the final chunk of a + multi-part upload. Defaults to False. + filename: Optional file name for the content part. + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims forwarded to DMS. + + Returns: + Document: The updated document. + + Raises: + DMSObjectNotFoundException: If the document is not found. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + """ + form_data: Dict[str, str] = { + "cmisaction": "appendContent", + "objectId": document_id, + "succinct": "true", + "isLastChunk": str(is_last_chunk).lower(), + "_charset_": "UTF-8", + } + logger.info( + "Appending content to document '%s' in repo '%s'", + document_id, + repository_id, + ) + response = self._http.post_form( + self._browser_url(repository_id), + data=form_data, + files={ + "media": ( + filename or "content", + file, + "application/octet-stream", + ) + }, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return Document.from_dict(response.json()) + + # ================================================================== + # CMIS — query operations + # ================================================================== + + @record_metrics(Module.DMS, Operation.DMS_CMIS_QUERY) + def cmis_query( + self, + repository_id: str, + statement: str, + *, + options: Optional[QueryOptions] = None, + tenant: Optional[str] = None, + user_claim: Optional[UserClaim] = None, + ) -> QueryResultPage: + """Execute a CMIS SQL query against a repository. + + Args: + repository_id: Target repository ID. + statement: CMIS-QL query string (e.g. + ``"SELECT * FROM cmis:document WHERE cmis:name = 'test.pdf'"``). + options: Pagination options. Defaults to ``QueryOptions()`` + (max 100 items, no skip). + tenant: Optional subscriber subdomain. + user_claim: Optional user identity claims forwarded to DMS. + + Returns: + QueryResultPage: Paginated query results. + + Raises: + DMSInvalidArgumentException: If the query is malformed. + DMSPermissionDeniedException: If the access token is invalid. + DMSRuntimeException: If the server encounters an internal error. + + Example:: + + from sap_cloud_sdk.dms import create_client, QueryOptions + + client = create_client() + page = client.cmis_query( + repo_id, + "SELECT * FROM cmis:document WHERE cmis:name LIKE 'report%'", + options=QueryOptions(max_items=50), + ) + """ + opts = options or QueryOptions() + params: Dict[str, str] = { + "cmisselector": "query", + "q": statement, + } + params.update(opts.to_query_params()) + + logger.info("Executing CMIS query in repo '%s'", repository_id) + response = self._http.get( + f"/browser/{repository_id}", + params=params, + tenant_subdomain=tenant, + user_claim=user_claim, + ) + return QueryResultPage.from_dict(response.json()) diff --git a/src/sap_cloud_sdk/dms/model.py b/src/sap_cloud_sdk/dms/model.py index c628f1f..f84a7a5 100644 --- a/src/sap_cloud_sdk/dms/model.py +++ b/src/sap_cloud_sdk/dms/model.py @@ -601,3 +601,76 @@ def from_dict(cls, data: Dict[str, Any]) -> "ChildrenPage": has_more_items=data.get("hasMoreItems", False), num_items=data.get("numItems"), ) + + +@dataclass +class QueryOptions: + """Pagination and search options for :meth:`DMSClient.cmis_query`. + + Example: + ```python + from sap_cloud_sdk.dms import create_client, QueryOptions + + client = create_client() + opts = QueryOptions(max_items=50, search_all_versions=True) + page = client.cmis_query(repo_id, "SELECT * FROM cmis:document", options=opts) + while page.has_more_items: + opts.skip_count += opts.max_items + page = client.cmis_query(repo_id, "SELECT * FROM cmis:document", options=opts) + ``` + + Attributes: + max_items: Maximum number of results to return per page (default 100). + skip_count: Number of results to skip (pagination offset, default 0). + search_all_versions: Search all versions, not just latest (default False). + """ + + max_items: int = 100 + skip_count: int = 0 + search_all_versions: bool = False + + def to_query_params(self) -> Dict[str, str]: + """Convert options to CMIS query parameters. + + Returns: + Dict[str, str]: Query parameters for the HTTP request. + """ + params: Dict[str, str] = { + "maxItems": str(self.max_items), + "skipCount": str(self.skip_count), + } + if self.search_all_versions: + params["searchAllVersions"] = "true" + return params + + +@dataclass +class QueryResultPage: + """Paginated result from a CMIS query request. + + Query results use verbose property format where each property is + ``{"cmis:name": {"value": "MyDoc"}}`` rather than succinct format. + """ + + results: List[CmisObject] = field(default_factory=list) + has_more_items: bool = False + num_items: Optional[int] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "QueryResultPage": + raw_results = data.get("results") or [] + parsed: List[CmisObject] = [] + for entry in raw_results: + props = entry.get("properties") or entry.get("succinctProperties") or {} + base_type = _prop_val(props, "cmis:baseTypeId") or "" + if base_type == "cmis:document": + parsed.append(Document.from_dict(entry)) + elif base_type == "cmis:folder": + parsed.append(Folder.from_dict(entry)) + else: + parsed.append(CmisObject.from_dict(entry)) + return cls( + results=parsed, + has_more_items=data.get("hasMoreItems", False), + num_items=data.get("numItems"), + ) diff --git a/tests/core/unit/telemetry/test_operation.py b/tests/core/unit/telemetry/test_operation.py index 31d9b02..4ba3323 100644 --- a/tests/core/unit/telemetry/test_operation.py +++ b/tests/core/unit/telemetry/test_operation.py @@ -59,6 +59,32 @@ def test_aicore_operations(self): assert Operation.AICORE_SET_CONFIG.value == "set_aicore_config" assert Operation.AICORE_AUTO_INSTRUMENT.value == "auto_instrument" + def test_dms_operations(self): + """Test DMS operation values.""" + assert Operation.DMS_ONBOARD_REPOSITORY.value == "onboard_repository" + assert Operation.DMS_GET_REPOSITORY.value == "get_repository" + assert Operation.DMS_GET_ALL_REPOSITORIES.value == "get_all_repositories" + assert Operation.DMS_UPDATE_REPOSITORY.value == "update_repository" + assert Operation.DMS_DELETE_REPOSITORY.value == "delete_repository" + assert Operation.DMS_CREATE_CONFIG.value == "create_config" + assert Operation.DMS_GET_CONFIGS.value == "get_configs" + assert Operation.DMS_UPDATE_CONFIG.value == "update_config" + assert Operation.DMS_DELETE_CONFIG.value == "delete_config" + assert Operation.DMS_CREATE_FOLDER.value == "create_folder" + assert Operation.DMS_CREATE_DOCUMENT.value == "create_document" + assert Operation.DMS_CHECK_OUT.value == "check_out" + assert Operation.DMS_CHECK_IN.value == "check_in" + assert Operation.DMS_CANCEL_CHECK_OUT.value == "cancel_check_out" + assert Operation.DMS_APPLY_ACL.value == "apply_acl" + assert Operation.DMS_GET_OBJECT.value == "get_object" + assert Operation.DMS_GET_CONTENT.value == "get_content" + assert Operation.DMS_UPDATE_PROPERTIES.value == "update_properties" + assert Operation.DMS_GET_CHILDREN.value == "get_children" + assert Operation.DMS_DELETE_OBJECT.value == "delete_cmis_object" + assert Operation.DMS_RESTORE_OBJECT.value == "restore_object" + assert Operation.DMS_APPEND_CONTENT_STREAM.value == "append_content_stream" + assert Operation.DMS_CMIS_QUERY.value == "cmis_query" + def test_operation_str_representation(self): """Test that Operation enum converts to string correctly.""" assert str(Operation.AUDITLOG_LOG) == "log" @@ -105,5 +131,5 @@ def test_operation_iteration(self): def test_operation_count(self): """Test that we have the expected number of operations.""" all_operations = list(Operation) - # 2 auditlog + 8 destination + 7 certificate + 7 fragment + 8 objectstore + 2 aicore + 18 dms = 52 - assert len(all_operations) == 52 + # 2 auditlog + 8 destination + 7 certificate + 7 fragment + 8 objectstore + 2 aicore + 22 dms = 56 + assert len(all_operations) == 56 diff --git a/tests/dms/integration/dms.feature b/tests/dms/integration/dms.feature index 8067903..2568b3f 100644 --- a/tests/dms/integration/dms.feature +++ b/tests/dms/integration/dms.feature @@ -191,3 +191,64 @@ Feature: Document Management Service Integration Given I select the first available repository When I attempt to download a non-existent document Then the operation should fail with a not found error + + # ==================== Delete & Restore Operations ==================== + + Scenario: Delete a document + Given I select the first available repository + And I use the root folder as parent + And I have document content "Delete me!" + And I upload a document named "sdk-delete-test.txt" with mime type "text/plain" + When I delete the document + Then the delete should be successful + When I attempt to get the deleted document + Then the operation should fail with a not found error + + Scenario: Delete and restore a document + Given I select the first available repository + And I use the root folder as parent + And I have document content "Delete and restore me!" + And I upload a document named "sdk-restore-test.txt" with mime type "text/plain" + When I delete the document + Then the delete should be successful + When I restore the deleted document + Then the restore should be successful + And I clean up the created document + + # ==================== Append Content Stream ==================== + + Scenario: Append content to a document + Given I select the first available repository + And I use the root folder as parent + And I have document content "Initial content" + And I upload a document named "sdk-append-test.txt" with mime type "text/plain" + When I append content "Additional content" to the document + Then the append should be successful + And the appended document should be a Document + And I clean up the created document + + Scenario: Append content as last chunk + Given I select the first available repository + And I use the root folder as parent + And I have document content "Base content" + And I upload a document named "sdk-append-last-test.txt" with mime type "text/plain" + When I append content "Final chunk" as the last chunk + Then the append should be successful + And I clean up the created document + + # ==================== CMIS Query ==================== + + Scenario: Execute a simple CMIS query + Given I select the first available repository + And I use the root folder as parent + And I have document content "Query test content" + And I upload a document named "sdk-query-test.txt" with mime type "text/plain" + When I execute a CMIS query for documents named "sdk-query-test" + Then the query should be successful + And the query results should contain at least 1 item + And I clean up the created document + + Scenario: Execute a CMIS query with pagination + Given I select the first available repository + When I execute a CMIS query for all documents with max items 5 + Then the query should be successful diff --git a/tests/dms/integration/test_dms_bdd.py b/tests/dms/integration/test_dms_bdd.py index 15f0468..482b173 100644 --- a/tests/dms/integration/test_dms_bdd.py +++ b/tests/dms/integration/test_dms_bdd.py @@ -18,6 +18,8 @@ CreateConfigRequest, Document, Folder, + QueryOptions, + QueryResultPage, Repository, RepositoryConfig, ) @@ -62,6 +64,10 @@ def __init__(self): self.child_doc_id: Optional[str] = None self._config_request: Optional[CreateConfigRequest] = None self._expected_updated_name: str = "" + self.restore_message: Optional[str] = None + self.appended_document: Optional[Document] = None + self.query_result: Optional[QueryResultPage] = None + self._deleted_doc_id: Optional[str] = None @pytest.fixture @@ -827,17 +833,8 @@ def cleanup_children_folder(context: DMSTestContext, dms_client: DMSClient): def _delete_cmis_object(client: DMSClient, repo_id: str, object_id: str): - """Delete a CMIS object using the update properties endpoint (CMIS delete action).""" - # Use the HTTP invoker directly to perform a CMIS delete - form_data = { - "cmisaction": "delete", - "objectId": object_id, - "_charset_": "UTF-8", - } - client._http.post_form( - client._browser_url(repo_id), - data=form_data, - ) + """Delete a CMIS object via the SDK's delete_object method.""" + client.delete_object(repo_id, object_id) def _remove_from_cleanup(context: DMSTestContext, object_id: str): @@ -845,3 +842,158 @@ def _remove_from_cleanup(context: DMSTestContext, object_id: str): context.cleanup_objects = [ (r, o) for r, o in context.cleanup_objects if o != object_id ] + + +# ==================== DELETE / RESTORE: WHEN ==================== + + +@when("I delete the document") +def delete_document(context: DMSTestContext, dms_client: DMSClient): + """Delete the uploaded document.""" + try: + dms_client.delete_object( + context.repo_id, + context.document.object_id, # ty: ignore[unresolved-attribute] + ) + context._deleted_doc_id = context.document.object_id # ty: ignore[unresolved-attribute] + _remove_from_cleanup(context, context.document.object_id) # ty: ignore[unresolved-attribute] + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when("I attempt to get the deleted document") +def get_deleted_document(context: DMSTestContext, dms_client: DMSClient): + """Try to get the previously deleted document.""" + context.operation_error = None + try: + dms_client.get_object(context.repo_id, context._deleted_doc_id) # ty: ignore[invalid-argument-type] + context.operation_success = True + except DMSObjectNotFoundException as e: + context.operation_error = e + except DMSError as e: + context.operation_error = e + + +@when("I restore the deleted document") +def restore_document(context: DMSTestContext, dms_client: DMSClient): + """Restore the previously deleted document.""" + context.operation_error = None + try: + context.restore_message = dms_client.restore_object( + context.repo_id, + context._deleted_doc_id, # ty: ignore[invalid-argument-type] + ) + # Re-add to cleanup since it's restored + context.cleanup_objects.append((context.repo_id, context._deleted_doc_id)) # ty: ignore[invalid-argument-type] + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== APPEND CONTENT: WHEN ==================== + + +@when(parsers.parse('I append content "{content}" to the document')) +def append_content(context: DMSTestContext, dms_client: DMSClient, content: str): + """Append content to the uploaded document.""" + try: + context.appended_document = dms_client.append_content_stream( + context.repo_id, + context.document.object_id, # ty: ignore[unresolved-attribute] + io.BytesIO(content.encode("utf-8")), + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when(parsers.parse('I append content "{content}" as the last chunk')) +def append_last_chunk(context: DMSTestContext, dms_client: DMSClient, content: str): + """Append content as the last chunk.""" + try: + context.appended_document = dms_client.append_content_stream( + context.repo_id, + context.document.object_id, # ty: ignore[unresolved-attribute] + io.BytesIO(content.encode("utf-8")), + is_last_chunk=True, + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== CMIS QUERY: WHEN ==================== + + +@when(parsers.parse('I execute a CMIS query for documents named "{name_prefix}"')) +def cmis_query_by_name(context: DMSTestContext, dms_client: DMSClient, name_prefix: str): + """Execute a CMIS query filtering by document name.""" + try: + statement = f"SELECT * FROM cmis:document WHERE cmis:name LIKE '{name_prefix}%'" + context.query_result = dms_client.cmis_query( + context.repo_id, + statement, + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +@when(parsers.parse("I execute a CMIS query for all documents with max items {max_items:d}")) +def cmis_query_paginated(context: DMSTestContext, dms_client: DMSClient, max_items: int): + """Execute a CMIS query with pagination.""" + try: + opts = QueryOptions(max_items=max_items) + context.query_result = dms_client.cmis_query( + context.repo_id, + "SELECT * FROM cmis:document", + options=opts, + ) + context.operation_success = True + except Exception as e: + context.operation_error = e + + +# ==================== DELETE / RESTORE: THEN ==================== + + +@then("the delete should be successful") +def delete_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.operation_success is True + + +@then("the restore should be successful") +def restore_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.restore_message is not None + + +# ==================== APPEND: THEN ==================== + + +@then("the append should be successful") +def append_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.appended_document is not None + + +@then("the appended document should be a Document") +def appended_is_document(context: DMSTestContext): + assert isinstance(context.appended_document, Document) + + +# ==================== QUERY: THEN ==================== + + +@then("the query should be successful") +def query_success(context: DMSTestContext): + assert context.operation_error is None, f"Failed: {context.operation_error}" + assert context.query_result is not None + assert isinstance(context.query_result, QueryResultPage) + + +@then(parsers.parse("the query results should contain at least {count:d} item")) +def query_results_count(context: DMSTestContext, count: int): + assert len(context.query_result.results) >= count # ty: ignore[unresolved-attribute] diff --git a/tests/dms/unit/test_client_cmis.py b/tests/dms/unit/test_client_cmis.py index e814eba..1d9c59c 100644 --- a/tests/dms/unit/test_client_cmis.py +++ b/tests/dms/unit/test_client_cmis.py @@ -22,6 +22,8 @@ DMSCredentials, Document, Folder, + QueryOptions, + QueryResultPage, UserClaim, ) @@ -1010,3 +1012,269 @@ def test_with_tenant_and_user_claim(self, client): call_args = client._mock_http.get.call_args assert call_args[1]["tenant_subdomain"] == "t1" assert call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# delete_object +# --------------------------------------------------------------- + + +class TestDeleteObject: + def test_basic(self, client): + client._mock_http.post_form.return_value = _mock_response({}) + + result = client.delete_object("repo1", "doc-xyz") + + assert result is None + call_args = client._mock_http.post_form.call_args + assert call_args[0][0] == "/browser/repo1/root" + data = call_args[1]["data"] + assert data["cmisaction"] == "delete" + assert data["objectId"] == "doc-xyz" + assert data["allVersions"] == "true" + assert data["_charset_"] == "UTF-8" + + def test_all_versions_false(self, client): + client._mock_http.post_form.return_value = _mock_response({}) + + client.delete_object("repo1", "doc-xyz", all_versions=False) + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["allVersions"] == "false" + + def test_with_tenant(self, client): + client._mock_http.post_form.return_value = _mock_response({}) + + client.delete_object("repo1", "doc-xyz", tenant="sub1") + + assert client._mock_http.post_form.call_args[1]["tenant_subdomain"] == "sub1" + + def test_with_user_claim(self, client): + client._mock_http.post_form.return_value = _mock_response({}) + claim = UserClaim(x_ecm_user_enc="alice@sap.com") + + client.delete_object("repo1", "doc-xyz", user_claim=claim) + + assert client._mock_http.post_form.call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# restore_object +# --------------------------------------------------------------- + +_RESTORE_RESPONSE = {"message": "Object restored successfully"} + + +class TestRestoreObject: + def test_basic(self, client): + client._mock_http.post.return_value = _mock_response(_RESTORE_RESPONSE) + + msg = client.restore_object("repo1", "doc-xyz") + + assert msg == "Object restored successfully" + call_args = client._mock_http.post.call_args + assert ( + call_args[1]["path"] + == "/rest/v2/repositories/repo1/deleted/objects/doc-xyz/restore" + ) + assert call_args[1]["payload"] == {} + + def test_with_tenant(self, client): + client._mock_http.post.return_value = _mock_response(_RESTORE_RESPONSE) + + client.restore_object("repo1", "doc-xyz", tenant="sub1") + + assert client._mock_http.post.call_args[1]["tenant_subdomain"] == "sub1" + + def test_with_user_claim(self, client): + client._mock_http.post.return_value = _mock_response(_RESTORE_RESPONSE) + claim = UserClaim(x_ecm_user_enc="bob@sap.com") + + client.restore_object("repo1", "doc-xyz", user_claim=claim) + + assert client._mock_http.post.call_args[1]["user_claim"] is claim + + def test_empty_message(self, client): + client._mock_http.post.return_value = _mock_response({}) + + msg = client.restore_object("repo1", "doc-xyz") + + assert msg == "" + + +# --------------------------------------------------------------- +# append_content_stream +# --------------------------------------------------------------- + + +class TestAppendContentStream: + def test_basic(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + stream = BytesIO(b"additional content") + + doc = client.append_content_stream("repo1", "doc-xyz", stream) + + assert isinstance(doc, Document) + assert doc.object_id == "doc-xyz" + + call_args = client._mock_http.post_form.call_args + assert call_args[0][0] == "/browser/repo1/root" + data = call_args[1]["data"] + assert data["cmisaction"] == "appendContent" + assert data["objectId"] == "doc-xyz" + assert data["succinct"] == "true" + assert data["isLastChunk"] == "false" + assert data["_charset_"] == "UTF-8" + + files_arg = call_args[1]["files"] + assert "media" in files_arg + assert files_arg["media"][0] == "content" + assert files_arg["media"][2] == "application/octet-stream" + + def test_is_last_chunk_true(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.append_content_stream( + "repo1", "doc-xyz", BytesIO(b"final"), is_last_chunk=True + ) + + data = client._mock_http.post_form.call_args[1]["data"] + assert data["isLastChunk"] == "true" + + def test_with_filename(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.append_content_stream( + "repo1", "doc-xyz", BytesIO(b"data"), filename="chunk.bin" + ) + + files_arg = client._mock_http.post_form.call_args[1]["files"] + assert files_arg["media"][0] == "chunk.bin" + + def test_with_tenant(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + + client.append_content_stream( + "repo1", "doc-xyz", BytesIO(b"data"), tenant="sub1" + ) + + assert client._mock_http.post_form.call_args[1]["tenant_subdomain"] == "sub1" + + def test_with_user_claim(self, client): + client._mock_http.post_form.return_value = _mock_response(_DOCUMENT_RESPONSE) + claim = UserClaim(x_ecm_user_enc="carol@sap.com") + + client.append_content_stream( + "repo1", "doc-xyz", BytesIO(b"data"), user_claim=claim + ) + + assert client._mock_http.post_form.call_args[1]["user_claim"] is claim + + +# --------------------------------------------------------------- +# cmis_query +# --------------------------------------------------------------- + +_QUERY_RESPONSE = { + "results": [ + { + "properties": { + "cmis:objectId": {"value": "doc-1"}, + "cmis:name": {"value": "Report.pdf"}, + "cmis:baseTypeId": {"value": "cmis:document"}, + "cmis:objectTypeId": {"value": "cmis:document"}, + "cmis:contentStreamLength": {"value": 4096}, + } + }, + { + "properties": { + "cmis:objectId": {"value": "folder-1"}, + "cmis:name": {"value": "Archive"}, + "cmis:baseTypeId": {"value": "cmis:folder"}, + "cmis:objectTypeId": {"value": "cmis:folder"}, + } + }, + ], + "hasMoreItems": True, + "numItems": 200, +} + + +class TestCmisQuery: + def test_basic(self, client): + client._mock_http.get.return_value = _mock_response(_QUERY_RESPONSE) + + page = client.cmis_query("repo1", "SELECT * FROM cmis:document") + + assert isinstance(page, QueryResultPage) + assert len(page.results) == 2 + assert page.has_more_items is True + assert page.num_items == 200 + + assert isinstance(page.results[0], Document) + assert page.results[0].object_id == "doc-1" + assert isinstance(page.results[1], Folder) + assert page.results[1].object_id == "folder-1" + + call_args = client._mock_http.get.call_args + assert call_args[0][0] == "/browser/repo1" + params = call_args[1]["params"] + assert params["cmisselector"] == "query" + assert params["q"] == "SELECT * FROM cmis:document" + assert params["maxItems"] == "100" + assert params["skipCount"] == "0" + + def test_with_options(self, client): + client._mock_http.get.return_value = _mock_response( + {"results": [], "hasMoreItems": False} + ) + + opts = QueryOptions(max_items=25, skip_count=50, search_all_versions=True) + client.cmis_query("repo1", "SELECT * FROM cmis:document", options=opts) + + params = client._mock_http.get.call_args[1]["params"] + assert params["maxItems"] == "25" + assert params["skipCount"] == "50" + assert params["searchAllVersions"] == "true" + + def test_search_all_versions_default_not_in_params(self, client): + client._mock_http.get.return_value = _mock_response( + {"results": [], "hasMoreItems": False} + ) + + client.cmis_query("repo1", "SELECT * FROM cmis:document") + + params = client._mock_http.get.call_args[1]["params"] + assert "searchAllVersions" not in params + + def test_empty_results(self, client): + client._mock_http.get.return_value = _mock_response( + {"results": [], "hasMoreItems": False, "numItems": 0} + ) + + page = client.cmis_query("repo1", "SELECT * FROM cmis:document") + + assert page.results == [] + assert page.has_more_items is False + assert page.num_items == 0 + + def test_with_tenant(self, client): + client._mock_http.get.return_value = _mock_response( + {"results": [], "hasMoreItems": False} + ) + + client.cmis_query("repo1", "SELECT * FROM cmis:document", tenant="sub1") + + assert client._mock_http.get.call_args[1]["tenant_subdomain"] == "sub1" + + def test_with_user_claim(self, client): + client._mock_http.get.return_value = _mock_response( + {"results": [], "hasMoreItems": False} + ) + claim = UserClaim(x_ecm_user_enc="eve@sap.com") + + client.cmis_query( + "repo1", "SELECT * FROM cmis:document", user_claim=claim + ) + + assert client._mock_http.get.call_args[1]["user_claim"] is claim diff --git a/tests/dms/unit/test_cmis_models.py b/tests/dms/unit/test_cmis_models.py index a5716eb..f814dba 100644 --- a/tests/dms/unit/test_cmis_models.py +++ b/tests/dms/unit/test_cmis_models.py @@ -10,6 +10,8 @@ CmisObject, Document, Folder, + QueryOptions, + QueryResultPage, _parse_datetime as _parse_cmis_datetime, _prop_val, ) @@ -363,3 +365,122 @@ def test_from_dict_no_num_items(self): page = ChildrenPage.from_dict({"objects": [], "hasMoreItems": True}) assert page.has_more_items is True assert page.num_items is None + + +# --------------------------------------------------------------- +# QueryOptions +# --------------------------------------------------------------- + + +class TestQueryOptions: + def test_defaults(self): + opts = QueryOptions() + assert opts.max_items == 100 + assert opts.skip_count == 0 + assert opts.search_all_versions is False + + def test_to_query_params_defaults(self): + params = QueryOptions().to_query_params() + assert params == {"maxItems": "100", "skipCount": "0"} + assert "searchAllVersions" not in params + + def test_to_query_params_custom(self): + opts = QueryOptions(max_items=25, skip_count=50, search_all_versions=True) + params = opts.to_query_params() + assert params["maxItems"] == "25" + assert params["skipCount"] == "50" + assert params["searchAllVersions"] == "true" + + def test_search_all_versions_false_omitted(self): + opts = QueryOptions(search_all_versions=False) + params = opts.to_query_params() + assert "searchAllVersions" not in params + + +# --------------------------------------------------------------- +# QueryResultPage +# --------------------------------------------------------------- + + +class TestQueryResultPage: + def test_from_dict_verbose_properties(self): + data = { + "results": [ + { + "properties": { + "cmis:objectId": {"value": "doc-1"}, + "cmis:name": {"value": "Report.pdf"}, + "cmis:baseTypeId": {"value": "cmis:document"}, + "cmis:objectTypeId": {"value": "cmis:document"}, + "cmis:contentStreamLength": {"value": 4096}, + } + }, + ], + "hasMoreItems": True, + "numItems": 100, + } + page = QueryResultPage.from_dict(data) + assert len(page.results) == 1 + assert isinstance(page.results[0], Document) + assert page.results[0].object_id == "doc-1" + assert page.results[0].name == "Report.pdf" + assert page.has_more_items is True + assert page.num_items == 100 + + def test_from_dict_mixed_types(self): + data = { + "results": [ + { + "properties": { + "cmis:objectId": {"value": "doc-1"}, + "cmis:name": {"value": "Doc"}, + "cmis:baseTypeId": {"value": "cmis:document"}, + "cmis:objectTypeId": {"value": "cmis:document"}, + } + }, + { + "properties": { + "cmis:objectId": {"value": "folder-1"}, + "cmis:name": {"value": "Folder"}, + "cmis:baseTypeId": {"value": "cmis:folder"}, + "cmis:objectTypeId": {"value": "cmis:folder"}, + } + }, + ], + "hasMoreItems": False, + "numItems": 2, + } + page = QueryResultPage.from_dict(data) + assert len(page.results) == 2 + assert isinstance(page.results[0], Document) + assert isinstance(page.results[1], Folder) + + def test_from_dict_empty(self): + page = QueryResultPage.from_dict({"results": [], "hasMoreItems": False}) + assert page.results == [] + assert page.has_more_items is False + assert page.num_items is None + + def test_from_dict_unknown_type(self): + data = { + "results": [ + { + "properties": { + "cmis:objectId": {"value": "item-1"}, + "cmis:name": {"value": "Item"}, + "cmis:baseTypeId": {"value": "cmis:item"}, + "cmis:objectTypeId": {"value": "cmis:item"}, + } + }, + ], + "hasMoreItems": False, + } + page = QueryResultPage.from_dict(data) + assert len(page.results) == 1 + assert isinstance(page.results[0], CmisObject) + assert not isinstance(page.results[0], (Folder, Document)) + + def test_from_dict_no_num_items(self): + page = QueryResultPage.from_dict({"results": [], "hasMoreItems": True}) + assert page.has_more_items is True + assert page.num_items is None From 578053cb0da78e16eaf8dcd207b05ea1a45e8f29 Mon Sep 17 00:00:00 2001 From: Karan Shukla Date: Fri, 10 Apr 2026 14:27:00 +0700 Subject: [PATCH 24/24] fix(dms): format logger.info calls to pass ruff format check --- src/sap_cloud_sdk/dms/client.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/sap_cloud_sdk/dms/client.py b/src/sap_cloud_sdk/dms/client.py index f6f69ce..0f1c4ab 100644 --- a/src/sap_cloud_sdk/dms/client.py +++ b/src/sap_cloud_sdk/dms/client.py @@ -1045,9 +1045,7 @@ def delete_object( "allVersions": str(all_versions).lower(), "_charset_": "UTF-8", } - logger.info( - "Deleting object '%s' from repo '%s'", object_id, repository_id - ) + logger.info("Deleting object '%s' from repo '%s'", object_id, repository_id) self._http.post_form( self._browser_url(repository_id), data=form_data, @@ -1081,9 +1079,7 @@ def restore_object( DMSRuntimeException: If the server encounters an internal error. """ path = f"{_REPOSITORIES}/{repository_id}/deleted/objects/{object_id}/restore" - logger.info( - "Restoring object '%s' in repo '%s'", object_id, repository_id - ) + logger.info("Restoring object '%s' in repo '%s'", object_id, repository_id) response = self._http.post( path=path, payload={},