Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
8b95146
init
JagnathReddy Mar 17, 2026
cbf7209
Merge branch 'SAP:main' into dms-integration
JagnathReddy Mar 17, 2026
33d1e3e
added admin service
JagnathReddy Mar 17, 2026
0ba96b5
clean up
JagnathReddy Mar 17, 2026
11c33af
clean up - minor
JagnathReddy Mar 17, 2026
06677c6
Merge branch 'SAP:main' into dms-integration
JagnathReddy Mar 17, 2026
9a0723b
adding union import
JagnathReddy Mar 17, 2026
ba7c708
deleting config
JagnathReddy Mar 17, 2026
64c4a5c
redesing
JagnathReddy Mar 23, 2026
f809559
created overriden headers and CONSTAnTs
JagnathReddy Mar 24, 2026
f2a3881
added support for ecmuser and principals headers
JagnathReddy Mar 24, 2026
443dd53
added telemetry with new admin apis and models
JagnathReddy Mar 25, 2026
38c3194
Merge branch 'SAP:main' into dms-integration
JagnathReddy Mar 25, 2026
37ad206
Merge pull request #1 from JagnathReddy/dms-integration
JagnathReddy Mar 26, 2026
7238b87
Add CMIS retrieval and user APIs for DMS module
I559656 Mar 26, 2026
a3b5526
Merge branch 'cmis-retrieval-apis' into cmis-retrieval-apis
JagnathReddy Mar 26, 2026
783ac64
removed aces from create doc and folder and added the unit test for a…
I559656 Mar 31, 2026
d4b9ccf
fix the issues with ruff and test imports
I559656 Mar 31, 2026
63f9491
applied the fix ruff, import collision in tests
I559656 Mar 31, 2026
07c434f
fix(dms): add ty type checker ignore comments for test files
I559656 Mar 31, 2026
1a009b5
fix(dms): fix parent_folder_id description in user guide
I559656 Apr 1, 2026
7de09bb
Merge pull request #2 from I559656/cmis-retrieval-apis
JagnathReddy Apr 1, 2026
0fa0f43
Merge branch 'SAP:main' into main
JagnathReddy Apr 1, 2026
b1904eb
todo complete
JagnathReddy Apr 2, 2026
a37d666
fix ruff: missing newlines at end of file
I559656 Apr 2, 2026
59aa82c
Merge pull request #3 from JagnathReddy/hashenum
JagnathReddy Apr 2, 2026
af0a684
fix(dms): refactored the pagination and added more documentations in …
I559656 Apr 8, 2026
80e0dff
fix(dms): move ty:ignore comments to correct lines in test_dms_bdd
I559656 Apr 8, 2026
3c8d970
docs(dms): add docstrings to create_client() and DMSClient.__init__()
I559656 Apr 8, 2026
d4e31cb
FIX: added warning for direct client creation and completed flow for …
JagnathReddy Apr 8, 2026
86f0e28
Merge branch 'SAP:main' into main
JagnathReddy Apr 9, 2026
1a18051
fix(dms): warning for direct client creation and completed timeout flows
JagnathReddy Apr 9, 2026
aa0ee55
feat(dms): add delete_object, restore_object, append_content_stream, …
I559656 Apr 10, 2026
578053c
fix(dms): format logger.info calls to pass ruff format check
I559656 Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env_integration_tests.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"}'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to have multiple variables for it, we have faced some issues in the past

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we thought of keeping it similar to how these variables are defined in the actual mount path in kyma runtime, should we keep the structure of env variables different that the mount?

screenshot of mount below

Image

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/sap_cloud_sdk/core/telemetry/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class Module(str, Enum):
AUDITLOG = "auditlog"
DESTINATION = "destination"
OBJECTSTORE = "objectstore"
DMS = "dms"

def __str__(self) -> str:
return self.value
29 changes: 29 additions & 0 deletions src/sap_cloud_sdk/core/telemetry/operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,34 @@ 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"

# 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"
# 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
100 changes: 100 additions & 0 deletions src/sap_cloud_sdk/dms/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""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 (
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


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.

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:
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,
connect_timeout=connect_timeout,
read_timeout=read_timeout,
)
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",
"Ace",
"Acl",
"ChildrenOptions",
"ChildrenPage",
"CmisObject",
"DMSClient",
"DMSCredentials",
"DMSError",
"Document",
"Folder",
"QueryOptions",
"QueryResultPage",
"UserClaim",
]
109 changes: 109 additions & 0 deletions src/sap_cloud_sdk/dms/_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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 (
DMSError,
DMSConnectionError,
DMSPermissionDeniedException,
)
from sap_cloud_sdk.dms.model import DMSCredentials

logger = logging.getLogger(__name__)


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


_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: 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

logger.debug("Fetching new token for key '%s'", cache_key)
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),
)
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:
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 DMSError("Token response missing access_token")

logger.debug("Token fetched successfully")
return payload
Loading
Loading