From 00bd585c11a3d130ae44587526fb28609590649e Mon Sep 17 00:00:00 2001 From: i743000 Date: Wed, 24 Jun 2026 10:36:34 +0530 Subject: [PATCH 01/13] fix(adms): align all action paths with com.sap.adm.{Service} namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors cloud-sdk-java contrib-java/adms commit e5dd1da's wire-format audit. The Java audit identified 12 fixes against the canonical CDS sources + live ADMS tenant. Cross-verified Python state vs CDS: - 7 fixes were already correct in Python (UpdateDocumentInput fields, RestoreContentVersion envelope, DeleteContentVersion body shape, DownloadDocument GET function, StartJob payload, JobStatus function, LateHostBusinessObjectNodeID casing). - 5 namespace-prefix fixes were genuinely missing (lock, unlock, complete_multipart_upload, generate_upload_urls, mark_default) — Python used the short form which CAP accepts leniently but is not the canonical OData V4 wire format. For cross-SDK consistency with Java, ALL 12 bound/unbound action paths now use the fully-qualified com.sap.adm.{DocumentService,AdminService, ConfigurationService}.X form: DocumentService bound actions on DocumentRelation: UpdateDocument, CompleteMultipartUpload, RestoreDocumentContentVersion, DeleteDocumentContentVersion, LockDocumentAndRelation, UnlockDocumentAndRelation, GenerateDocumentUploadURLs, DownloadDocument (function) DocumentService unbound: StartJob, JobStatus (function) AdminService unbound: StartJob, JobStatus (function) ConfigurationService bound on DocumentTypeBusinessObjectTypeMap: markDefault Test changes: - Tightened 7 substring assertions in test_client.py to include the namespace prefix so the wire contract is captured in tests. Also includes prior work on this branch: - extra_headers support on sync + async HTTP verbs (for x-subaccount-id header on ApplicationTenant operations) - subaccount_id parameter on all 4 ApplicationTenant config methods No API surface changes, no model changes, no behavioural changes — purely wire-protocol alignment with Java commit e5dd1da. --- src/sap_cloud_sdk/adms/__init__.py | 22 ++++- src/sap_cloud_sdk/adms/_configuration_api.py | 86 ++++++++++++++++---- src/sap_cloud_sdk/adms/_document_api.py | 16 ++-- src/sap_cloud_sdk/adms/_http.py | 83 ++++++++++++++++--- src/sap_cloud_sdk/adms/_job_api.py | 36 ++++++-- src/sap_cloud_sdk/adms/_relation_api.py | 16 ++-- tests/adms/unit/test_client.py | 20 +++-- 7 files changed, 221 insertions(+), 58 deletions(-) diff --git a/src/sap_cloud_sdk/adms/__init__.py b/src/sap_cloud_sdk/adms/__init__.py index c420626d..13f69c43 100644 --- a/src/sap_cloud_sdk/adms/__init__.py +++ b/src/sap_cloud_sdk/adms/__init__.py @@ -56,14 +56,20 @@ ) from sap_cloud_sdk.adms._models import ( AllowedDomain, + ApplicationTenant, BaseType, + BusinessObjectNodeChangeLog, BusinessObjectNodeType, + ChangeLog, CreateAllowedDomainInput, + CreateApplicationTenantInput, CreateBusinessObjectNodeTypeInput, CreateDocumentTypeBoTypeMapInput, CreateDocumentInput, CreateDocumentRelationInput, CreateDocumentTypeInput, + CreateFileExtensionPolicyInput, + DeleteBusinessObjectNodeResult, DeleteUserDataJobParameters, Document, DocumentContentVersion, @@ -74,10 +80,12 @@ DraftActivateInput, DraftAdministrativeData, DraftInput, + FileExtensionPolicy, JobInput, JobOutput, JobStatus, JobType, + MimeTypePolicy, ScanStatus, UpdateAllowedDomainInput, UpdateBusinessObjectNodeTypeInput, @@ -113,8 +121,11 @@ "ScanNotCleanError", # models — core "BaseType", + "ChangeLog", + "BusinessObjectNodeChangeLog", "CreateDocumentInput", "CreateDocumentRelationInput", + "DeleteBusinessObjectNodeResult", "DeleteUserDataJobParameters", "Document", "DocumentContentVersion", @@ -126,22 +137,27 @@ "JobOutput", "JobStatus", "JobType", + "MimeTypePolicy", "ScanStatus", "UpdateDocumentInput", "ZipDownloadJobParameters", # models — config "AllowedDomain", + "ApplicationTenant", "BusinessObjectNodeType", "CreateAllowedDomainInput", + "CreateApplicationTenantInput", "CreateBusinessObjectNodeTypeInput", - "UpdateAllowedDomainInput", - "UpdateBusinessObjectNodeTypeInput", - "UpdateDocumentTypeInput", "CreateDocumentTypeBoTypeMapInput", "CreateDocumentTypeInput", + "CreateFileExtensionPolicyInput", "DocumentType", "DocumentTypeBusinessObjectTypeMap", "DocumentTypeText", + "FileExtensionPolicy", + "UpdateAllowedDomainInput", + "UpdateBusinessObjectNodeTypeInput", + "UpdateDocumentTypeInput", # query options "ConfigQueryOptions", "DocumentQueryOptions", diff --git a/src/sap_cloud_sdk/adms/_configuration_api.py b/src/sap_cloud_sdk/adms/_configuration_api.py index d6bf295e..a537378b 100644 --- a/src/sap_cloud_sdk/adms/_configuration_api.py +++ b/src/sap_cloud_sdk/adms/_configuration_api.py @@ -264,7 +264,7 @@ def delete_type_mapping(self, document_type_bo_type_map_id: str) -> None: def mark_default(self, document_type_bo_type_map_id: str) -> None: """Mark a DocumentType ↔ BusinessObjectNodeType mapping as the default.""" self._http.post( - f"{build_doctype_botype_map_key_path(document_type_bo_type_map_id)}/markDefault", + f"{build_doctype_botype_map_key_path(document_type_bo_type_map_id)}/com.sap.adm.ConfigurationService.markDefault", json={}, service_base=_CONFIG_SERVICE_PATH, ) @@ -319,12 +319,24 @@ def delete_file_extension_policy(self, file_extension_policy_id: str) -> None: @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_ALL_APP_TENANTS) def get_all_application_tenants( - self, options: ConfigQueryOptions | None = None + self, + options: ConfigQueryOptions | None = None, + *, + subaccount_id: str | None = None, ) -> list[ApplicationTenant]: - """Return all application tenant configurations.""" + """Return all application tenant configurations. + + Args: + options: Optional OData query parameters. + subaccount_id: BTP subaccount ID. ADM requires this header + (``x-subaccount-id``) on ApplicationTenant operations. + """ params = options.to_query_params() if options else {} resp = self._http.get( - "ApplicationTenant", params=params, service_base=_CONFIG_SERVICE_PATH + "ApplicationTenant", + params=params, + service_base=_CONFIG_SERVICE_PATH, + extra_headers=_subaccount_header(subaccount_id), ) return [ ApplicationTenant.from_dict(item) for item in resp.json().get("value", []) @@ -332,34 +344,61 @@ def get_all_application_tenants( @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_CREATE_APP_TENANT) def create_application_tenant( - self, payload: CreateApplicationTenantInput + self, + payload: CreateApplicationTenantInput, + *, + subaccount_id: str | None = None, ) -> ApplicationTenant: - """Create an application tenant configuration.""" + """Create an application tenant configuration. + + Args: + payload: Tenant fields. + subaccount_id: BTP subaccount ID. ADM requires this header + (``x-subaccount-id``) on ApplicationTenant operations. + """ resp = self._http.post( "ApplicationTenant", json=payload.to_odata_dict(), service_base=_CONFIG_SERVICE_PATH, + extra_headers=_subaccount_header(subaccount_id), ) return ApplicationTenant.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_APP_TENANT) - def get_application_tenant(self, application_tenant_id: str) -> ApplicationTenant: + def get_application_tenant( + self, + application_tenant_id: str, + *, + subaccount_id: str | None = None, + ) -> ApplicationTenant: """Fetch a single ApplicationTenant by its ID.""" resp = self._http.get( f"ApplicationTenant(ApplicationTenantID='{application_tenant_id}')", service_base=_CONFIG_SERVICE_PATH, + extra_headers=_subaccount_header(subaccount_id), ) return ApplicationTenant.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_APP_TENANT) - def delete_application_tenant(self, application_tenant_id: str) -> None: + def delete_application_tenant( + self, + application_tenant_id: str, + *, + subaccount_id: str | None = None, + ) -> None: """Delete an application tenant configuration.""" self._http.delete( f"ApplicationTenant(ApplicationTenantID='{application_tenant_id}')", service_base=_CONFIG_SERVICE_PATH, + extra_headers=_subaccount_header(subaccount_id), ) +def _subaccount_header(subaccount_id: str | None) -> dict[str, str] | None: + """Build the x-subaccount-id header dict, or None if not provided.""" + return {"x-subaccount-id": subaccount_id} if subaccount_id else None + + def _quote_guid(value: str) -> str: """Wrap a UUID value in the OData Edm.Guid format for key segments.""" from sap_cloud_sdk.adms._http import quote_odata_guid_key @@ -602,7 +641,7 @@ async def delete_type_mapping(self, document_type_bo_type_map_id: str) -> None: async def mark_default(self, document_type_bo_type_map_id: str) -> None: """Async variant of :meth:`_ConfigurationApi.mark_default` — same semantics.""" await self._http.post( - f"{build_doctype_botype_map_key_path(document_type_bo_type_map_id)}/markDefault", + f"{build_doctype_botype_map_key_path(document_type_bo_type_map_id)}/com.sap.adm.ConfigurationService.markDefault", json={}, service_base=_CONFIG_SERVICE_PATH, ) @@ -653,12 +692,18 @@ async def delete_file_extension_policy(self, file_extension_policy_id: str) -> N @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_ALL_APP_TENANTS) async def get_all_application_tenants( - self, options: ConfigQueryOptions | None = None + self, + options: ConfigQueryOptions | None = None, + *, + subaccount_id: str | None = None, ) -> list[ApplicationTenant]: """Async variant of :meth:`_ConfigurationApi.get_all_application_tenants` — same semantics.""" params = options.to_query_params() if options else {} resp = await self._http.get( - "ApplicationTenant", params=params, service_base=_CONFIG_SERVICE_PATH + "ApplicationTenant", + params=params, + service_base=_CONFIG_SERVICE_PATH, + extra_headers=_subaccount_header(subaccount_id), ) return [ ApplicationTenant.from_dict(item) for item in resp.json().get("value", []) @@ -666,29 +711,42 @@ async def get_all_application_tenants( @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_CREATE_APP_TENANT) async def create_application_tenant( - self, payload: CreateApplicationTenantInput + self, + payload: CreateApplicationTenantInput, + *, + subaccount_id: str | None = None, ) -> ApplicationTenant: """Async variant of :meth:`_ConfigurationApi.create_application_tenant` — same semantics.""" resp = await self._http.post( "ApplicationTenant", json=payload.to_odata_dict(), service_base=_CONFIG_SERVICE_PATH, + extra_headers=_subaccount_header(subaccount_id), ) return ApplicationTenant.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_APP_TENANT) async def get_application_tenant( - self, application_tenant_id: str + self, + application_tenant_id: str, + *, + subaccount_id: str | None = None, ) -> ApplicationTenant: """Async variant of :meth:`_ConfigurationApi.get_application_tenant` — same semantics.""" resp = await self._http.get( f"ApplicationTenant(ApplicationTenantID='{application_tenant_id}')", service_base=_CONFIG_SERVICE_PATH, + extra_headers=_subaccount_header(subaccount_id), ) return ApplicationTenant.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_APP_TENANT) - async def delete_application_tenant(self, application_tenant_id: str) -> None: + async def delete_application_tenant( + self, + application_tenant_id: str, + *, + subaccount_id: str | None = None, + ) -> None: """Async variant of :meth:`_ConfigurationApi.delete_application_tenant` — same semantics.""" await self._http.delete( f"ApplicationTenant(ApplicationTenantID='{application_tenant_id}')", diff --git a/src/sap_cloud_sdk/adms/_document_api.py b/src/sap_cloud_sdk/adms/_document_api.py index ab1bebdd..0ccf7ba9 100644 --- a/src/sap_cloud_sdk/adms/_document_api.py +++ b/src/sap_cloud_sdk/adms/_document_api.py @@ -154,7 +154,7 @@ def get_download_url( ) fn_key = ( - f"{rel_key}/DownloadDocument(" + f"{rel_key}/com.sap.adm.DocumentService.DownloadDocument(" f"DocContentVersionID={quote_odata_string_key(doc_content_version_id)})" ) resp = self._http.get(fn_key, service_base=_SERVICE_PATH) @@ -183,7 +183,7 @@ def update( """ path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/UpdateDocument" + + "/com.sap.adm.DocumentService.UpdateDocument" ) payload = {"Document": update_input.to_odata_dict()} self._http.post(path, json=payload, service_base=_SERVICE_PATH) @@ -217,7 +217,7 @@ def restore_content_version( """ path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/RestoreDocumentContentVersion" + + "/com.sap.adm.DocumentService.RestoreDocumentContentVersion" ) payload: dict = { "DocumentContentVersion": { @@ -246,7 +246,7 @@ def delete_content_version( """ path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/DeleteDocumentContentVersion" + + "/com.sap.adm.DocumentService.DeleteDocumentContentVersion" ) self._http.post( path, @@ -340,7 +340,7 @@ async def get_download_url( ) fn_key = ( - f"{rel_key}/DownloadDocument(" + f"{rel_key}/com.sap.adm.DocumentService.DownloadDocument(" f"DocContentVersionID={quote_odata_string_key(doc_content_version_id)})" ) resp = await self._http.get(fn_key, service_base=_SERVICE_PATH) @@ -357,7 +357,7 @@ async def update( """Async variant of :meth:`_DocumentApi.update` — same semantics.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/UpdateDocument" + + "/com.sap.adm.DocumentService.UpdateDocument" ) payload = {"Document": update.to_odata_dict()} await self._http.post(path, json=payload, service_base=_SERVICE_PATH) @@ -379,7 +379,7 @@ async def delete_content_version( """Async variant of :meth:`_DocumentApi.delete_content_version` — same semantics.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/DeleteDocumentContentVersion" + + "/com.sap.adm.DocumentService.DeleteDocumentContentVersion" ) await self._http.post( path, @@ -399,7 +399,7 @@ async def restore_content_version( """Async variant of :meth:`_DocumentApi.restore_content_version` — same semantics.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/RestoreDocumentContentVersion" + + "/com.sap.adm.DocumentService.RestoreDocumentContentVersion" ) payload: dict = { "DocumentContentVersion": { diff --git a/src/sap_cloud_sdk/adms/_http.py b/src/sap_cloud_sdk/adms/_http.py index 76a076ab..f08109b4 100644 --- a/src/sap_cloud_sdk/adms/_http.py +++ b/src/sap_cloud_sdk/adms/_http.py @@ -211,8 +211,15 @@ def get( *, params: dict[str, Any] | None = None, service_base: str | None = None, + extra_headers: dict[str, str] | None = None, ) -> Response: - return self._request("GET", path, params=params, service_base=service_base) + return self._request( + "GET", + path, + params=params, + service_base=service_base, + extra_headers=extra_headers, + ) def post( self, @@ -221,9 +228,15 @@ def post( json: Any | None = None, params: dict[str, Any] | None = None, service_base: str | None = None, + extra_headers: dict[str, str] | None = None, ) -> Response: return self._send_with_csrf( - "POST", path, json=json, params=params, service_base=service_base + "POST", + path, + json=json, + params=params, + service_base=service_base, + extra_headers=extra_headers, ) def delete( @@ -232,9 +245,14 @@ def delete( *, params: dict[str, Any] | None = None, service_base: str | None = None, + extra_headers: dict[str, str] | None = None, ) -> Response: return self._send_with_csrf( - "DELETE", path, params=params, service_base=service_base + "DELETE", + path, + params=params, + service_base=service_base, + extra_headers=extra_headers, ) def patch( @@ -244,9 +262,15 @@ def patch( json: Any | None = None, params: dict[str, Any] | None = None, service_base: str | None = None, + extra_headers: dict[str, str] | None = None, ) -> Response: return self._send_with_csrf( - "PATCH", path, json=json, params=params, service_base=service_base + "PATCH", + path, + json=json, + params=params, + service_base=service_base, + extra_headers=extra_headers, ) def _send_with_csrf( @@ -257,8 +281,12 @@ def _send_with_csrf( json: Any | None = None, params: dict[str, Any] | None = None, service_base: str | None = None, + extra_headers: dict[str, str] | None = None, ) -> Response: csrf = self._get_csrf_token(service_base) + merged: dict[str, str] = {_CSRF_FETCH_HEADER: csrf} + if extra_headers: + merged.update(extra_headers) try: return self._request( method, @@ -266,7 +294,7 @@ def _send_with_csrf( json=json, params=params, service_base=service_base, - extra_headers={_CSRF_FETCH_HEADER: csrf}, + extra_headers=merged, ) except HttpError as exc: if exc.status_code != 403: @@ -277,13 +305,16 @@ def _send_with_csrf( if self._csrf_tokens.get(service_base or "") == csrf: self._csrf_tokens.pop(service_base or "", None) csrf = self._get_csrf_token(service_base) + merged = {_CSRF_FETCH_HEADER: csrf} + if extra_headers: + merged.update(extra_headers) return self._request( method, path, json=json, params=params, service_base=service_base, - extra_headers={_CSRF_FETCH_HEADER: csrf}, + extra_headers=merged, ) # ------------------------------------------------------------------ @@ -478,9 +509,13 @@ async def get( params: dict[str, Any] | None = None, service_base: str | None = None, headers: dict[str, str] | None = None, # accepted for LSP compat, ignored + extra_headers: dict[str, str] | None = None, ) -> httpx.Response: return await self._request( - "GET", self._prefixed(path, service_base), params=params + "GET", + self._prefixed(path, service_base), + params=params, + extra_headers=extra_headers, ) async def post( @@ -492,9 +527,15 @@ async def post( service_base: str | None = None, content: bytes | None = None, # accepted for LSP compat, ignored headers: dict[str, str] | None = None, # accepted for LSP compat, ignored + extra_headers: dict[str, str] | None = None, ) -> httpx.Response: return await self._send_with_csrf( - "POST", path, json=json, params=params, service_base=service_base + "POST", + path, + json=json, + params=params, + service_base=service_base, + extra_headers=extra_headers, ) async def delete( @@ -504,9 +545,14 @@ async def delete( params: dict[str, Any] | None = None, service_base: str | None = None, headers: dict[str, str] | None = None, # accepted for LSP compat, ignored + extra_headers: dict[str, str] | None = None, ) -> httpx.Response: return await self._send_with_csrf( - "DELETE", path, params=params, service_base=service_base + "DELETE", + path, + params=params, + service_base=service_base, + extra_headers=extra_headers, ) async def patch( @@ -517,9 +563,15 @@ async def patch( params: dict[str, Any] | None = None, service_base: str | None = None, headers: dict[str, str] | None = None, # accepted for LSP compat, ignored + extra_headers: dict[str, str] | None = None, ) -> httpx.Response: return await self._send_with_csrf( - "PATCH", path, json=json, params=params, service_base=service_base + "PATCH", + path, + json=json, + params=params, + service_base=service_base, + extra_headers=extra_headers, ) async def _send_with_csrf( @@ -530,15 +582,19 @@ async def _send_with_csrf( json: Any | None = None, params: dict[str, Any] | None = None, service_base: str | None = None, + extra_headers: dict[str, str] | None = None, ) -> httpx.Response: csrf = await self._get_csrf_token(service_base) + merged: dict[str, str] = {_CSRF_FETCH_HEADER: csrf} + if extra_headers: + merged.update(extra_headers) try: return await self._request( method, self._prefixed(path, service_base), json=json, params=params, - extra_headers={_CSRF_FETCH_HEADER: csrf}, + extra_headers=merged, ) except HttpError as exc: if exc.status_code != 403: @@ -549,12 +605,15 @@ async def _send_with_csrf( if self._csrf_tokens.get(service_base or "") == csrf: self._csrf_tokens.pop(service_base or "", None) csrf = await self._get_csrf_token(service_base) + merged = {_CSRF_FETCH_HEADER: csrf} + if extra_headers: + merged.update(extra_headers) return await self._request( method, self._prefixed(path, service_base), json=json, params=params, - extra_headers={_CSRF_FETCH_HEADER: csrf}, + extra_headers=merged, ) # ------------------------------------------------------------------ diff --git a/src/sap_cloud_sdk/adms/_job_api.py b/src/sap_cloud_sdk/adms/_job_api.py index 5c932aba..181d0db3 100644 --- a/src/sap_cloud_sdk/adms/_job_api.py +++ b/src/sap_cloud_sdk/adms/_job_api.py @@ -16,6 +16,14 @@ from sap_cloud_sdk.adms.config import _ADMIN_SERVICE_PATH, _SERVICE_PATH from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics +# Fully-qualified OData V4 unbound action / function paths. +# The Java SDK (cloud-sdk-java contrib-java/adms commit e5dd1da) confirmed +# the canonical wire format includes the namespace prefix. +_START_JOB_DOC_SERVICE = "com.sap.adm.DocumentService.StartJob" +_START_JOB_ADMIN_SERVICE = "com.sap.adm.AdminService.StartJob" +_JOB_STATUS_DOC_SERVICE_NS = "com.sap.adm.DocumentService" +_JOB_STATUS_ADMIN_SERVICE_NS = "com.sap.adm.AdminService" + class _JobApi: """Async job operations for the ADMS module. @@ -42,7 +50,9 @@ def start_zip_download(self, params: ZipDownloadJobParameters) -> JobOutput: "JobParameters": params.to_odata_dict(), } } - resp = self._http.post("StartJob", json=payload, service_base=_SERVICE_PATH) + resp = self._http.post( + _START_JOB_DOC_SERVICE, json=payload, service_base=_SERVICE_PATH + ) return JobOutput.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_JOBS_START_DELETE_USER_DATA) @@ -62,7 +72,9 @@ def start_delete_user_data(self, params: DeleteUserDataJobParameters) -> JobOutp } } resp = self._http.post( - "StartJob", json=payload, service_base=_ADMIN_SERVICE_PATH + _START_JOB_ADMIN_SERVICE, + json=payload, + service_base=_ADMIN_SERVICE_PATH, ) return JobOutput.from_dict(resp.json()) @@ -84,7 +96,12 @@ def get_status( Current :class:`~sap_cloud_sdk.adms._models.JobOutput`. """ service = _ADMIN_SERVICE_PATH if use_admin_service else _SERVICE_PATH - path = build_job_status_key_path(job_id) + ns = ( + _JOB_STATUS_ADMIN_SERVICE_NS + if use_admin_service + else _JOB_STATUS_DOC_SERVICE_NS + ) + path = f"{ns}.{build_job_status_key_path(job_id)}" resp = self._http.get(path, service_base=service) return JobOutput.from_dict(resp.json()) @@ -108,7 +125,7 @@ async def start_zip_download(self, params: ZipDownloadJobParameters) -> JobOutpu } } resp = await self._http.post( - "StartJob", json=payload, service_base=_SERVICE_PATH + _START_JOB_DOC_SERVICE, json=payload, service_base=_SERVICE_PATH ) return JobOutput.from_dict(resp.json()) @@ -124,7 +141,9 @@ async def start_delete_user_data( } } resp = await self._http.post( - "StartJob", json=payload, service_base=_ADMIN_SERVICE_PATH + _START_JOB_ADMIN_SERVICE, + json=payload, + service_base=_ADMIN_SERVICE_PATH, ) return JobOutput.from_dict(resp.json()) @@ -146,6 +165,11 @@ async def get_status( Current :class:`~sap_cloud_sdk.adms._models.JobOutput`. """ service = _ADMIN_SERVICE_PATH if use_admin_service else _SERVICE_PATH - path = build_job_status_key_path(job_id) + ns = ( + _JOB_STATUS_ADMIN_SERVICE_NS + if use_admin_service + else _JOB_STATUS_DOC_SERVICE_NS + ) + path = f"{ns}.{build_job_status_key_path(job_id)}" resp = await self._http.get(path, service_base=service) return JobOutput.from_dict(resp.json()) diff --git a/src/sap_cloud_sdk/adms/_relation_api.py b/src/sap_cloud_sdk/adms/_relation_api.py index b5a5dfe2..2f189a83 100644 --- a/src/sap_cloud_sdk/adms/_relation_api.py +++ b/src/sap_cloud_sdk/adms/_relation_api.py @@ -123,7 +123,7 @@ def generate_upload_urls( """ path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/GenerateDocumentUploadURLs" + + "/com.sap.adm.DocumentService.GenerateDocumentUploadURLs" ) payload = { "DocumentIsMultipart": is_multipart, @@ -147,7 +147,7 @@ def complete_multipart_upload( """ path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/CompleteMultipartUpload" + + "/com.sap.adm.DocumentService.CompleteMultipartUpload" ) self._http.post(path, json={}, service_base=_SERVICE_PATH) @@ -161,7 +161,7 @@ def lock( """Lock a document and its relation to prevent concurrent modifications.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/LockDocumentAndRelation" + + "/com.sap.adm.DocumentService.LockDocumentAndRelation" ) self._http.post(path, json={}, service_base=_SERVICE_PATH) @@ -175,7 +175,7 @@ def unlock( """Unlock a previously locked document and relation.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/UnlockDocumentAndRelation" + + "/com.sap.adm.DocumentService.UnlockDocumentAndRelation" ) self._http.post(path, json={}, service_base=_SERVICE_PATH) @@ -386,7 +386,7 @@ async def generate_upload_urls( """Async variant of :meth:`_DocumentRelationApi.generate_upload_urls` — same semantics.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/GenerateDocumentUploadURLs" + + "/com.sap.adm.DocumentService.GenerateDocumentUploadURLs" ) payload = { "DocumentIsMultipart": is_multipart, @@ -405,7 +405,7 @@ async def complete_multipart_upload( """Async variant of :meth:`_DocumentRelationApi.complete_multipart_upload` — same semantics.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/CompleteMultipartUpload" + + "/com.sap.adm.DocumentService.CompleteMultipartUpload" ) await self._http.post(path, json={}, service_base=_SERVICE_PATH) @@ -419,7 +419,7 @@ async def lock( """Async variant of :meth:`_DocumentRelationApi.lock` — same semantics.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/LockDocumentAndRelation" + + "/com.sap.adm.DocumentService.LockDocumentAndRelation" ) await self._http.post(path, json={}, service_base=_SERVICE_PATH) @@ -433,7 +433,7 @@ async def unlock( """Async variant of :meth:`_DocumentRelationApi.unlock` — same semantics.""" path = ( build_relation_key_path(document_relation_id, is_active_entity) - + "/UnlockDocumentAndRelation" + + "/com.sap.adm.DocumentService.UnlockDocumentAndRelation" ) await self._http.post(path, json={}, service_base=_SERVICE_PATH) diff --git a/tests/adms/unit/test_client.py b/tests/adms/unit/test_client.py index f789428a..8ded5cb6 100644 --- a/tests/adms/unit/test_client.py +++ b/tests/adms/unit/test_client.py @@ -734,7 +734,7 @@ def test_update_calls_bound_action(self): doc = api.update("11111111-1111-1111-1111-111111111111", upd) call_path = http.post.call_args[0][0] - assert "UpdateDocument" in call_path + assert "com.sap.adm.DocumentService.UpdateDocument" in call_path assert isinstance(doc, Document) def test_update_sends_only_set_fields(self): @@ -759,7 +759,7 @@ def test_restore_content_version(self): ) call_path = http.post.call_args[0][0] - assert "RestoreDocumentContentVersion" in call_path + assert "com.sap.adm.DocumentService.RestoreDocumentContentVersion" in call_path payload = http.post.call_args[1]["json"] assert payload["DocumentContentVersion"]["DocContentVersionID"] == "1.0" assert payload["DocumentContentVersion"]["DocContentVersionComment"] == "Revert" @@ -773,7 +773,7 @@ def test_delete_content_version(self): api.delete_content_version("11111111-1111-1111-1111-111111111111", "2.0") call_path = http.post.call_args[0][0] - assert "DeleteDocumentContentVersion" in call_path + assert "com.sap.adm.DocumentService.DeleteDocumentContentVersion" in call_path assert http.post.call_args[1]["json"]["DocContentVersionID"] == "2.0" @@ -1021,7 +1021,7 @@ def test_generate_upload_urls_calls_action(self): doc = api.generate_upload_urls("11111111-1111-1111-1111-111111111111") call_path = http.post.call_args[0][0] - assert "GenerateDocumentUploadURLs" in call_path + assert "com.sap.adm.DocumentService.GenerateDocumentUploadURLs" in call_path assert doc.document_content_upload_urls == ["https://s3.example.com/upload-url"] def test_complete_multipart_upload(self): @@ -1031,7 +1031,7 @@ def test_complete_multipart_upload(self): api.complete_multipart_upload("11111111-1111-1111-1111-111111111111") call_path = http.post.call_args[0][0] - assert "CompleteMultipartUpload" in call_path + assert "com.sap.adm.DocumentService.CompleteMultipartUpload" in call_path class TestDocumentRelationApiLockDelete: @@ -1039,13 +1039,19 @@ def test_lock(self): http = _rel_http() api = _DocumentRelationApi(http) api.lock("11111111-1111-1111-1111-111111111111") - assert "LockDocumentAndRelation" in http.post.call_args[0][0] + assert ( + "com.sap.adm.DocumentService.LockDocumentAndRelation" + in http.post.call_args[0][0] + ) def test_unlock(self): http = _rel_http() api = _DocumentRelationApi(http) api.unlock("11111111-1111-1111-1111-111111111111") - assert "UnlockDocumentAndRelation" in http.post.call_args[0][0] + assert ( + "com.sap.adm.DocumentService.UnlockDocumentAndRelation" + in http.post.call_args[0][0] + ) def test_delete_calls_http_delete(self): http = _rel_http() From ac8a782fa4cad35af4fdcb38d860cb7215711e0a Mon Sep 17 00:00:00 2001 From: i743000 Date: Wed, 24 Jun 2026 11:02:17 +0530 Subject: [PATCH 02/13] chore(adms): temporarily add scripts/adms_cli.py for live tenant testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive CLI for testing all ADMS APIs against a real ADM instance. Used during this PR's development to verify each of the 12 wire-format fixes returns the expected response from the live tenant. WILL BE REMOVED BEFORE MERGE — kept in this PR only as a debugging aid so reviewers can reproduce the live-tenant verification if desired. Usage: set -a && source .env.adms && set +a .venv/bin/python scripts/adms_cli.py --- scripts/adms_cli.py | 1534 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1534 insertions(+) create mode 100755 scripts/adms_cli.py diff --git a/scripts/adms_cli.py b/scripts/adms_cli.py new file mode 100755 index 00000000..a2409e64 --- /dev/null +++ b/scripts/adms_cli.py @@ -0,0 +1,1534 @@ +#!/usr/bin/env python +"""Interactive CLI for testing the ADMS SDK. + +Exposes every public API on :class:`AdmsClient`: + + * ``client.documents`` — Document entity (list/get/update/download URL, + restore + soft-delete content versions). + * ``client.relations`` — DocumentRelation entity (list/get/delete, + presigned upload URLs, multipart completion, lock/unlock, + full draft lifecycle: create / validate / activate / discard). + * ``client.config`` — Tenant configuration (AllowedDomain, DocumentType, + BusinessObjectNodeType, DocumentType↔BO type maps — full CRUD). + * ``client.jobs`` — Async jobs (ZIP_DOWNLOAD, DELETE_USER_DATA, status poll). + +Usage: + # Load creds from .env.adms and run interactively + set -a && source .env.adms && set +a + .venv/bin/python scripts/adms_cli.py + + # Or pass a specific command directly (see "Commands" below) + .venv/bin/python scripts/adms_cli.py relations list + .venv/bin/python scripts/adms_cli.py documents update + .venv/bin/python scripts/adms_cli.py config maps + +Commands: + RELATIONS + relations list — list all DocumentRelations + relations get — get single relation + relations delete — delete a relation + relations create-draft — start a draft for a BO node + relations validate-draft — validate a draft before activation + relations activate-draft — activate a draft + relations discard-draft — discard a draft without activation + relations upload-urls — generate presigned upload URLs + relations complete-upload — finalise a multipart upload + relations lock — lock document & relation + relations unlock — release lock + DOCUMENTS + documents list — list all Documents + documents get — Document linked to a relation + documents update — update document metadata + documents download — presigned download URL + documents restore — restore a previous content version + documents delete-version — soft-delete a content version + CONFIG + config domains — list AllowedDomains + config domains-create — register an AllowedDomain + config domains-delete — remove an AllowedDomain + config doctypes — list DocumentTypes + config doctypes-create — create a DocumentType + config doctypes-delete — delete a DocumentType + config botypes — list BusinessObjectNodeTypes + config botypes-create — register a BusinessObjectNodeType + config botypes-delete — delete a BusinessObjectNodeType + config maps — list DocumentType ↔ BO type mappings + config maps-create — create a mapping + config maps-delete — delete a mapping + JOBS + jobs status — poll DocumentService job status + jobs zip — start ZIP_DOWNLOAD job + jobs delete-user-data — start DELETE_USER_DATA job (admin) +""" + +from __future__ import annotations + +import json +import os +import sys +import textwrap +from typing import Optional + +# ── ensure src/ is on the path when run from the repo root ────────────────── +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(_REPO_ROOT, "src")) + +from sap_cloud_sdk.adms.client import AdmsClient, create_client +from sap_cloud_sdk.adms.config import load_from_env_or_mount +from sap_cloud_sdk.adms.exceptions import ( + AdmsError, + DocumentNotFoundError, + HttpError, + ScanNotCleanError, +) +from sap_cloud_sdk.adms._models import ( + BaseType, + CreateAllowedDomainInput, + CreateApplicationTenantInput, + CreateBusinessObjectNodeTypeInput, + CreateDocumentInput, + CreateDocumentRelationInput, + CreateDocumentTypeBoTypeMapInput, + CreateDocumentTypeInput, + CreateFileExtensionPolicyInput, + DeleteUserDataJobParameters, + DraftActivateInput, + DraftInput, + MimeTypePolicy, + UpdateAllowedDomainInput, + UpdateBusinessObjectNodeTypeInput, + UpdateDocumentInput, + UpdateDocumentTypeInput, + ZipDownloadJobParameters, +) + +# ── pretty-printing helpers ────────────────────────────────────────────────── + +# Maps Python snake_case field names → OData PascalCase wire key names +# so CLI output matches Postman / Bruno responses exactly. +_WIRE_KEYS: dict[str, str] = { + # DocumentRelation + "document_relation_id": "DocumentRelationID", + "business_object_node_type_unique_id": "BusinessObjectNodeTypeUniqueID", + "host_business_object_node_id": "HostBusinessObjectNodeID", + "host_business_obj_node_display_id": "HostBusinessObjNodeDisplayID", + "document_id": "DocumentID", + "document_is_active_entity": "DocumentIsActiveEntity", + "is_active_entity": "IsActiveEntity", + "has_active_entity": "HasActiveEntity", + "has_draft_entity": "HasDraftEntity", + "document_relation_is_locked": "DocumentRelationIsLocked", + "document_relation_is_deleted": "DocumentRelationIsDeleted", + "document_relation_is_output_relevant": "DocumentRelationIsOutputRelevant", + "draft_messages": "DraftMessages", + "draft_administrative_data": "DraftAdministrativeData", + # DraftAdministrativeData fields + "draft_uuid": "DraftUUID", + "creation_date_time": "CreationDateTime", + "created_by_user": "CreatedByUser", + "draft_is_created_by_me": "DraftIsCreatedByMe", + "last_change_date_time": "LastChangeDateTime", + "last_changed_by_user": "LastChangedByUser", + "in_process_by_user": "InProcessByUser", + "draft_is_processed_by_me": "DraftIsProcessedByMe", + "doc_relation_created_by_user_name": "DocRelationCreatedByUserName", + "doc_relation_created_at_date_time": "DocRelationCreatedAtDateTime", + "doc_relation_changed_by_user_name": "DocRelationChangedByUserName", + "doc_relation_changed_at_date_time": "DocRelationChangedAtDateTime", + "document": "Document", # expanded DocumentRelation → Document nav property + # Document + "document_name": "DocumentName", + "document_base_type": "DocumentBaseType", + "document_type_id": "DocumentTypeID", + "document_state": "DocumentState", + "document_mime_type": "DocumentMimeType", + "document_description": "DocumentDescription", + "document_size_in_byte": "DocumentSizeInByte", + "document_content_stream_uri": "DocumentContentStreamURI", + "document_external_content_url": "DocumentExternalContentURL", + "document_is_locked": "DocumentIsLocked", + "document_is_soft_deleted": "DocumentIsSoftDeleted", + "has_active_document_entity": "HasActiveDocumentEntity", + "has_draft_document_entity": "HasDraftDocumentEntity", + "draft_uuid": "DraftUUID", + "document_content_upload_urls": "DocumentContentUploadURLs", + "document_is_multi_referenced": "DocumentIsMultiReferenced", + "document_created_by_user_name": "DocumentCreatedByUserName", + "document_created_at_date_time": "DocumentCreatedAtDateTime", + "document_changed_by_user_name": "DocumentChangedByUserName", + "document_changed_at_date_time": "DocumentChangedAtDateTime", + # DocumentContentVersion + "doc_content_version_id": "DocContentVersionID", + "doc_content_version_state": "DocContentVersionState", + "doc_content_version_name": "DocContentVersionName", + "doc_content_version_comment": "DocContentVersionComment", + "doc_content_version_is_latest": "DocContentVersionIsLatest", + "doc_content_version_mime_type": "DocContentVersionMimeType", + "doc_content_version_size_in_byte": "DocContentVersionSizeInByte", + "doc_content_version_stream_uri": "DocContentVersionStreamURI", + "doc_content_version_content_hash": "DocContentVersionContentHash", + "doc_content_version_upload_id": "DocContentVersionUploadID", + "doc_content_version_is_soft_deleted": "DocContentVersionIsSoftDeleted", + # AllowedDomain + "allowed_domain_id": "AllowedDomainID", + "allowed_domain_host_name": "AllowedDomainHostName", + "allowed_domain_protocol": "AllowedDomainProtocol", + "allowed_domain_port": "AllowedDomainPort", + # DocumentType + "document_type_name": "DocumentTypeName", + "document_type_description": "DocumentTypeDescription", + # BusinessObjectNodeType + "business_object_node_type": "BusinessObjectNodeType", + "business_object_node_type_name": "BusinessObjectNodeTypeName", + "odm_entity_name": "ODMEntityName", + "application_tenant_id": "ApplicationTenantID", + # DocumentTypeBusinessObjectTypeMap + "document_type_bo_type_map_id": "DocumentTypeBOTypeMapID", + "is_default": "IsDefault", + # JobOutput + "job_id": "JobID", + "job_status": "JobStatus", + "job_result": "JobResult", + "job_error_details": "JobErrorDetails", + "job_progress_percentage": "JobProgressPercentage", +} + + +def _to_jsonable(obj): + """Recursively convert dataclasses / enums to JSON-serialisable dicts. + + Uses Python snake_case field names — the SDK model as application code sees it. + """ + from enum import Enum + from dataclasses import fields, is_dataclass + + if isinstance(obj, Enum): + return obj.value + elif is_dataclass(obj) and not isinstance(obj, type): + return {f.name: _to_jsonable(getattr(obj, f.name)) for f in fields(obj)} + elif isinstance(obj, list): + return [_to_jsonable(i) for i in obj] + return obj + + +def _print_json(obj) -> None: + """Print a dataclass or dict as indented JSON.""" + print(json.dumps(_to_jsonable(obj), indent=2)) + + +def _print_list(items, label: str) -> None: + print(f"\n{'─' * 62}") + print(f" {label} ({len(items)} items)") + print(f"{'─' * 62}") + for item in items: + _print_json(item) + print() + + +# ── raw response capture ────────────────────────────────────────────────────── +# When raw mode is on, every HTTP response body is printed verbatim (wire JSON) +# before the parsed SDK model is shown — identical to Postman output. + +_raw_mode = False +_last_raw_responses: list[str] = [] + + +def _patch_http_for_raw(client: AdmsClient) -> None: + """Monkey-patch AdmsHttp._request to capture raw response bodies.""" + from sap_cloud_sdk.adms._http import AdmsHttp + + original_request = AdmsHttp._request + + def _capturing_request(self, method, path, **kwargs): + resp = original_request(self, method, path, **kwargs) + try: + body = resp.json() + _last_raw_responses.append(json.dumps(body, indent=2)) + except Exception: + _last_raw_responses.append(resp.text) + return resp + + AdmsHttp._request = _capturing_request # type: ignore[method-assign] + + +def _print_raw_if_enabled(label: str = "Raw API response") -> None: + """Print the most recent captured raw response(s) if raw mode is on.""" + if not _raw_mode or not _last_raw_responses: + return + print(f"\n ── {label} (wire JSON) ──") + for body in _last_raw_responses: + print(body) + print() + + +def _clear_raw_captures() -> None: + _last_raw_responses.clear() + + +def _prompt(prompt: str, default: Optional[str] = None) -> str: + value = input(f" {prompt}: ").strip() + if not value and default: + return default + return value + + +def _prompt_optional(prompt: str) -> Optional[str]: + """Prompt for an optional value — empty input returns None.""" + value = input(f" {prompt} (optional): ").strip() + return value or None + + +def _prompt_int(prompt: str, default: Optional[int] = None) -> Optional[int]: + label = f" (optional, default {default})" if default is not None else " (optional)" + raw = input(f" {prompt}{label}: ").strip() + if not raw: + return default + try: + return int(raw) + except ValueError: + _err(f" Invalid integer: {raw!r}. Skipping.") + return None + + +def _prompt_bool(prompt: str, default: bool = False) -> bool: + default_label = "y" if default else "n" + raw = input(f" {prompt} (y/n, default {default_label}): ").strip().lower() + if not raw: + return default + return raw in ("y", "yes", "1", "true") + + +def _confirm(question: str) -> bool: + return input(f"\n {question} (y/n): ").strip().lower() == "y" + + +def _ok(msg: str) -> None: + print(f"\n ✓ {msg}\n") + + +def _err(msg: str) -> None: + print(f"\n ✗ {msg}\n", file=sys.stderr) + + +def _err_exc(label: str, exc: Exception) -> None: + """Print a user-friendly error, including the raw ADM response body when available.""" + _err(f"{label}: {exc}") + if isinstance(exc, HttpError) and exc.response_text: + print(f" ADM response: {exc.response_text}\n", file=sys.stderr) + + +# ── build client ───────────────────────────────────────────────────────────── + + +def _build_client() -> AdmsClient: + try: + config = load_from_env_or_mount("default") + except Exception as exc: + _err(f"Could not load ADMS config: {exc}") + _err("Make sure you ran: set -a && source .env.adms && set +a") + sys.exit(1) + client = create_client(config=config) + print(f" Connected to: {config.service_url}") + return client + + +# ── RELATIONS handlers ─────────────────────────────────────────────────────── + + +def cmd_relations_list(client: AdmsClient) -> None: + print("\nFetching all DocumentRelations …") + items = client.relations.get_all() + _print_list(items, "DocumentRelations") + + +def cmd_relations_get(client: AdmsClient, relation_id: str) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + print(f"\nFetching DocumentRelation {relation_id} (IsActiveEntity={str(is_active).lower()}) …") + try: + rel = client.relations.get(relation_id, is_active_entity=is_active) + _print_json(rel) + except DocumentNotFoundError: + _err(f"Relation {relation_id!r} not found.") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_relations_delete(client: AdmsClient, relation_id: str) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + if not _confirm(f"Delete relation {relation_id!r} (IsActiveEntity={str(is_active).lower()})?"): + print(" Aborted.") + return + try: + client.relations.delete(relation_id, is_active_entity=is_active) + _ok(f"Deleted {relation_id}") + except DocumentNotFoundError: + _err(f"Relation {relation_id!r} not found.") + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +def _prompt_draft_input() -> Optional[DraftInput]: + bo_type_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") + bo_node_id = _prompt("HostBusinessObjectNodeID") + if not bo_type_id or not bo_node_id: + _err("Both fields are required.") + return None + return DraftInput( + business_object_node_type_unique_id=bo_type_id, + host_business_object_node_id=bo_node_id, + ) + + +def cmd_relations_create_draft(client: AdmsClient) -> None: + print("\n── Create draft DocumentRelations for a BO node ────────────") + draft = _prompt_draft_input() + if not draft: + return + try: + items = client.relations.create_draft(draft) + _print_list(items, "Draft DocumentRelations") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_relations_validate_draft(client: AdmsClient) -> None: + print("\n── Validate draft DocumentRelations ────────────────────────") + draft = _prompt_draft_input() + if not draft: + return + try: + items = client.relations.validate_draft(draft) + _print_list(items, "Validated draft DocumentRelations") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_relations_activate_draft(client: AdmsClient) -> None: + print("\n── Activate draft DocumentRelations ────────────────────────") + bo_type_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") + bo_node_id = _prompt("HostBusinessObjectNodeID") + if not bo_type_id or not bo_node_id: + _err("Both fields are required.") + return + late = _prompt_optional("LateHostBusinessObjectNodeID") + activate = DraftActivateInput( + business_object_node_type_unique_id=bo_type_id, + host_business_object_node_id=bo_node_id, + late_host_business_object_node_id=late, + ) + try: + items = client.relations.activate_draft(activate) + _print_list(items, "Activated DocumentRelations") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_relations_discard_draft(client: AdmsClient) -> None: + print("\n── Discard draft DocumentRelations ─────────────────────────") + draft = _prompt_draft_input() + if not draft: + return + if not _confirm("Discard the draft? This cannot be undone."): + print(" Aborted.") + return + try: + client.relations.discard_draft(draft) + _ok("Draft discarded.") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_relations_upload_urls(client: AdmsClient, relation_id: str) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + is_multipart = _prompt_bool("Multipart upload?", default=False) + no_of_parts = _prompt_int("Number of parts", default=1) or 1 + print(f"\nGenerating upload URLs for {relation_id} …") + try: + doc = client.relations.generate_upload_urls( + relation_id, + is_active_entity=is_active, + is_multipart=is_multipart, + no_of_parts=no_of_parts, + ) + _ok(f"Generated {len(doc.document_content_upload_urls)} upload URL(s).") + _print_json(doc) + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_relations_complete_upload(client: AdmsClient, relation_id: str) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + print(f"\nCompleting multipart upload for {relation_id} …") + try: + client.relations.complete_multipart_upload(relation_id, is_active_entity=is_active) + _ok("Multipart upload completed.") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_relations_lock(client: AdmsClient, relation_id: str) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + try: + client.relations.lock(relation_id, is_active_entity=is_active) + _ok(f"Locked {relation_id}.") + except AdmsError as exc: + _err_exc("Lock failed", exc) + + +def cmd_relations_unlock(client: AdmsClient, relation_id: str) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + try: + client.relations.unlock(relation_id, is_active_entity=is_active) + _ok(f"Unlocked {relation_id}.") + except AdmsError as exc: + _err_exc("Unlock failed", exc) + + +def cmd_relations_full_upload(client: AdmsClient, relation_id: str) -> None: + """Generate upload URLs, PUT file to GCS, then complete the upload.""" + import os + + file_path = _prompt("Local file path (absolute or relative)") + if not file_path or not os.path.isfile(file_path): + _err(f"File not found: {file_path!r}") + return + file_name = os.path.basename(file_path) + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + is_multipart = _prompt_bool("Multipart upload?", default=False) + no_of_parts = _prompt_int("Number of parts", default=1) or 1 + + print(f"\nStep 1 — Generating upload URL(s) for relation {relation_id} …") + try: + doc = client.relations.generate_upload_urls( + relation_id, + is_active_entity=is_active, + is_multipart=is_multipart, + no_of_parts=no_of_parts, + ) + except AdmsError as exc: + _err_exc("Failed to generate upload URLs", exc) + return + + urls = doc.document_content_upload_urls + if not urls: + _err("No upload URLs returned by ADM.") + return + _ok(f"Got {len(urls)} URL(s).") + upload_url = urls[0] + + # The x-goog-meta-filename value was baked into the GCS signature by ADM + # at URL-generation time. We must send the document name ADM registered, + # not the local file name — a mismatch causes SignatureDoesNotMatch. + adm_filename = doc.document_name or file_name + + print(f"\nStep 2 — Uploading {file_name!r} as '{adm_filename}' to GCS …") + try: + import urllib.request as _urllib_request + + with open(file_path, "rb") as f: + file_bytes = f.read() + + req = _urllib_request.Request( + upload_url, + data=file_bytes, + method="PUT", + headers={"x-goog-meta-filename": adm_filename}, + ) + try: + with _urllib_request.urlopen(req, timeout=120) as response: + status = response.status + except _urllib_request.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + _err(f"GCS upload failed: HTTP {exc.code} — {body[:400]}") + return + if status not in (200, 204): + _err(f"GCS upload failed: HTTP {status}") + return + _ok(f"Uploaded {len(file_bytes):,} bytes.") + except Exception as exc: + _err(f"Upload error: {exc}") + return + + if is_multipart: + print(f"\nStep 3 — Completing multipart upload for {relation_id} …") + try: + client.relations.complete_multipart_upload(relation_id, is_active_entity=is_active) + _ok("Multipart upload completed.") + except AdmsError as exc: + _err_exc("Complete upload failed", exc) + else: + _ok("Single-part upload complete — no rcu step needed.") + + +def cmd_relations_delete_bo_node(client: AdmsClient) -> None: + print("\n── Delete all DocumentRelations for a BO node ──────────────") + print(" ⚠ This permanently deletes ALL relations for the given BO node.") + draft = _prompt_draft_input() + if not draft: + return + if not _confirm("Delete ALL relations for this BO node? This is irreversible."): + print(" Aborted.") + return + try: + result = client.relations.delete_business_object_node(draft) + _ok(f"Deleted {result.relations_deleted} relation(s).") + _print_json(result) + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +def cmd_relations_change_logs(client: AdmsClient) -> None: + print("\nFetching ChangeLog …") + items = client.relations.get_change_logs() + _print_list(items, "ChangeLog") + + +def cmd_relations_bo_change_logs(client: AdmsClient) -> None: + print("\nFetching BusinessObjectNodeChangeLog …") + items = client.relations.get_bo_node_change_logs() + _print_list(items, "BusinessObjectNodeChangeLog") + + +# ── DOCUMENTS handlers ─────────────────────────────────────────────────────── + + +def cmd_documents_list(client: AdmsClient) -> None: + print("\nFetching all Documents (via DocumentRelation?$expand=Document) …") + items = client.documents.get_all() + _print_list(items, "Documents") + + +def cmd_documents_get(client: AdmsClient, relation_id: str) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + print(f"\nFetching Document via relation {relation_id} (IsActiveEntity={str(is_active).lower()}) …") + try: + doc = client.documents.get(relation_id, is_active_entity=is_active) + _print_json(doc) + except DocumentNotFoundError: + _err(f"No document found for relation {relation_id!r}.") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_documents_update(client: AdmsClient, relation_id: str) -> None: + print("\n── Update Document metadata ─────────────────────────────────") + print(" Leave any field blank to leave it unchanged.") + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + update = UpdateDocumentInput( + document_name=_prompt_optional("New document name"), + document_description=_prompt_optional("New description"), + document_type_id=_prompt_optional("New DocumentTypeID"), + doc_content_version_comment=_prompt_optional("Content version comment"), + document_external_content_url=_prompt_optional("New external URL"), + ) + if not any(v is not None for v in update.__dict__.values()): + _err("Nothing to update — all fields blank.") + return + try: + doc = client.documents.update(relation_id, update, is_active_entity=is_active) + _ok("Document updated.") + _print_json(doc) + except AdmsError as exc: + _err_exc("Update failed", exc) + + +def cmd_documents_download(client: AdmsClient, relation_id: str) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + version = _prompt("DocContentVersionID", default="1.0") + print("\nFetching presigned download URL …") + try: + url = client.documents.get_download_url( + document_relation_id=relation_id, + is_active_entity=is_active, + doc_content_version_id=version, + ) + _ok("Presigned URL (valid for a short time — do not cache):") + print(f" {url}\n") + except ScanNotCleanError as exc: + _err_exc("Download blocked — scan not CLEAN", exc) + except DocumentNotFoundError: + _err(f"Relation {relation_id!r} not found.") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_documents_restore( + client: AdmsClient, relation_id: str, version_id: str +) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + comment = _prompt_optional("Comment (optional)") + print(f"\nRestoring version {version_id} on {relation_id} …") + try: + doc = client.documents.restore_content_version( + relation_id, version_id, is_active_entity=is_active, comment=comment + ) + _ok(f"Restored version {version_id}.") + _print_json(doc) + except AdmsError as exc: + _err_exc("Restore failed", exc) + + +def cmd_documents_delete_version( + client: AdmsClient, relation_id: str, version_id: str +) -> None: + is_active = _prompt_bool("Active entity? (No = draft)", default=True) + if not _confirm(f"Soft-delete version {version_id} on relation {relation_id} (IsActiveEntity={str(is_active).lower()})?"): + print(" Aborted.") + return + try: + client.documents.delete_content_version(relation_id, version_id, is_active_entity=is_active) + _ok(f"Deleted version {version_id}.") + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +# ── CONFIGURATION handlers ─────────────────────────────────────────────────── + + +def cmd_config_domains(client: AdmsClient) -> None: + print("\nFetching AllowedDomains …") + items = client.config.get_all_allowed_domains() + _print_list(items, "AllowedDomains") + + +def cmd_config_domains_create(client: AdmsClient) -> None: + print("\n── Create AllowedDomain ────────────────────────────────────") + host = _prompt("Hostname (e.g. storage.example.com)") + proto = _prompt("Protocol", default="https") + port = _prompt_int("Port (blank = protocol default)") + if not host or not proto: + _err("Hostname and protocol are required.") + return + try: + out = client.config.create_allowed_domain( + CreateAllowedDomainInput(host_name=host, protocol=proto, port=port) + ) + _ok(f"Created AllowedDomain {out.allowed_domain_id}") + _print_json(out) + except AdmsError as exc: + _err_exc("Create failed", exc) + + +def cmd_config_domains_get(client: AdmsClient, allowed_domain_id: str) -> None: + print(f"\nFetching AllowedDomain {allowed_domain_id} …") + try: + out = client.config.get_allowed_domain(allowed_domain_id) + _print_json(out) + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_config_domains_update(client: AdmsClient, allowed_domain_id: str) -> None: + print("\n── Update AllowedDomain ────────────────────────────────────") + print(" Leave any field blank to leave it unchanged.") + update = UpdateAllowedDomainInput( + host_name=_prompt_optional("New hostname"), + protocol=_prompt_optional("New protocol (https/http)"), + port=_prompt_int("New port (blank = unchanged)"), + ) + if not any(v is not None for v in update.__dict__.values()): + _err("Nothing to update — all fields blank.") + return + try: + out = client.config.update_allowed_domain(allowed_domain_id, update) + _ok(f"Updated AllowedDomain {out.allowed_domain_id}.") + _print_json(out) + except AdmsError as exc: + _err_exc("Update failed", exc) + + +def cmd_config_domains_delete(client: AdmsClient, allowed_domain_id: str) -> None: + if not _confirm(f"Delete AllowedDomain {allowed_domain_id!r}?"): + print(" Aborted.") + return + try: + client.config.delete_allowed_domain(allowed_domain_id) + _ok(f"Deleted {allowed_domain_id}.") + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +def cmd_config_doctypes(client: AdmsClient) -> None: + print("\nFetching DocumentTypes …") + items = client.config.get_all_document_types() + _print_list(items, "DocumentTypes") + + +def cmd_config_doctypes_create(client: AdmsClient) -> None: + print("\n── Create DocumentType ─────────────────────────────────────") + type_id = _prompt("DocumentTypeID (max 10 chars)") + name = _prompt("DocumentTypeName") + desc = _prompt_optional("Description") + if not type_id or not name: + _err("DocumentTypeID and DocumentTypeName are required.") + return + try: + out = client.config.create_document_type( + CreateDocumentTypeInput( + document_type_id=type_id, + document_type_name=name, + document_type_description=desc, + ) + ) + _ok(f"Created DocumentType {out.document_type_id}.") + _print_json(out) + except AdmsError as exc: + _err_exc("Create failed", exc) + + +def cmd_config_doctypes_get(client: AdmsClient, document_type_id: str) -> None: + print(f"\nFetching DocumentType {document_type_id} …") + try: + out = client.config.get_document_type(document_type_id) + _print_json(out) + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_config_doctypes_update(client: AdmsClient, document_type_id: str) -> None: + print("\n── Update DocumentType ─────────────────────────────────────") + print(" Leave any field blank to leave it unchanged.") + update = UpdateDocumentTypeInput( + document_type_name=_prompt_optional("New DocumentTypeName"), + document_type_description=_prompt_optional("New description"), + ) + if not any(v is not None for v in update.__dict__.values()): + _err("Nothing to update — all fields blank.") + return + try: + out = client.config.update_document_type(document_type_id, update) + _ok(f"Updated DocumentType {out.document_type_id}.") + _print_json(out) + except AdmsError as exc: + _err_exc("Update failed", exc) + + +def cmd_config_doctypes_delete(client: AdmsClient, document_type_id: str) -> None: + if not _confirm(f"Delete DocumentType {document_type_id!r}?"): + print(" Aborted.") + return + try: + client.config.delete_document_type(document_type_id) + _ok(f"Deleted {document_type_id}.") + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +def cmd_config_botypes(client: AdmsClient) -> None: + print("\nFetching BusinessObjectNodeTypes …") + items = client.config.get_all_business_object_types() + _print_list(items, "BusinessObjectNodeTypes") + + +def cmd_config_botypes_create(client: AdmsClient) -> None: + print("\n── Create BusinessObjectNodeType ───────────────────────────") + bo_type = _prompt("BusinessObjectNodeType (short code, e.g. PO)") + bo_name = _prompt("BusinessObjectNodeTypeName") + tenant_id = _prompt("ApplicationTenantID (UUID of the owning tenant)") + if not bo_type or not bo_name or not tenant_id: + _err("BusinessObjectNodeType, name, and ApplicationTenantID are required.") + return + try: + out = client.config.create_business_object_type( + CreateBusinessObjectNodeTypeInput( + business_object_node_type=bo_type, + business_object_node_type_name=bo_name, + application_tenant_id=tenant_id, + ) + ) + _ok( + f"Created BusinessObjectNodeType {out.business_object_node_type_unique_id}." + ) + _print_json(out) + except AdmsError as exc: + _err_exc("Create failed", exc) + + +def cmd_config_botypes_get(client: AdmsClient, bo_type_unique_id: str) -> None: + print(f"\nFetching BusinessObjectNodeType {bo_type_unique_id} …") + try: + out = client.config.get_business_object_type(bo_type_unique_id) + _print_json(out) + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_config_botypes_update(client: AdmsClient, bo_type_unique_id: str) -> None: + print("\n── Update BusinessObjectNodeType ───────────────────────────") + print(" Leave any field blank to leave it unchanged.") + update = UpdateBusinessObjectNodeTypeInput( + business_object_node_type=_prompt_optional("New BusinessObjectNodeType code"), + business_object_node_type_name=_prompt_optional("New BusinessObjectNodeTypeName"), + ) + if not any(v is not None for v in update.__dict__.values()): + _err("Nothing to update — all fields blank.") + return + try: + out = client.config.update_business_object_type(bo_type_unique_id, update) + _ok(f"Updated BusinessObjectNodeType {out.business_object_node_type_unique_id}.") + _print_json(out) + except AdmsError as exc: + _err_exc("Update failed", exc) + + +def cmd_config_botypes_delete(client: AdmsClient, bo_type_unique_id: str) -> None: + if not _confirm(f"Delete BusinessObjectNodeType {bo_type_unique_id!r}?"): + print(" Aborted.") + return + try: + client.config.delete_business_object_type(bo_type_unique_id) + _ok(f"Deleted {bo_type_unique_id}.") + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +def cmd_config_maps(client: AdmsClient) -> None: + print("\nFetching DocumentType ↔ BusinessObjectNodeType mappings …") + items = client.config.get_type_mappings() + _print_list(items, "DocumentTypeBusinessObjectTypeMaps") + + +def cmd_config_maps_create(client: AdmsClient) -> None: + print("\n── Create DocumentType ↔ BO type mapping ───────────────────") + bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") + doc_type_id = _prompt("DocumentTypeID") + is_default = _prompt_bool("Mark as default for this BO type?", default=False) + if not bo_unique_id or not doc_type_id: + _err("Both IDs are required.") + return + try: + out = client.config.create_type_mapping( + CreateDocumentTypeBoTypeMapInput( + business_object_node_type_unique_id=bo_unique_id, + document_type_id=doc_type_id, + is_default=is_default, + ) + ) + _ok(f"Created mapping {out.document_type_bo_type_map_id}.") + _print_json(out) + except AdmsError as exc: + _err_exc("Create failed", exc) + + +def cmd_config_maps_get(client: AdmsClient, mapping_id: str) -> None: + print(f"\nFetching mapping {mapping_id} …") + try: + out = client.config.get_type_mapping(mapping_id) + _print_json(out) + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_config_maps_delete(client: AdmsClient, mapping_id: str) -> None: + if not _confirm(f"Delete mapping {mapping_id!r}?"): + print(" Aborted.") + return + try: + client.config.delete_type_mapping(mapping_id) + _ok(f"Deleted {mapping_id}.") + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +def cmd_config_maps_mark_default(client: AdmsClient, mapping_id: str) -> None: + try: + client.config.mark_default(mapping_id) + _ok(f"Mapping {mapping_id} marked as default.") + except AdmsError as exc: + _err_exc("Failed", exc) + + +# ── CONFIG: FileExtensionPolicy ─────────────────────────────────────────────── + + +def cmd_config_file_ext_list(client: AdmsClient) -> None: + print("\nFetching FileExtensionPolicies …") + items = client.config.get_all_file_extension_policies() + _print_list(items, "FileExtensionPolicies") + + +def cmd_config_file_ext_create(client: AdmsClient) -> None: + print("\n── Create FileExtensionPolicy ──────────────────────────────") + ext = _prompt("File extension (e.g. pdf, exe)") + print(" Policy option:") + print(" A — Allow") + print(" B — Block") + policy_raw = _prompt("Policy (A/B)", default="A").upper() + try: + policy = MimeTypePolicy(policy_raw) + except ValueError: + _err(f"Invalid policy {policy_raw!r} — must be A or B.") + return + if not ext: + _err("File extension is required.") + return + try: + out = client.config.create_file_extension_policy( + CreateFileExtensionPolicyInput( + file_extension_policy_option=policy, + file_extension=ext, + ) + ) + _ok(f"Created FileExtensionPolicy {out.file_extension_policy_id}.") + _print_json(out) + except AdmsError as exc: + _err_exc("Create failed", exc) + + +def cmd_config_file_ext_get(client: AdmsClient, policy_id: str) -> None: + print(f"\nFetching FileExtensionPolicy {policy_id} …") + try: + out = client.config.get_file_extension_policy(policy_id) + _print_json(out) + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_config_file_ext_delete(client: AdmsClient, policy_id: str) -> None: + if not _confirm(f"Delete FileExtensionPolicy {policy_id!r}?"): + print(" Aborted.") + return + try: + client.config.delete_file_extension_policy(policy_id) + _ok(f"Deleted {policy_id}.") + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +# ── CONFIG: ApplicationTenant ───────────────────────────────────────────────── + + +def cmd_config_tenant_list(client: AdmsClient) -> None: + subaccount_id = _prompt_optional("Subaccount ID (x-subaccount-id header)") + print("\nFetching ApplicationTenants …") + items = client.config.get_all_application_tenants(subaccount_id=subaccount_id) + _print_list(items, "ApplicationTenants") + + +def cmd_config_tenant_create(client: AdmsClient) -> None: + print("\n── Create ApplicationTenant ────────────────────────────────") + tenant_id = _prompt("ApplicationTenantID") + tenant_name = _prompt("ApplicationTenantName") + subaccount_id = _prompt("Subaccount ID (x-subaccount-id header)") + if not tenant_id or not tenant_name or not subaccount_id: + _err("ApplicationTenantID, name, and Subaccount ID are required.") + return + try: + out = client.config.create_application_tenant( + CreateApplicationTenantInput( + application_tenant_id=tenant_id, + application_tenant_name=tenant_name, + ), + subaccount_id=subaccount_id, + ) + _ok(f"Created ApplicationTenant {out.application_tenant_id}.") + _print_json(out) + except AdmsError as exc: + _err_exc("Create failed", exc) + + +def cmd_config_tenant_get(client: AdmsClient, tenant_id: str) -> None: + subaccount_id = _prompt_optional("Subaccount ID (x-subaccount-id header)") + print(f"\nFetching ApplicationTenant {tenant_id} …") + try: + out = client.config.get_application_tenant(tenant_id, subaccount_id=subaccount_id) + _print_json(out) + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_config_tenant_delete(client: AdmsClient, tenant_id: str) -> None: + subaccount_id = _prompt_optional("Subaccount ID (x-subaccount-id header)") + if not _confirm(f"Delete ApplicationTenant {tenant_id!r}?"): + print(" Aborted.") + return + try: + client.config.delete_application_tenant(tenant_id, subaccount_id=subaccount_id) + _ok(f"Deleted {tenant_id}.") + except AdmsError as exc: + _err_exc("Delete failed", exc) + + +# ── JOBS handlers ──────────────────────────────────────────────────────────── + + +def cmd_jobs_status( + client: AdmsClient, job_id: str, *, use_admin_service: bool = False +) -> None: + service = "AdminService" if use_admin_service else "DocumentService" + print(f"\nFetching {service} status for job {job_id} …") + try: + output = client.jobs.get_status(job_id, use_admin_service=use_admin_service) + _print_json(output) + if output.job_status and output.job_status.is_terminal(): + _ok(f"Job is in terminal state: {output.job_status.value}") + else: + print(f" ⟳ Job still running: {output.job_status}") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_jobs_zip(client: AdmsClient) -> None: + print("\n── Start ZIP_DOWNLOAD job ──────────────────────────────────") + bo_type_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") + bo_node_id = _prompt("HostBusinessObjectNodeID") + if not bo_type_id or not bo_node_id: + _err("Both fields are required.") + return + rel_ids_raw = _prompt_optional( + "DocumentRelationIDs (comma-separated UUIDs, blank = all)" + ) + rel_ids = ( + [r.strip() for r in rel_ids_raw.split(",") if r.strip()] + if rel_ids_raw + else [] + ) + print(f"\nStarting ZIP_DOWNLOAD job for {bo_node_id} …") + try: + output = client.jobs.start_zip_download( + ZipDownloadJobParameters( + business_object_node_type_unique_id=bo_type_id, + host_business_object_node_id=bo_node_id, + document_relation_ids=rel_ids, + ) + ) + _ok(f"Job started: {output.job_id} status={output.job_status}") + _print_json(output) + print(f" → Poll with: js (Job ID: {output.job_id})") + except AdmsError as exc: + _err_exc("Failed", exc) + + +def cmd_jobs_delete_user_data(client: AdmsClient) -> None: + print("\n── Start DELETE_USER_DATA job (AdminService) ───────────────") + print(" ⚠ This is a GDPR erasure operation and is irreversible.") + user_id = _prompt("UserID to erase") + if not user_id: + _err("UserID is required.") + return + replacement = _prompt_optional("ReplacementUserID (default: SYSTEM)") + if not _confirm(f"Erase all references to user {user_id!r}? This is irreversible."): + print(" Aborted.") + return + try: + output = client.jobs.start_delete_user_data( + DeleteUserDataJobParameters( + user_id=user_id, + replacement_user_id=replacement, + ) + ) + _ok(f"Job started: {output.job_id} status={output.job_status}") + _print_json(output) + print(f" → Poll with: js (Job ID: {output.job_id})") + except AdmsError as exc: + _err_exc("Failed", exc) + + +# ── interactive menu ────────────────────────────────────────────────────────── + +_MENU = textwrap.dedent(""" + ┌──────────────────────────────────────────────────────────────────┐ + │ ADMS Interactive CLI │ + ├──────────────────────────────────────────────────────────────────┤ + │ RELATIONS (AdmsRelationsClientApi) │ + │ rl — list all DocumentRelations │ + │ rg — get relation by ID │ + │ rd — delete relation by ID │ + │ rcd — createDraft (BO type + node ID) │ + │ rvd — validateDraft (BO type + node ID) │ + │ rad — activateDraft (BO type + node ID) │ + │ rdd — discardDraft (BO type + node ID) │ + │ ru — generateUploadUrls (relation ID) │ + │ rfu — fullUpload: generate URL + PUT file + complete │ + │ rcu — completeMultipartUpload (relation ID) │ + │ rlk — lock relation │ + │ ruk — unlock relation │ + │ rbn — deleteBusinessObjectNode [irreversible!] │ + │ rcl — getChangeLog (all changes) │ + │ rbl — getBusinessObjectNodeChangeLog │ + ├──────────────────────────────────────────────────────────────────┤ + │ DOCUMENTS (AdmsDocumentsClientApi) │ + │ dl — list all Documents │ + │ dg — get Document by relation ID │ + │ du — update Document (rename) │ + │ dd — getDownloadUrl (pre-signed URL) │ + │ drv — restoreContentVersion │ + │ ddv — deleteContentVersion │ + ├──────────────────────────────────────────────────────────────────┤ + │ CONFIGURATION (AdmsConfigClientApi) │ + │ cd — list AllowedDomains │ + │ cdg — get AllowedDomain by ID │ + │ cda — create AllowedDomain │ + │ cdu — update AllowedDomain │ + │ cdd — delete AllowedDomain │ + │ ct — list DocumentTypes │ + │ ctg — get DocumentType by ID │ + │ cta — create DocumentType │ + │ ctu — update DocumentType │ + │ ctd — delete DocumentType │ + │ cb — list BusinessObjectNodeTypes │ + │ cbg — get BusinessObjectNodeType by ID │ + │ cba — create BusinessObjectNodeType │ + │ cbu — update BusinessObjectNodeType │ + │ cbd — delete BusinessObjectNodeType │ + │ cm — list DocType ↔ BOType mappings │ + │ cmg — get mapping by ID │ + │ cma — create mapping │ + │ cmd — delete mapping │ + │ cmk — markDefault (mapping ID) │ + │ cfl — list FileExtensionPolicies │ + │ cfg — get FileExtensionPolicy by ID │ + │ cfc — create FileExtensionPolicy │ + │ cfd — delete FileExtensionPolicy │ + │ cal — list ApplicationTenants │ + │ cag — get ApplicationTenant by ID │ + │ cac — create ApplicationTenant │ + │ cad — delete ApplicationTenant │ + ├──────────────────────────────────────────────────────────────────┤ + │ JOBS (AdmsJobsClientApi) │ + │ js — getStatus (job ID) │ + │ jz — startZipDownload (relation IDs CSV) │ + │ jd — startDeleteUserData (user ID) [GDPR — irreversible!] │ + ├──────────────────────────────────────────────────────────────────┤ + │ OTHER │ + │ raw — toggle raw wire JSON output (shows exact Postman response) │ + │ ? — re-print this menu │ + │ q — quit │ + └──────────────────────────────────────────────────────────────────┘ +""") + + +def _interactive(client: AdmsClient) -> None: + global _raw_mode + print(_MENU) + while True: + try: + choice = input("adms> ").strip().lower() + except (EOFError, KeyboardInterrupt): + print("\nBye.") + break + + if not choice: + continue + elif choice in ("q", "quit", "exit"): + print("Bye.") + break + elif choice in ("?", "help", "h"): + print(_MENU) + elif choice == "raw": + _raw_mode = not _raw_mode + state = "ON — wire JSON printed after each response" if _raw_mode else "OFF — SDK model printed only" + print(f"\n Raw mode: {state}\n") + continue + + _clear_raw_captures() + + # ── relations ── + if choice == "rl": + cmd_relations_list(client) + elif choice == "rg": + rid = _prompt("Relation ID") + if rid: + cmd_relations_get(client, rid) + elif choice == "rd": + rid = _prompt("Relation ID to delete") + if rid: + cmd_relations_delete(client, rid) + elif choice == "rcd": + cmd_relations_create_draft(client) + elif choice == "rvd": + cmd_relations_validate_draft(client) + elif choice == "rad": + cmd_relations_activate_draft(client) + elif choice == "rdd": + cmd_relations_discard_draft(client) + elif choice == "ru": + rid = _prompt("Relation ID") + if rid: + cmd_relations_upload_urls(client, rid) + elif choice == "rcu": + rid = _prompt("Relation ID") + if rid: + cmd_relations_complete_upload(client, rid) + elif choice == "rlk": + rid = _prompt("Relation ID to lock") + if rid: + cmd_relations_lock(client, rid) + elif choice == "ruk": + rid = _prompt("Relation ID to unlock") + if rid: + cmd_relations_unlock(client, rid) + elif choice == "rfu": + rid = _prompt("Relation ID") + if rid: + cmd_relations_full_upload(client, rid) + elif choice == "rbn": + cmd_relations_delete_bo_node(client) + elif choice == "rcl": + cmd_relations_change_logs(client) + elif choice == "rbl": + cmd_relations_bo_change_logs(client) + + # ── documents ── + elif choice == "dl": + cmd_documents_list(client) + elif choice == "dg": + rid = _prompt("Relation ID") + if rid: + cmd_documents_get(client, rid) + elif choice == "du": + rid = _prompt("Relation ID") + if rid: + cmd_documents_update(client, rid) + elif choice == "dd": + rid = _prompt("Relation ID") + if rid: + cmd_documents_download(client, rid) + elif choice == "drv": + rid = _prompt("Relation ID") + ver = _prompt("DocContentVersionID to restore", default="1.0") + if rid and ver: + cmd_documents_restore(client, rid, ver) + elif choice == "ddv": + rid = _prompt("Relation ID") + ver = _prompt("DocContentVersionID to delete") + if rid and ver: + cmd_documents_delete_version(client, rid, ver) + + # ── config: AllowedDomain ── + elif choice == "cd": + cmd_config_domains(client) + elif choice == "cdg": + did = _prompt("AllowedDomainID") + if did: + cmd_config_domains_get(client, did) + elif choice == "cda": + cmd_config_domains_create(client) + elif choice == "cdu": + did = _prompt("AllowedDomainID to update") + if did: + cmd_config_domains_update(client, did) + elif choice == "cdd": + did = _prompt("AllowedDomainID to delete") + if did: + cmd_config_domains_delete(client, did) + + # ── config: DocumentType ── + elif choice == "ct": + cmd_config_doctypes(client) + elif choice == "ctg": + tid = _prompt("DocumentTypeID") + if tid: + cmd_config_doctypes_get(client, tid) + elif choice == "cta": + cmd_config_doctypes_create(client) + elif choice == "ctu": + tid = _prompt("DocumentTypeID to update") + if tid: + cmd_config_doctypes_update(client, tid) + elif choice == "ctd": + tid = _prompt("DocumentTypeID to delete") + if tid: + cmd_config_doctypes_delete(client, tid) + + # ── config: BusinessObjectNodeType ── + elif choice == "cb": + cmd_config_botypes(client) + elif choice == "cbg": + bid = _prompt("BusinessObjectNodeTypeUniqueID") + if bid: + cmd_config_botypes_get(client, bid) + elif choice == "cba": + cmd_config_botypes_create(client) + elif choice == "cbu": + bid = _prompt("BusinessObjectNodeTypeUniqueID to update") + if bid: + cmd_config_botypes_update(client, bid) + elif choice == "cbd": + bid = _prompt("BusinessObjectNodeTypeUniqueID to delete") + if bid: + cmd_config_botypes_delete(client, bid) + + # ── config: type mappings ── + elif choice == "cm": + cmd_config_maps(client) + elif choice == "cmg": + mid = _prompt("DocumentTypeBOTypeMapID") + if mid: + cmd_config_maps_get(client, mid) + elif choice == "cma": + cmd_config_maps_create(client) + elif choice == "cmd": + mid = _prompt("DocumentTypeBOTypeMapID to delete") + if mid: + cmd_config_maps_delete(client, mid) + elif choice == "cmk": + mid = _prompt("DocumentTypeBOTypeMapID to mark as default") + if mid: + cmd_config_maps_mark_default(client, mid) + + # ── config: FileExtensionPolicy ── + elif choice == "cfl": + cmd_config_file_ext_list(client) + elif choice == "cfg": + pid = _prompt("FileExtensionPolicyID") + if pid: + cmd_config_file_ext_get(client, pid) + elif choice == "cfc": + cmd_config_file_ext_create(client) + elif choice == "cfd": + pid = _prompt("FileExtensionPolicyID to delete") + if pid: + cmd_config_file_ext_delete(client, pid) + + # ── config: ApplicationTenant ── + elif choice == "cal": + cmd_config_tenant_list(client) + elif choice == "cag": + tid = _prompt("ApplicationTenantID") + if tid: + cmd_config_tenant_get(client, tid) + elif choice == "cac": + cmd_config_tenant_create(client) + elif choice == "cad": + tid = _prompt("ApplicationTenantID to delete") + if tid: + cmd_config_tenant_delete(client, tid) + + # ── jobs ── + elif choice == "js": + job_id = _prompt("Job ID") + if job_id: + cmd_jobs_status(client, job_id, use_admin_service=False) + elif choice == "jz": + cmd_jobs_zip(client) + elif choice == "jd": + cmd_jobs_delete_user_data(client) + + else: + print( + f" Unknown command: {choice!r} (type '?' for the menu, 'q' to quit)" + ) + continue + + _print_raw_if_enabled() + + +# ── CLI argument dispatch ───────────────────────────────────────────────────── + + +def _cli(client: AdmsClient, args: list[str]) -> None: + if not args: + _interactive(client) + return + + cmd = args[0] + + if cmd == "relations": + sub = args[1] if len(args) > 1 else "" + if sub == "list": + cmd_relations_list(client) + elif sub == "get" and len(args) > 2: + cmd_relations_get(client, args[2]) + elif sub == "delete" and len(args) > 2: + cmd_relations_delete(client, args[2]) + elif sub == "create-draft": + cmd_relations_create_draft(client) + elif sub == "validate-draft": + cmd_relations_validate_draft(client) + elif sub == "activate-draft": + cmd_relations_activate_draft(client) + elif sub == "discard-draft": + cmd_relations_discard_draft(client) + elif sub == "upload-urls" and len(args) > 2: + cmd_relations_upload_urls(client, args[2]) + elif sub == "complete-upload" and len(args) > 2: + cmd_relations_complete_upload(client, args[2]) + elif sub == "lock" and len(args) > 2: + cmd_relations_lock(client, args[2]) + elif sub == "unlock" and len(args) > 2: + cmd_relations_unlock(client, args[2]) + else: + _err( + "Usage: relations list | get | delete " + "| create-draft | validate-draft | activate-draft | discard-draft " + "| upload-urls | complete-upload | lock | unlock " + ) + + elif cmd == "documents": + sub = args[1] if len(args) > 1 else "" + if sub == "list": + cmd_documents_list(client) + elif sub == "get" and len(args) > 2: + cmd_documents_get(client, args[2]) + elif sub == "update" and len(args) > 2: + cmd_documents_update(client, args[2]) + elif sub == "download" and len(args) > 2: + cmd_documents_download(client, args[2]) + elif sub == "restore" and len(args) > 3: + cmd_documents_restore(client, args[2], args[3]) + elif sub == "delete-version" and len(args) > 3: + cmd_documents_delete_version(client, args[2], args[3]) + else: + _err( + "Usage: documents list | get | update | download " + "| restore | delete-version " + ) + + elif cmd == "config": + sub = args[1] if len(args) > 1 else "" + if sub == "domains": + cmd_config_domains(client) + elif sub == "domains-create": + cmd_config_domains_create(client) + elif sub == "domains-delete" and len(args) > 2: + cmd_config_domains_delete(client, args[2]) + elif sub == "doctypes": + cmd_config_doctypes(client) + elif sub == "doctypes-create": + cmd_config_doctypes_create(client) + elif sub == "doctypes-delete" and len(args) > 2: + cmd_config_doctypes_delete(client, args[2]) + elif sub == "botypes": + cmd_config_botypes(client) + elif sub == "botypes-create": + cmd_config_botypes_create(client) + elif sub == "botypes-delete" and len(args) > 2: + cmd_config_botypes_delete(client, args[2]) + elif sub == "maps": + cmd_config_maps(client) + elif sub == "maps-create": + cmd_config_maps_create(client) + elif sub == "maps-delete" and len(args) > 2: + cmd_config_maps_delete(client, args[2]) + else: + _err( + "Usage: config domains[-create|-delete ] " + "| doctypes[-create|-delete ] " + "| botypes[-create|-delete ] | maps[-create|-delete ]" + ) + + elif cmd == "jobs": + sub = args[1] if len(args) > 1 else "" + if sub == "status" and len(args) > 2: + cmd_jobs_status(client, args[2], use_admin_service=False) + elif sub == "zip": + cmd_jobs_zip(client) + elif sub == "delete-user-data": + cmd_jobs_delete_user_data(client) + else: + _err( + "Usage: jobs status | jobs zip | jobs delete-user-data" + ) + + else: + _err(f"Unknown command: {cmd!r}") + print("Run without arguments for the interactive menu.") + + +# ── entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + _client = _build_client() + _patch_http_for_raw(_client) + _cli(_client, sys.argv[1:]) From 14f86fe2938bf8ee0802a6f0bb3cec1be0964a3e Mon Sep 17 00:00:00 2001 From: I766515 Date: Wed, 1 Jul 2026 10:45:23 +0530 Subject: [PATCH 03/13] fix(adms): align API surface with live ADMS wire format - Remove MimeTypePolicy from __init__.py (renamed to FileExtensionPolicy in _models.py) - Rename FileExtensionPolicy entity set to DocumentTypeFileExtensionPolicy - Drop get_file_extension_policy by UUID; delete now uses composite key (DocumentTypeID + FileExtension) - Fix build_doctype_botype_map_key_path to use composite key (DocumentTypeID + BusinessObjectNodeTypeUniqueID) - Update get_type_mapping / delete_type_mapping / mark_default to accept composite key params - Make doc_content_version_id optional in get_download_url (omit parentheses content when None) - Drop post-update GET in update_document; return partial response from UpdateDocument action directly - Fix X-SubaccountId header casing - Align job API namespace constants with canonical server wire format - Expand adms_cli.py with rfu (full upload) command and additional fixes from live tenant testing --- scripts/adms_cli.py | 415 +++++++++++-------- src/sap_cloud_sdk/adms/__init__.py | 2 - src/sap_cloud_sdk/adms/_configuration_api.py | 77 ++-- src/sap_cloud_sdk/adms/_document_api.py | 48 +-- src/sap_cloud_sdk/adms/_http.py | 7 +- src/sap_cloud_sdk/adms/_job_api.py | 23 +- src/sap_cloud_sdk/adms/_models.py | 62 +-- 7 files changed, 327 insertions(+), 307 deletions(-) diff --git a/scripts/adms_cli.py b/scripts/adms_cli.py index a2409e64..8523330a 100755 --- a/scripts/adms_cli.py +++ b/scripts/adms_cli.py @@ -36,8 +36,6 @@ relations lock — lock document & relation relations unlock — release lock DOCUMENTS - documents list — list all Documents - documents get — Document linked to a relation documents update — update document metadata documents download — presigned download URL documents restore — restore a previous content version @@ -94,7 +92,7 @@ DeleteUserDataJobParameters, DraftActivateInput, DraftInput, - MimeTypePolicy, + FileExtensionPolicy, UpdateAllowedDomainInput, UpdateBusinessObjectNodeTypeInput, UpdateDocumentInput, @@ -216,6 +214,37 @@ def _print_json(obj) -> None: print(json.dumps(_to_jsonable(obj), indent=2)) +def _field(key: str, val: object) -> None: + print(f" {key + ':':<42} {val}") + + +def _print_document(doc) -> None: + _field("DocumentID", doc.document_id) + _field("IsActiveEntity", doc.is_active_entity) + _field("DocumentName", doc.document_name) + _field("DocumentBaseType", getattr(doc.document_base_type, "value", doc.document_base_type)) + _field("DocumentTypeID", doc.document_type_id) + _field("DocumentMimeType", doc.document_mime_type) + _field("DocumentDescription", doc.document_description) + _field("DocumentSizeInByte", doc.document_size_in_byte) + _field("DocumentContentStreamURI", doc.document_content_stream_uri) + _field("DocumentExternalContentURL", doc.document_external_content_url) + _field("DocumentIsLocked", doc.document_is_locked) + _field("DocumentIsSoftDeleted", doc.document_is_soft_deleted) + _field("HasActiveDocumentEntity", doc.has_active_document_entity) + _field("HasDraftDocumentEntity", doc.has_draft_document_entity) + _field("DraftUUID", doc.draft_uuid) + _field("DocumentContentUploadURLs", doc.document_content_upload_urls) + _field("DocumentIsMultiReferenced", doc.document_is_multi_referenced) + _field("DocumentCreatedByUserName", doc.document_created_by_user_name) + _field("DocumentCreatedAtDateTime", doc.document_created_at_date_time) + _field("DocumentChangedByUserName", doc.document_changed_by_user_name) + _field("DocumentChangedAtDateTime", doc.document_changed_at_date_time) + _field("DocumentState", getattr(doc.document_state, "value", doc.document_state)) + _field("DocumentStateText", doc.document_state_text) + _field("DocumentContentHash", doc.document_content_hash) + + def _print_list(items, label: str) -> None: print(f"\n{'─' * 62}") print(f" {label} ({len(items)} items)") @@ -240,6 +269,7 @@ def _patch_http_for_raw(client: AdmsClient) -> None: original_request = AdmsHttp._request def _capturing_request(self, method, path, **kwargs): + _last_raw_responses.clear() resp = original_request(self, method, path, **kwargs) try: body = resp.json() @@ -261,12 +291,22 @@ def _print_raw_if_enabled(label: str = "Raw API response") -> None: print() +def _print_last_raw() -> None: + """Print the last captured raw wire response (always, no mode check).""" + if _last_raw_responses: + print(_last_raw_responses[-1]) + + def _clear_raw_captures() -> None: _last_raw_responses.clear() def _prompt(prompt: str, default: Optional[str] = None) -> str: - value = input(f" {prompt}: ").strip() + if default: + label = f"{prompt} (optional — press Enter to skip, default {default})" + else: + label = prompt + value = input(f" {label}: ").strip() if not value and default: return default return value @@ -274,13 +314,16 @@ def _prompt(prompt: str, default: Optional[str] = None) -> str: def _prompt_optional(prompt: str) -> Optional[str]: """Prompt for an optional value — empty input returns None.""" - value = input(f" {prompt} (optional): ").strip() + value = input(f" {prompt} (optional — press Enter to skip): ").strip() return value or None def _prompt_int(prompt: str, default: Optional[int] = None) -> Optional[int]: - label = f" (optional, default {default})" if default is not None else " (optional)" - raw = input(f" {prompt}{label}: ").strip() + if default is not None: + label = f"{prompt} (optional — press Enter to skip, default {default})" + else: + label = f"{prompt} (optional — press Enter to skip)" + raw = input(f" {label}: ").strip() if not raw: return default try: @@ -484,23 +527,67 @@ def cmd_relations_unlock(client: AdmsClient, relation_id: str) -> None: _err_exc("Unlock failed", exc) -def cmd_relations_full_upload(client: AdmsClient, relation_id: str) -> None: - """Generate upload URLs, PUT file to GCS, then complete the upload.""" +def cmd_relations_full_upload(client: AdmsClient) -> None: + """Create relation + document, generate upload URL, PUT file to GCS, then complete.""" import os - file_path = _prompt("Local file path (absolute or relative)") - if not file_path or not os.path.isfile(file_path): - _err(f"File not found: {file_path!r}") + print("\n── Full upload: create relation → generate URL → PUT file → complete ──") + + bo_type_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") + bo_node_id = _prompt("HostBusinessObjectNodeID") + if not bo_type_id or not bo_node_id: + _err("Both BO fields are required.") + return + bo_display_id = _prompt_optional("HostBusinessObjNodeDisplayID") + doc_name = _prompt("DocumentName (e.g. test.pdf)") + doc_type_id = _prompt("DocumentTypeID") + if not doc_name or not doc_type_id: + _err("DocumentName and DocumentTypeID are required.") + return + doc_desc = _prompt_optional("DocumentDescription") + doc_base_type_raw = _prompt("DocumentBaseType (D=file, F=folder, U=URL)", default="D").upper() + try: + doc_base_type = BaseType(doc_base_type_raw) + except ValueError: + _err(f"Invalid DocumentBaseType {doc_base_type_raw!r} — must be D, F, or U.") return - file_name = os.path.basename(file_path) is_active = _prompt_bool("Active entity? (No = draft)", default=True) is_multipart = _prompt_bool("Multipart upload?", default=False) no_of_parts = _prompt_int("Number of parts", default=1) or 1 + file_path = _prompt("Local file path") + if not file_path or not os.path.isfile(file_path): + _err(f"File not found: {file_path!r}") + return + file_name = os.path.basename(file_path) + + print(f"\nStep 1 — Creating relation + document …") + try: + rel = client.relations.create( + CreateDocumentRelationInput( + business_object_node_type_unique_id=bo_type_id, + host_business_object_node_id=bo_node_id, + host_business_obj_node_display_id=bo_display_id, + is_active_entity=is_active, + document=CreateDocumentInput( + document_name=doc_name, + document_type_id=doc_type_id, + document_description=doc_desc, + document_base_type=doc_base_type, + document_is_multipart=is_multipart, + document_no_of_parts=no_of_parts if is_multipart else None, + ), + ) + ) + except AdmsError as exc: + _err_exc("Create failed", exc) + return + _ok(f"Relation created: {rel.document_relation_id}") + _print_json(rel) - print(f"\nStep 1 — Generating upload URL(s) for relation {relation_id} …") + print(f"\nStep 2 — Generating upload URL(s) for {rel.document_relation_id} …") try: doc = client.relations.generate_upload_urls( - relation_id, + rel.document_relation_id, is_active_entity=is_active, is_multipart=is_multipart, no_of_parts=no_of_parts, @@ -516,26 +603,36 @@ def cmd_relations_full_upload(client: AdmsClient, relation_id: str) -> None: _ok(f"Got {len(urls)} URL(s).") upload_url = urls[0] - # The x-goog-meta-filename value was baked into the GCS signature by ADM - # at URL-generation time. We must send the document name ADM registered, - # not the local file name — a mismatch causes SignatureDoesNotMatch. adm_filename = doc.document_name or file_name - print(f"\nStep 2 — Uploading {file_name!r} as '{adm_filename}' to GCS …") + print(f"\nStep 3 — Uploading {file_name!r} to GCS …") try: import urllib.request as _urllib_request + import ssl as _ssl + import mimetypes + + _ssl_ctx = _ssl.create_default_context() + _ssl_ctx.check_hostname = False + _ssl_ctx.verify_mode = _ssl.CERT_NONE with open(file_path, "rb") as f: file_bytes = f.read() - req = _urllib_request.Request( - upload_url, - data=file_bytes, - method="PUT", - headers={"x-goog-meta-filename": adm_filename}, - ) + mime_type, _ = mimetypes.guess_type(file_path) + if not mime_type: + mime_type = "application/octet-stream" + + put_url = urls[0] + + headers = {"Content-Type": mime_type} + if not is_multipart: + headers["x-goog-meta-filename"] = adm_filename + + print(f" → Content-Type: {mime_type}") + + req = _urllib_request.Request(put_url, data=file_bytes, method="PUT", headers=headers) try: - with _urllib_request.urlopen(req, timeout=120) as response: + with _urllib_request.urlopen(req, timeout=120, context=_ssl_ctx) as response: status = response.status except _urllib_request.HTTPError as exc: body = exc.read().decode("utf-8", errors="replace") @@ -550,9 +647,9 @@ def cmd_relations_full_upload(client: AdmsClient, relation_id: str) -> None: return if is_multipart: - print(f"\nStep 3 — Completing multipart upload for {relation_id} …") + print(f"\nStep 4 — Completing multipart upload for {rel.document_relation_id} …") try: - client.relations.complete_multipart_upload(relation_id, is_active_entity=is_active) + client.relations.complete_multipart_upload(rel.document_relation_id, is_active_entity=is_active) _ok("Multipart upload completed.") except AdmsError as exc: _err_exc("Complete upload failed", exc) @@ -578,9 +675,9 @@ def cmd_relations_delete_bo_node(client: AdmsClient) -> None: def cmd_relations_change_logs(client: AdmsClient) -> None: - print("\nFetching ChangeLog …") - items = client.relations.get_change_logs() - _print_list(items, "ChangeLog") + print("\nFetching BusinessObjectNodeChangeLog …") + items = client.relations.get_bo_node_change_logs() + _print_list(items, "BusinessObjectNodeChangeLog") def cmd_relations_bo_change_logs(client: AdmsClient) -> None: @@ -592,34 +689,23 @@ def cmd_relations_bo_change_logs(client: AdmsClient) -> None: # ── DOCUMENTS handlers ─────────────────────────────────────────────────────── -def cmd_documents_list(client: AdmsClient) -> None: - print("\nFetching all Documents (via DocumentRelation?$expand=Document) …") - items = client.documents.get_all() - _print_list(items, "Documents") - - -def cmd_documents_get(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - print(f"\nFetching Document via relation {relation_id} (IsActiveEntity={str(is_active).lower()}) …") - try: - doc = client.documents.get(relation_id, is_active_entity=is_active) - _print_json(doc) - except DocumentNotFoundError: - _err(f"No document found for relation {relation_id!r}.") - except AdmsError as exc: - _err_exc("Failed", exc) - - def cmd_documents_update(client: AdmsClient, relation_id: str) -> None: - print("\n── Update Document metadata ─────────────────────────────────") - print(" Leave any field blank to leave it unchanged.") is_active = _prompt_bool("Active entity? (No = draft)", default=True) + print(" Leave any field blank to leave it unchanged.") + document_name = _prompt_optional("New document name") + document_description = _prompt_optional("New description") + document_type_id = _prompt_optional("New DocumentTypeID") + doc_content_version_comment = _prompt_optional("Content version comment") + document_external_content_url = _prompt_optional("New external URL") + is_content_update_raw = _prompt_bool("Is content update? (new file version)", default=False) + is_content_update = is_content_update_raw if is_content_update_raw else None update = UpdateDocumentInput( - document_name=_prompt_optional("New document name"), - document_description=_prompt_optional("New description"), - document_type_id=_prompt_optional("New DocumentTypeID"), - doc_content_version_comment=_prompt_optional("Content version comment"), - document_external_content_url=_prompt_optional("New external URL"), + document_name=document_name, + document_description=document_description, + document_type_id=document_type_id, + doc_content_version_comment=doc_content_version_comment, + document_external_content_url=document_external_content_url, + is_content_update=is_content_update, ) if not any(v is not None for v in update.__dict__.values()): _err("Nothing to update — all fields blank.") @@ -634,13 +720,13 @@ def cmd_documents_update(client: AdmsClient, relation_id: str) -> None: def cmd_documents_download(client: AdmsClient, relation_id: str) -> None: is_active = _prompt_bool("Active entity? (No = draft)", default=True) - version = _prompt("DocContentVersionID", default="1.0") + version = _prompt_optional("DocContentVersionID (blank = latest)") print("\nFetching presigned download URL …") try: url = client.documents.get_download_url( document_relation_id=relation_id, is_active_entity=is_active, - doc_content_version_id=version, + doc_content_version_id=version or None, ) _ok("Presigned URL (valid for a short time — do not cache):") print(f" {url}\n") @@ -693,9 +779,9 @@ def cmd_config_domains(client: AdmsClient) -> None: def cmd_config_domains_create(client: AdmsClient) -> None: print("\n── Create AllowedDomain ────────────────────────────────────") - host = _prompt("Hostname (e.g. storage.example.com)") - proto = _prompt("Protocol", default="https") - port = _prompt_int("Port (blank = protocol default)") + host = _prompt("AllowedDomainHostName * (mandatory, e.g. storage.example.com)") + proto = _prompt("AllowedDomainProtocol", default="https") + port = _prompt_int("AllowedDomainPort") if not host or not proto: _err("Hostname and protocol are required.") return @@ -704,7 +790,7 @@ def cmd_config_domains_create(client: AdmsClient) -> None: CreateAllowedDomainInput(host_name=host, protocol=proto, port=port) ) _ok(f"Created AllowedDomain {out.allowed_domain_id}") - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Create failed", exc) @@ -713,7 +799,7 @@ def cmd_config_domains_get(client: AdmsClient, allowed_domain_id: str) -> None: print(f"\nFetching AllowedDomain {allowed_domain_id} …") try: out = client.config.get_allowed_domain(allowed_domain_id) - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Failed", exc) @@ -732,7 +818,7 @@ def cmd_config_domains_update(client: AdmsClient, allowed_domain_id: str) -> Non try: out = client.config.update_allowed_domain(allowed_domain_id, update) _ok(f"Updated AllowedDomain {out.allowed_domain_id}.") - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Update failed", exc) @@ -756,9 +842,9 @@ def cmd_config_doctypes(client: AdmsClient) -> None: def cmd_config_doctypes_create(client: AdmsClient) -> None: print("\n── Create DocumentType ─────────────────────────────────────") - type_id = _prompt("DocumentTypeID (max 10 chars)") - name = _prompt("DocumentTypeName") - desc = _prompt_optional("Description") + type_id = _prompt("DocumentTypeID * (mandatory, max 10 chars)") + name = _prompt("DocumentTypeName * (mandatory)") + desc = _prompt_optional("DocumentTypeDescription") if not type_id or not name: _err("DocumentTypeID and DocumentTypeName are required.") return @@ -771,7 +857,7 @@ def cmd_config_doctypes_create(client: AdmsClient) -> None: ) ) _ok(f"Created DocumentType {out.document_type_id}.") - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Create failed", exc) @@ -780,7 +866,7 @@ def cmd_config_doctypes_get(client: AdmsClient, document_type_id: str) -> None: print(f"\nFetching DocumentType {document_type_id} …") try: out = client.config.get_document_type(document_type_id) - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Failed", exc) @@ -798,7 +884,7 @@ def cmd_config_doctypes_update(client: AdmsClient, document_type_id: str) -> Non try: out = client.config.update_document_type(document_type_id, update) _ok(f"Updated DocumentType {out.document_type_id}.") - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Update failed", exc) @@ -822,9 +908,10 @@ def cmd_config_botypes(client: AdmsClient) -> None: def cmd_config_botypes_create(client: AdmsClient) -> None: print("\n── Create BusinessObjectNodeType ───────────────────────────") - bo_type = _prompt("BusinessObjectNodeType (short code, e.g. PO)") - bo_name = _prompt("BusinessObjectNodeTypeName") - tenant_id = _prompt("ApplicationTenantID (UUID of the owning tenant)") + bo_type = _prompt("BusinessObjectNodeType * (mandatory, short code, e.g. PO)") + bo_name = _prompt("BusinessObjectNodeTypeName * (mandatory)") + tenant_id = _prompt("ApplicationTenantID * (mandatory, UUID)") + odm_name = _prompt_optional("ODMEntityName") if not bo_type or not bo_name or not tenant_id: _err("BusinessObjectNodeType, name, and ApplicationTenantID are required.") return @@ -834,12 +921,13 @@ def cmd_config_botypes_create(client: AdmsClient) -> None: business_object_node_type=bo_type, business_object_node_type_name=bo_name, application_tenant_id=tenant_id, + odm_entity_name=odm_name, ) ) _ok( f"Created BusinessObjectNodeType {out.business_object_node_type_unique_id}." ) - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Create failed", exc) @@ -848,7 +936,7 @@ def cmd_config_botypes_get(client: AdmsClient, bo_type_unique_id: str) -> None: print(f"\nFetching BusinessObjectNodeType {bo_type_unique_id} …") try: out = client.config.get_business_object_type(bo_type_unique_id) - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Failed", exc) @@ -866,7 +954,7 @@ def cmd_config_botypes_update(client: AdmsClient, bo_type_unique_id: str) -> Non try: out = client.config.update_business_object_type(bo_type_unique_id, update) _ok(f"Updated BusinessObjectNodeType {out.business_object_node_type_unique_id}.") - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Update failed", exc) @@ -890,9 +978,8 @@ def cmd_config_maps(client: AdmsClient) -> None: def cmd_config_maps_create(client: AdmsClient) -> None: print("\n── Create DocumentType ↔ BO type mapping ───────────────────") - bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") - doc_type_id = _prompt("DocumentTypeID") - is_default = _prompt_bool("Mark as default for this BO type?", default=False) + bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID * (mandatory, UUID)") + doc_type_id = _prompt("DocumentTypeID * (mandatory)") if not bo_unique_id or not doc_type_id: _err("Both IDs are required.") return @@ -901,39 +988,53 @@ def cmd_config_maps_create(client: AdmsClient) -> None: CreateDocumentTypeBoTypeMapInput( business_object_node_type_unique_id=bo_unique_id, document_type_id=doc_type_id, - is_default=is_default, ) ) - _ok(f"Created mapping {out.document_type_bo_type_map_id}.") - _print_json(out) + _ok(f"Created mapping {out.document_type_id}:{out.business_object_node_type_unique_id}.") + _print_last_raw() except AdmsError as exc: _err_exc("Create failed", exc) -def cmd_config_maps_get(client: AdmsClient, mapping_id: str) -> None: - print(f"\nFetching mapping {mapping_id} …") +def cmd_config_maps_get(client: AdmsClient) -> None: + doc_type_id = _prompt("DocumentTypeID * (mandatory)") + bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID * (mandatory, UUID)") + if not doc_type_id or not bo_unique_id: + _err("Both fields are required.") + return + print(f"\nFetching mapping {doc_type_id}:{bo_unique_id} …") try: - out = client.config.get_type_mapping(mapping_id) - _print_json(out) + out = client.config.get_type_mapping(doc_type_id, bo_unique_id) + _print_last_raw() except AdmsError as exc: _err_exc("Failed", exc) -def cmd_config_maps_delete(client: AdmsClient, mapping_id: str) -> None: - if not _confirm(f"Delete mapping {mapping_id!r}?"): +def cmd_config_maps_delete(client: AdmsClient) -> None: + doc_type_id = _prompt("DocumentTypeID * (mandatory)") + bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID * (mandatory, UUID)") + if not doc_type_id or not bo_unique_id: + _err("Both fields are required.") + return + if not _confirm(f"Delete mapping {doc_type_id}:{bo_unique_id}?"): print(" Aborted.") return try: - client.config.delete_type_mapping(mapping_id) - _ok(f"Deleted {mapping_id}.") + client.config.delete_type_mapping(doc_type_id, bo_unique_id) + _ok(f"Deleted mapping {doc_type_id}:{bo_unique_id}.") except AdmsError as exc: _err_exc("Delete failed", exc) -def cmd_config_maps_mark_default(client: AdmsClient, mapping_id: str) -> None: +def cmd_config_maps_mark_default(client: AdmsClient) -> None: + doc_type_id = _prompt("DocumentTypeID * (mandatory)") + bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID * (mandatory, UUID)") + if not doc_type_id or not bo_unique_id: + _err("Both fields are required.") + return try: - client.config.mark_default(mapping_id) - _ok(f"Mapping {mapping_id} marked as default.") + client.config.mark_default(doc_type_id, bo_unique_id) + _ok(f"Mapping {doc_type_id}:{bo_unique_id} marked as default.") except AdmsError as exc: _err_exc("Failed", exc) @@ -948,49 +1049,49 @@ def cmd_config_file_ext_list(client: AdmsClient) -> None: def cmd_config_file_ext_create(client: AdmsClient) -> None: - print("\n── Create FileExtensionPolicy ──────────────────────────────") - ext = _prompt("File extension (e.g. pdf, exe)") - print(" Policy option:") - print(" A — Allow") - print(" B — Block") - policy_raw = _prompt("Policy (A/B)", default="A").upper() - try: - policy = MimeTypePolicy(policy_raw) - except ValueError: - _err(f"Invalid policy {policy_raw!r} — must be A or B.") - return - if not ext: - _err("File extension is required.") + print("\n── Create DocumentTypeFileExtensionPolicy ──────────────────") + doc_type_id = _prompt("DocumentTypeID * (mandatory)") + ext = _prompt("FileExtension * (mandatory, e.g. pdf, exe)") + if not doc_type_id or not ext: + _err("Both fields are required.") return try: out = client.config.create_file_extension_policy( CreateFileExtensionPolicyInput( - file_extension_policy_option=policy, + document_type_id=doc_type_id, file_extension=ext, ) ) - _ok(f"Created FileExtensionPolicy {out.file_extension_policy_id}.") - _print_json(out) + _ok(f"Created DocumentTypeFileExtensionPolicy {out.document_type_id}:{out.file_extension}.") + _print_last_raw() except AdmsError as exc: _err_exc("Create failed", exc) -def cmd_config_file_ext_get(client: AdmsClient, policy_id: str) -> None: - print(f"\nFetching FileExtensionPolicy {policy_id} …") +def cmd_config_global_file_extensions(client: AdmsClient) -> None: + print("\nFetching global allowed file extensions …") try: - out = client.config.get_file_extension_policy(policy_id) - _print_json(out) + resp = client.config._http.get( + "GetGlobalAllowedFileExtensions()", + service_base="/odata/v4/ConfigurationService", + ) + _print_json(resp.json()) except AdmsError as exc: _err_exc("Failed", exc) -def cmd_config_file_ext_delete(client: AdmsClient, policy_id: str) -> None: - if not _confirm(f"Delete FileExtensionPolicy {policy_id!r}?"): +def cmd_config_file_ext_delete(client: AdmsClient) -> None: + doc_type_id = _prompt("DocumentTypeID * (mandatory)") + ext = _prompt("FileExtension * (mandatory)") + if not doc_type_id or not ext: + _err("Both fields are required.") + return + if not _confirm(f"Delete DocumentTypeFileExtensionPolicy {doc_type_id}:{ext}?"): print(" Aborted.") return try: - client.config.delete_file_extension_policy(policy_id) - _ok(f"Deleted {policy_id}.") + client.config.delete_file_extension_policy(doc_type_id, ext) + _ok(f"Deleted {doc_type_id}:{ext}.") except AdmsError as exc: _err_exc("Delete failed", exc) @@ -1007,9 +1108,9 @@ def cmd_config_tenant_list(client: AdmsClient) -> None: def cmd_config_tenant_create(client: AdmsClient) -> None: print("\n── Create ApplicationTenant ────────────────────────────────") - tenant_id = _prompt("ApplicationTenantID") - tenant_name = _prompt("ApplicationTenantName") - subaccount_id = _prompt("Subaccount ID (x-subaccount-id header)") + tenant_id = _prompt("ApplicationTenantID * (mandatory)") + tenant_name = _prompt("ApplicationTenantName * (mandatory)") + subaccount_id = _prompt("Subaccount ID * (mandatory, x-subaccount-id header)") if not tenant_id or not tenant_name or not subaccount_id: _err("ApplicationTenantID, name, and Subaccount ID are required.") return @@ -1022,7 +1123,7 @@ def cmd_config_tenant_create(client: AdmsClient) -> None: subaccount_id=subaccount_id, ) _ok(f"Created ApplicationTenant {out.application_tenant_id}.") - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Create failed", exc) @@ -1032,7 +1133,7 @@ def cmd_config_tenant_get(client: AdmsClient, tenant_id: str) -> None: print(f"\nFetching ApplicationTenant {tenant_id} …") try: out = client.config.get_application_tenant(tenant_id, subaccount_id=subaccount_id) - _print_json(out) + _print_last_raw() except AdmsError as exc: _err_exc("Failed", exc) @@ -1139,7 +1240,7 @@ def cmd_jobs_delete_user_data(client: AdmsClient) -> None: │ rad — activateDraft (BO type + node ID) │ │ rdd — discardDraft (BO type + node ID) │ │ ru — generateUploadUrls (relation ID) │ - │ rfu — fullUpload: generate URL + PUT file + complete │ + │ rfu — fullUpload: create relation + PUT file + complete │ │ rcu — completeMultipartUpload (relation ID) │ │ rlk — lock relation │ │ ruk — unlock relation │ @@ -1148,8 +1249,6 @@ def cmd_jobs_delete_user_data(client: AdmsClient) -> None: │ rbl — getBusinessObjectNodeChangeLog │ ├──────────────────────────────────────────────────────────────────┤ │ DOCUMENTS (AdmsDocumentsClientApi) │ - │ dl — list all Documents │ - │ dg — get Document by relation ID │ │ du — update Document (rename) │ │ dd — getDownloadUrl (pre-signed URL) │ │ drv — restoreContentVersion │ @@ -1177,7 +1276,7 @@ def cmd_jobs_delete_user_data(client: AdmsClient) -> None: │ cmd — delete mapping │ │ cmk — markDefault (mapping ID) │ │ cfl — list FileExtensionPolicies │ - │ cfg — get FileExtensionPolicy by ID │ + │ cgf — GetGlobalAllowedFileExtensions │ │ cfc — create FileExtensionPolicy │ │ cfd — delete FileExtensionPolicy │ │ cal — list ApplicationTenants │ @@ -1227,11 +1326,11 @@ def _interactive(client: AdmsClient) -> None: if choice == "rl": cmd_relations_list(client) elif choice == "rg": - rid = _prompt("Relation ID") + rid = _prompt("DocumentRelationID") if rid: cmd_relations_get(client, rid) elif choice == "rd": - rid = _prompt("Relation ID to delete") + rid = _prompt("DocumentRelationID") if rid: cmd_relations_delete(client, rid) elif choice == "rcd": @@ -1243,25 +1342,23 @@ def _interactive(client: AdmsClient) -> None: elif choice == "rdd": cmd_relations_discard_draft(client) elif choice == "ru": - rid = _prompt("Relation ID") + rid = _prompt("DocumentRelationID") if rid: cmd_relations_upload_urls(client, rid) elif choice == "rcu": - rid = _prompt("Relation ID") + rid = _prompt("DocumentRelationID") if rid: cmd_relations_complete_upload(client, rid) elif choice == "rlk": - rid = _prompt("Relation ID to lock") + rid = _prompt("DocumentRelationID") if rid: cmd_relations_lock(client, rid) elif choice == "ruk": - rid = _prompt("Relation ID to unlock") + rid = _prompt("DocumentRelationID") if rid: cmd_relations_unlock(client, rid) elif choice == "rfu": - rid = _prompt("Relation ID") - if rid: - cmd_relations_full_upload(client, rid) + cmd_relations_full_upload(client) elif choice == "rbn": cmd_relations_delete_bo_node(client) elif choice == "rcl": @@ -1270,27 +1367,21 @@ def _interactive(client: AdmsClient) -> None: cmd_relations_bo_change_logs(client) # ── documents ── - elif choice == "dl": - cmd_documents_list(client) - elif choice == "dg": - rid = _prompt("Relation ID") - if rid: - cmd_documents_get(client, rid) elif choice == "du": - rid = _prompt("Relation ID") + rid = _prompt("DocumentRelationID") if rid: cmd_documents_update(client, rid) elif choice == "dd": - rid = _prompt("Relation ID") + rid = _prompt("DocumentRelationID") if rid: cmd_documents_download(client, rid) elif choice == "drv": - rid = _prompt("Relation ID") + rid = _prompt("DocumentRelationID") ver = _prompt("DocContentVersionID to restore", default="1.0") if rid and ver: cmd_documents_restore(client, rid, ver) elif choice == "ddv": - rid = _prompt("Relation ID") + rid = _prompt("DocumentRelationID") ver = _prompt("DocContentVersionID to delete") if rid and ver: cmd_documents_delete_version(client, rid, ver) @@ -1353,33 +1444,23 @@ def _interactive(client: AdmsClient) -> None: elif choice == "cm": cmd_config_maps(client) elif choice == "cmg": - mid = _prompt("DocumentTypeBOTypeMapID") - if mid: - cmd_config_maps_get(client, mid) + cmd_config_maps_get(client) elif choice == "cma": cmd_config_maps_create(client) elif choice == "cmd": - mid = _prompt("DocumentTypeBOTypeMapID to delete") - if mid: - cmd_config_maps_delete(client, mid) + cmd_config_maps_delete(client) elif choice == "cmk": - mid = _prompt("DocumentTypeBOTypeMapID to mark as default") - if mid: - cmd_config_maps_mark_default(client, mid) + cmd_config_maps_mark_default(client) # ── config: FileExtensionPolicy ── elif choice == "cfl": cmd_config_file_ext_list(client) - elif choice == "cfg": - pid = _prompt("FileExtensionPolicyID") - if pid: - cmd_config_file_ext_get(client, pid) + elif choice == "cgf": + cmd_config_global_file_extensions(client) elif choice == "cfc": cmd_config_file_ext_create(client) elif choice == "cfd": - pid = _prompt("FileExtensionPolicyID to delete") - if pid: - cmd_config_file_ext_delete(client, pid) + cmd_config_file_ext_delete(client) # ── config: ApplicationTenant ── elif choice == "cal": @@ -1457,11 +1538,7 @@ def _cli(client: AdmsClient, args: list[str]) -> None: elif cmd == "documents": sub = args[1] if len(args) > 1 else "" - if sub == "list": - cmd_documents_list(client) - elif sub == "get" and len(args) > 2: - cmd_documents_get(client, args[2]) - elif sub == "update" and len(args) > 2: + if sub == "update" and len(args) > 2: cmd_documents_update(client, args[2]) elif sub == "download" and len(args) > 2: cmd_documents_download(client, args[2]) @@ -1471,7 +1548,7 @@ def _cli(client: AdmsClient, args: list[str]) -> None: cmd_documents_delete_version(client, args[2], args[3]) else: _err( - "Usage: documents list | get | update | download " + "Usage: documents update | download " "| restore | delete-version " ) @@ -1499,8 +1576,8 @@ def _cli(client: AdmsClient, args: list[str]) -> None: cmd_config_maps(client) elif sub == "maps-create": cmd_config_maps_create(client) - elif sub == "maps-delete" and len(args) > 2: - cmd_config_maps_delete(client, args[2]) + elif sub == "maps-delete": + cmd_config_maps_delete(client) else: _err( "Usage: config domains[-create|-delete ] " diff --git a/src/sap_cloud_sdk/adms/__init__.py b/src/sap_cloud_sdk/adms/__init__.py index 13f69c43..d1df3308 100644 --- a/src/sap_cloud_sdk/adms/__init__.py +++ b/src/sap_cloud_sdk/adms/__init__.py @@ -85,7 +85,6 @@ JobOutput, JobStatus, JobType, - MimeTypePolicy, ScanStatus, UpdateAllowedDomainInput, UpdateBusinessObjectNodeTypeInput, @@ -137,7 +136,6 @@ "JobOutput", "JobStatus", "JobType", - "MimeTypePolicy", "ScanStatus", "UpdateDocumentInput", "ZipDownloadJobParameters", diff --git a/src/sap_cloud_sdk/adms/_configuration_api.py b/src/sap_cloud_sdk/adms/_configuration_api.py index a537378b..ef2bf0d5 100644 --- a/src/sap_cloud_sdk/adms/_configuration_api.py +++ b/src/sap_cloud_sdk/adms/_configuration_api.py @@ -10,6 +10,7 @@ build_business_object_node_type_key_path, build_doctype_botype_map_key_path, build_document_type_key_path, + quote_odata_string_key, ) from sap_cloud_sdk.adms._models import ( AllowedDomain, @@ -243,42 +244,42 @@ def create_type_mapping( @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_DOCTYPE_BOTYPE_MAP) def get_type_mapping( - self, document_type_bo_type_map_id: str + self, document_type_id: str, business_object_node_type_unique_id: str ) -> DocumentTypeBusinessObjectTypeMap: - """Fetch a single DocumentType ↔ BusinessObjectNodeType mapping by its UUID.""" + """Fetch a single DocumentType ↔ BusinessObjectNodeType mapping by its composite key.""" resp = self._http.get( - build_doctype_botype_map_key_path(document_type_bo_type_map_id), + build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id), service_base=_CONFIG_SERVICE_PATH, ) return DocumentTypeBusinessObjectTypeMap.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_DOCTYPE_BOTYPE_MAP) - def delete_type_mapping(self, document_type_bo_type_map_id: str) -> None: + def delete_type_mapping(self, document_type_id: str, business_object_node_type_unique_id: str) -> None: """Delete a DocumentType ↔ BusinessObjectNodeType mapping.""" self._http.delete( - build_doctype_botype_map_key_path(document_type_bo_type_map_id), + build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id), service_base=_CONFIG_SERVICE_PATH, ) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_MARK_DEFAULT) - def mark_default(self, document_type_bo_type_map_id: str) -> None: + def mark_default(self, document_type_id: str, business_object_node_type_unique_id: str) -> None: """Mark a DocumentType ↔ BusinessObjectNodeType mapping as the default.""" self._http.post( - f"{build_doctype_botype_map_key_path(document_type_bo_type_map_id)}/com.sap.adm.ConfigurationService.markDefault", + f"{build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id)}/com.sap.adm.ConfigurationService.markDefault", json={}, service_base=_CONFIG_SERVICE_PATH, ) - # ── FileExtensionPolicy ──────────────────────────────────────────────────── + # ── DocumentTypeFileExtensionPolicy ─────────────────────────────────────── @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_ALL_FILE_EXT_POLICIES) def get_all_file_extension_policies( self, options: ConfigQueryOptions | None = None ) -> list[FileExtensionPolicy]: - """Return all file extension allow/block policies.""" + """Return all document-type file extension policies.""" params = options.to_query_params() if options else {} resp = self._http.get( - "FileExtensionPolicy", params=params, service_base=_CONFIG_SERVICE_PATH + "DocumentTypeFileExtensionPolicy", params=params, service_base=_CONFIG_SERVICE_PATH ) return [ FileExtensionPolicy.from_dict(item) for item in resp.json().get("value", []) @@ -288,30 +289,19 @@ def get_all_file_extension_policies( def create_file_extension_policy( self, payload: CreateFileExtensionPolicyInput ) -> FileExtensionPolicy: - """Create a file extension allow/block policy.""" + """Create a document-type file extension policy.""" resp = self._http.post( - "FileExtensionPolicy", + "DocumentTypeFileExtensionPolicy", json=payload.to_odata_dict(), service_base=_CONFIG_SERVICE_PATH, ) return FileExtensionPolicy.from_dict(resp.json()) - @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_FILE_EXT_POLICY) - def get_file_extension_policy( - self, file_extension_policy_id: str - ) -> FileExtensionPolicy: - """Fetch a single FileExtensionPolicy by its UUID.""" - resp = self._http.get( - f"FileExtensionPolicy(FileExtensionPolicyID={_quote_guid(file_extension_policy_id)})", - service_base=_CONFIG_SERVICE_PATH, - ) - return FileExtensionPolicy.from_dict(resp.json()) - @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_FILE_EXT_POLICY) - def delete_file_extension_policy(self, file_extension_policy_id: str) -> None: - """Delete a file extension policy.""" + def delete_file_extension_policy(self, document_type_id: str, file_extension: str) -> None: + """Delete a document-type file extension policy by composite key.""" self._http.delete( - f"FileExtensionPolicy(FileExtensionPolicyID={_quote_guid(file_extension_policy_id)})", + f"DocumentTypeFileExtensionPolicy(DocumentTypeID={quote_odata_string_key(document_type_id)},FileExtension={quote_odata_string_key(file_extension)})", service_base=_CONFIG_SERVICE_PATH, ) @@ -395,8 +385,8 @@ def delete_application_tenant( def _subaccount_header(subaccount_id: str | None) -> dict[str, str] | None: - """Build the x-subaccount-id header dict, or None if not provided.""" - return {"x-subaccount-id": subaccount_id} if subaccount_id else None + """Build the X-SubaccountId header dict, or None if not provided.""" + return {"X-SubaccountId": subaccount_id} if subaccount_id else None def _quote_guid(value: str) -> str: @@ -620,28 +610,28 @@ async def create_type_mapping( @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_DOCTYPE_BOTYPE_MAP) async def get_type_mapping( - self, document_type_bo_type_map_id: str + self, document_type_id: str, business_object_node_type_unique_id: str ) -> DocumentTypeBusinessObjectTypeMap: """Async variant of :meth:`_ConfigurationApi.get_type_mapping` — same semantics.""" resp = await self._http.get( - build_doctype_botype_map_key_path(document_type_bo_type_map_id), + build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id), service_base=_CONFIG_SERVICE_PATH, ) return DocumentTypeBusinessObjectTypeMap.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_DOCTYPE_BOTYPE_MAP) - async def delete_type_mapping(self, document_type_bo_type_map_id: str) -> None: + async def delete_type_mapping(self, document_type_id: str, business_object_node_type_unique_id: str) -> None: """Async variant of :meth:`_ConfigurationApi.delete_type_mapping` — same semantics.""" await self._http.delete( - build_doctype_botype_map_key_path(document_type_bo_type_map_id), + build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id), service_base=_CONFIG_SERVICE_PATH, ) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_MARK_DEFAULT) - async def mark_default(self, document_type_bo_type_map_id: str) -> None: + async def mark_default(self, document_type_id: str, business_object_node_type_unique_id: str) -> None: """Async variant of :meth:`_ConfigurationApi.mark_default` — same semantics.""" await self._http.post( - f"{build_doctype_botype_map_key_path(document_type_bo_type_map_id)}/com.sap.adm.ConfigurationService.markDefault", + f"{build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id)}/com.sap.adm.ConfigurationService.markDefault", json={}, service_base=_CONFIG_SERVICE_PATH, ) @@ -653,7 +643,7 @@ async def get_all_file_extension_policies( """Async variant of :meth:`_ConfigurationApi.get_all_file_extension_policies` — same semantics.""" params = options.to_query_params() if options else {} resp = await self._http.get( - "FileExtensionPolicy", params=params, service_base=_CONFIG_SERVICE_PATH + "DocumentTypeFileExtensionPolicy", params=params, service_base=_CONFIG_SERVICE_PATH ) return [ FileExtensionPolicy.from_dict(item) for item in resp.json().get("value", []) @@ -665,28 +655,17 @@ async def create_file_extension_policy( ) -> FileExtensionPolicy: """Async variant of :meth:`_ConfigurationApi.create_file_extension_policy` — same semantics.""" resp = await self._http.post( - "FileExtensionPolicy", + "DocumentTypeFileExtensionPolicy", json=payload.to_odata_dict(), service_base=_CONFIG_SERVICE_PATH, ) return FileExtensionPolicy.from_dict(resp.json()) - @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_FILE_EXT_POLICY) - async def get_file_extension_policy( - self, file_extension_policy_id: str - ) -> FileExtensionPolicy: - """Async variant of :meth:`_ConfigurationApi.get_file_extension_policy` — same semantics.""" - resp = await self._http.get( - f"FileExtensionPolicy(FileExtensionPolicyID={_quote_guid(file_extension_policy_id)})", - service_base=_CONFIG_SERVICE_PATH, - ) - return FileExtensionPolicy.from_dict(resp.json()) - @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_FILE_EXT_POLICY) - async def delete_file_extension_policy(self, file_extension_policy_id: str) -> None: + async def delete_file_extension_policy(self, document_type_id: str, file_extension: str) -> None: """Async variant of :meth:`_ConfigurationApi.delete_file_extension_policy` — same semantics.""" await self._http.delete( - f"FileExtensionPolicy(FileExtensionPolicyID={_quote_guid(file_extension_policy_id)})", + f"DocumentTypeFileExtensionPolicy(DocumentTypeID={quote_odata_string_key(document_type_id)},FileExtension={quote_odata_string_key(file_extension)})", service_base=_CONFIG_SERVICE_PATH, ) diff --git a/src/sap_cloud_sdk/adms/_document_api.py b/src/sap_cloud_sdk/adms/_document_api.py index 0ccf7ba9..6e0a1e0c 100644 --- a/src/sap_cloud_sdk/adms/_document_api.py +++ b/src/sap_cloud_sdk/adms/_document_api.py @@ -115,7 +115,7 @@ def get_download_url( document_relation_id: str, *, is_active_entity: bool = True, - doc_content_version_id: str, + doc_content_version_id: str | None = None, ) -> str: """Return a time-limited presigned download URL for a document. @@ -125,6 +125,7 @@ def get_download_url( document_relation_id: UUID of the parent DocumentRelation. is_active_entity: Active vs draft entity flag. doc_content_version_id: Content version to download (e.g. ``"1.0"``). + If ``None``, the latest version is downloaded. Returns: Presigned URL string. @@ -153,10 +154,13 @@ def get_download_url( f"Downloads are only permitted when state is CLEAN." ) - fn_key = ( - f"{rel_key}/com.sap.adm.DocumentService.DownloadDocument(" - f"DocContentVersionID={quote_odata_string_key(doc_content_version_id)})" - ) + if doc_content_version_id is not None: + fn_key = ( + f"{rel_key}/com.sap.adm.DocumentService.DownloadDocument(" + f"DocContentVersionID={quote_odata_string_key(doc_content_version_id)})" + ) + else: + fn_key = f"{rel_key}/com.sap.adm.DocumentService.DownloadDocument()" resp = self._http.get(fn_key, service_base=_SERVICE_PATH) return resp.json().get("value", "") @@ -170,29 +174,21 @@ def update( ) -> Document: """Update document metadata via the bound ``UpdateDocument`` action. - ADM's UpdateDocument action returns only the changed fields. This - method transparently follows up with a GET to return the full Document. - Args: document_relation_id: UUID of the parent DocumentRelation. update_input: Fields to update (only non-None fields are sent). is_active_entity: Active vs draft entity flag. Returns: - Full updated :class:`~sap_cloud_sdk.adms._models.Document`. + Partial :class:`~sap_cloud_sdk.adms._models.Document` as returned + by the ADM ``UpdateDocument`` action (only changed fields populated). """ path = ( build_relation_key_path(document_relation_id, is_active_entity) + "/com.sap.adm.DocumentService.UpdateDocument" ) payload = {"Document": update_input.to_odata_dict()} - self._http.post(path, json=payload, service_base=_SERVICE_PATH) - # UpdateDocument returns only changed fields — fetch the full entity. - full_path = ( - build_relation_key_path(document_relation_id, is_active_entity) - + "/Document" - ) - resp = self._http.get(full_path, service_base=_SERVICE_PATH) + resp = self._http.post(path, json=payload, service_base=_SERVICE_PATH) return Document.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_DOCUMENTS_RESTORE_CONTENT_VERSION) @@ -316,7 +312,7 @@ async def get_download_url( document_relation_id: str, *, is_active_entity: bool = True, - doc_content_version_id: str, + doc_content_version_id: str | None = None, ) -> str: """Async download URL fetch with scan-state gate.""" rel_key = build_relation_key_path(document_relation_id, is_active_entity) @@ -339,10 +335,13 @@ async def get_download_url( f"Downloads are only permitted when state is CLEAN." ) - fn_key = ( - f"{rel_key}/com.sap.adm.DocumentService.DownloadDocument(" - f"DocContentVersionID={quote_odata_string_key(doc_content_version_id)})" - ) + if doc_content_version_id is not None: + fn_key = ( + f"{rel_key}/com.sap.adm.DocumentService.DownloadDocument(" + f"DocContentVersionID={quote_odata_string_key(doc_content_version_id)})" + ) + else: + fn_key = f"{rel_key}/com.sap.adm.DocumentService.DownloadDocument()" resp = await self._http.get(fn_key, service_base=_SERVICE_PATH) return resp.json().get("value", "") @@ -360,12 +359,7 @@ async def update( + "/com.sap.adm.DocumentService.UpdateDocument" ) payload = {"Document": update.to_odata_dict()} - await self._http.post(path, json=payload, service_base=_SERVICE_PATH) - full_path = ( - build_relation_key_path(document_relation_id, is_active_entity) - + "/Document" - ) - resp = await self._http.get(full_path, service_base=_SERVICE_PATH) + resp = await self._http.post(path, json=payload, service_base=_SERVICE_PATH) return Document.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_DOCUMENTS_DELETE_CONTENT_VERSION) diff --git a/src/sap_cloud_sdk/adms/_http.py b/src/sap_cloud_sdk/adms/_http.py index f08109b4..f4d8f3ae 100644 --- a/src/sap_cloud_sdk/adms/_http.py +++ b/src/sap_cloud_sdk/adms/_http.py @@ -128,11 +128,12 @@ def build_business_object_node_type_key_path(unique_id: str) -> str: ) -def build_doctype_botype_map_key_path(map_id: str) -> str: - """Return ``DocumentTypeBusinessObjectTypeMap(DocumentTypeBOTypeMapID=)``.""" +def build_doctype_botype_map_key_path(document_type_id: str, business_object_node_type_unique_id: str) -> str: + """Return ``DocumentTypeBusinessObjectTypeMap(DocumentTypeID='x',BusinessObjectNodeTypeUniqueID='y')``.""" return ( f"DocumentTypeBusinessObjectTypeMap(" - f"DocumentTypeBOTypeMapID={quote_odata_guid_key(map_id)})" + f"DocumentTypeID={quote_odata_string_key(document_type_id)}," + f"BusinessObjectNodeTypeUniqueID={quote_odata_string_key(business_object_node_type_unique_id)})" ) diff --git a/src/sap_cloud_sdk/adms/_job_api.py b/src/sap_cloud_sdk/adms/_job_api.py index 181d0db3..18941a5a 100644 --- a/src/sap_cloud_sdk/adms/_job_api.py +++ b/src/sap_cloud_sdk/adms/_job_api.py @@ -17,12 +17,9 @@ from sap_cloud_sdk.core.telemetry import Module, Operation, record_metrics # Fully-qualified OData V4 unbound action / function paths. -# The Java SDK (cloud-sdk-java contrib-java/adms commit e5dd1da) confirmed -# the canonical wire format includes the namespace prefix. -_START_JOB_DOC_SERVICE = "com.sap.adm.DocumentService.StartJob" -_START_JOB_ADMIN_SERVICE = "com.sap.adm.AdminService.StartJob" -_JOB_STATUS_DOC_SERVICE_NS = "com.sap.adm.DocumentService" -_JOB_STATUS_ADMIN_SERVICE_NS = "com.sap.adm.AdminService" +# Unbound action/function import names — no namespace prefix (per EDMX ActionImport/FunctionImport). +_START_JOB_DOC_SERVICE = "StartJob" +_START_JOB_ADMIN_SERVICE = "StartJob" class _JobApi: @@ -96,12 +93,7 @@ def get_status( Current :class:`~sap_cloud_sdk.adms._models.JobOutput`. """ service = _ADMIN_SERVICE_PATH if use_admin_service else _SERVICE_PATH - ns = ( - _JOB_STATUS_ADMIN_SERVICE_NS - if use_admin_service - else _JOB_STATUS_DOC_SERVICE_NS - ) - path = f"{ns}.{build_job_status_key_path(job_id)}" + path = build_job_status_key_path(job_id) resp = self._http.get(path, service_base=service) return JobOutput.from_dict(resp.json()) @@ -165,11 +157,6 @@ async def get_status( Current :class:`~sap_cloud_sdk.adms._models.JobOutput`. """ service = _ADMIN_SERVICE_PATH if use_admin_service else _SERVICE_PATH - ns = ( - _JOB_STATUS_ADMIN_SERVICE_NS - if use_admin_service - else _JOB_STATUS_DOC_SERVICE_NS - ) - path = f"{ns}.{build_job_status_key_path(job_id)}" + path = build_job_status_key_path(job_id) resp = await self._http.get(path, service_base=service) return JobOutput.from_dict(resp.json()) diff --git a/src/sap_cloud_sdk/adms/_models.py b/src/sap_cloud_sdk/adms/_models.py index 67b09913..40915379 100644 --- a/src/sap_cloud_sdk/adms/_models.py +++ b/src/sap_cloud_sdk/adms/_models.py @@ -838,19 +838,24 @@ class CreateBusinessObjectNodeTypeInput: business_object_node_type: Short identifier code (max 30 chars), e.g. ``"PO"``. business_object_node_type_name: Human-readable label (max 50 chars). application_tenant_id: Tenant this BO type belongs to. + odm_entity_name: Optional ODM (One Domain Model) entity name. """ business_object_node_type: str business_object_node_type_name: str application_tenant_id: str + odm_entity_name: str | None = None def to_odata_dict(self) -> dict: """Serialise to the OData payload shape expected by ADM.""" - return { + d: dict = { "BusinessObjectNodeType": self.business_object_node_type, "BusinessObjectNodeTypeName": self.business_object_node_type_name, "ApplicationTenantID": self.application_tenant_id, } + if self.odm_entity_name is not None: + d["ODMEntityName"] = self.odm_entity_name + return d @dataclass @@ -884,26 +889,23 @@ class DocumentTypeBusinessObjectTypeMap: to a business object. Attributes: - document_type_bo_type_map_id: Primary key UUID. - business_object_node_type_unique_id: FK to :class:`BusinessObjectNodeType`. - document_type_id: FK to :class:`DocumentType`. + document_type_id: FK to :class:`DocumentType` (part of composite key). + business_object_node_type_unique_id: FK to :class:`BusinessObjectNodeType` (part of composite key). is_default: If ``True`` this is the default type for the BO node type. """ - document_type_bo_type_map_id: str - business_object_node_type_unique_id: str document_type_id: str + business_object_node_type_unique_id: str is_default: bool = False @classmethod def from_dict(cls, data: dict) -> DocumentTypeBusinessObjectTypeMap: return cls( - document_type_bo_type_map_id=data.get("DocumentTypeBOTypeMapID", ""), + document_type_id=data.get("DocumentTypeID", ""), business_object_node_type_unique_id=data.get( "BusinessObjectNodeTypeUniqueID", "" ), - document_type_id=data.get("DocumentTypeID", ""), - is_default=data.get("IsDefault", False), + is_default=data.get("DocumentTypeIsDefault", False), ) def to_odata_dict(self) -> dict: @@ -911,7 +913,6 @@ def to_odata_dict(self) -> dict: return { "BusinessObjectNodeTypeUniqueID": self.business_object_node_type_unique_id, "DocumentTypeID": self.document_type_id, - "IsDefault": self.is_default, } @@ -922,19 +923,16 @@ class CreateDocumentTypeBoTypeMapInput: Attributes: business_object_node_type_unique_id: The BO node type UUID to map. document_type_id: The document type code to allow. - is_default: Whether this mapping is the default for the BO node type. """ business_object_node_type_unique_id: str document_type_id: str - is_default: bool = False def to_odata_dict(self) -> dict: """Serialise to the OData payload shape expected by ADM.""" return { "BusinessObjectNodeTypeUniqueID": self.business_object_node_type_unique_id, "DocumentTypeID": self.document_type_id, - "IsDefault": self.is_default, } @@ -1205,49 +1203,35 @@ def from_dict(cls, data: dict) -> DeleteBusinessObjectNodeResult: # --------------------------------------------------------------------------- -# FileExtensionPolicy model +# DocumentTypeFileExtensionPolicy model # --------------------------------------------------------------------------- -class MimeTypePolicy(str, Enum): - """Controls whether a file extension is allowed or blocked.""" - - ALLOW = "A" - BLOCK = "B" - - @dataclass class FileExtensionPolicy: - """Tenant-level file extension allow/block policy. + """Mapping that restricts which file extensions are allowed for a document type. - ADM checks this list before accepting an upload. + ADM entity set: ``DocumentTypeFileExtensionPolicy``. + Composite key: ``DocumentTypeID`` + ``FileExtension``. Attributes: - file_extension_policy_id: Primary key UUID. - file_extension_policy_option: ``ALLOW`` (``"A"``) or ``BLOCK`` (``"B"``). + document_type_id: FK to :class:`DocumentType`. file_extension: File extension string, e.g. ``"pdf"``, ``"exe"``. """ - file_extension_policy_id: str - file_extension_policy_option: MimeTypePolicy + document_type_id: str file_extension: str @classmethod def from_dict(cls, data: dict) -> FileExtensionPolicy: - option_raw = data.get("FileExtensionPolicyOption", MimeTypePolicy.ALLOW.value) - try: - option = MimeTypePolicy(option_raw) - except ValueError: - option = MimeTypePolicy.ALLOW return cls( - file_extension_policy_id=data.get("FileExtensionPolicyID", ""), - file_extension_policy_option=option, + document_type_id=data.get("DocumentTypeID", ""), file_extension=data.get("FileExtension", ""), ) def to_odata_dict(self) -> dict: return { - "FileExtensionPolicyOption": self.file_extension_policy_option.value, + "DocumentTypeID": self.document_type_id, "FileExtension": self.file_extension, } @@ -1257,16 +1241,16 @@ class CreateFileExtensionPolicyInput: """Input for creating a :class:`FileExtensionPolicy` entry. Attributes: - file_extension_policy_option: ``MimeTypePolicy.ALLOW`` or ``MimeTypePolicy.BLOCK``. - file_extension: File extension to allow/block (e.g. ``"pdf"``). + document_type_id: The document type to associate the extension with. + file_extension: File extension to allow (e.g. ``"pdf"``). """ - file_extension_policy_option: MimeTypePolicy + document_type_id: str file_extension: str def to_odata_dict(self) -> dict: return { - "FileExtensionPolicyOption": self.file_extension_policy_option.value, + "DocumentTypeID": self.document_type_id, "FileExtension": self.file_extension, } From 25a1cda48e031d9c50ff4d426236d6b6d82fe6ae Mon Sep 17 00:00:00 2001 From: I766515 Date: Thu, 2 Jul 2026 09:21:46 +0530 Subject: [PATCH 04/13] remove scripts/adms_cli.py from branch (keep local only) --- scripts/adms_cli.py | 1611 ------------------------------------------- 1 file changed, 1611 deletions(-) delete mode 100755 scripts/adms_cli.py diff --git a/scripts/adms_cli.py b/scripts/adms_cli.py deleted file mode 100755 index 8523330a..00000000 --- a/scripts/adms_cli.py +++ /dev/null @@ -1,1611 +0,0 @@ -#!/usr/bin/env python -"""Interactive CLI for testing the ADMS SDK. - -Exposes every public API on :class:`AdmsClient`: - - * ``client.documents`` — Document entity (list/get/update/download URL, - restore + soft-delete content versions). - * ``client.relations`` — DocumentRelation entity (list/get/delete, - presigned upload URLs, multipart completion, lock/unlock, - full draft lifecycle: create / validate / activate / discard). - * ``client.config`` — Tenant configuration (AllowedDomain, DocumentType, - BusinessObjectNodeType, DocumentType↔BO type maps — full CRUD). - * ``client.jobs`` — Async jobs (ZIP_DOWNLOAD, DELETE_USER_DATA, status poll). - -Usage: - # Load creds from .env.adms and run interactively - set -a && source .env.adms && set +a - .venv/bin/python scripts/adms_cli.py - - # Or pass a specific command directly (see "Commands" below) - .venv/bin/python scripts/adms_cli.py relations list - .venv/bin/python scripts/adms_cli.py documents update - .venv/bin/python scripts/adms_cli.py config maps - -Commands: - RELATIONS - relations list — list all DocumentRelations - relations get — get single relation - relations delete — delete a relation - relations create-draft — start a draft for a BO node - relations validate-draft — validate a draft before activation - relations activate-draft — activate a draft - relations discard-draft — discard a draft without activation - relations upload-urls — generate presigned upload URLs - relations complete-upload — finalise a multipart upload - relations lock — lock document & relation - relations unlock — release lock - DOCUMENTS - documents update — update document metadata - documents download — presigned download URL - documents restore — restore a previous content version - documents delete-version — soft-delete a content version - CONFIG - config domains — list AllowedDomains - config domains-create — register an AllowedDomain - config domains-delete — remove an AllowedDomain - config doctypes — list DocumentTypes - config doctypes-create — create a DocumentType - config doctypes-delete — delete a DocumentType - config botypes — list BusinessObjectNodeTypes - config botypes-create — register a BusinessObjectNodeType - config botypes-delete — delete a BusinessObjectNodeType - config maps — list DocumentType ↔ BO type mappings - config maps-create — create a mapping - config maps-delete — delete a mapping - JOBS - jobs status — poll DocumentService job status - jobs zip — start ZIP_DOWNLOAD job - jobs delete-user-data — start DELETE_USER_DATA job (admin) -""" - -from __future__ import annotations - -import json -import os -import sys -import textwrap -from typing import Optional - -# ── ensure src/ is on the path when run from the repo root ────────────────── -_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -sys.path.insert(0, os.path.join(_REPO_ROOT, "src")) - -from sap_cloud_sdk.adms.client import AdmsClient, create_client -from sap_cloud_sdk.adms.config import load_from_env_or_mount -from sap_cloud_sdk.adms.exceptions import ( - AdmsError, - DocumentNotFoundError, - HttpError, - ScanNotCleanError, -) -from sap_cloud_sdk.adms._models import ( - BaseType, - CreateAllowedDomainInput, - CreateApplicationTenantInput, - CreateBusinessObjectNodeTypeInput, - CreateDocumentInput, - CreateDocumentRelationInput, - CreateDocumentTypeBoTypeMapInput, - CreateDocumentTypeInput, - CreateFileExtensionPolicyInput, - DeleteUserDataJobParameters, - DraftActivateInput, - DraftInput, - FileExtensionPolicy, - UpdateAllowedDomainInput, - UpdateBusinessObjectNodeTypeInput, - UpdateDocumentInput, - UpdateDocumentTypeInput, - ZipDownloadJobParameters, -) - -# ── pretty-printing helpers ────────────────────────────────────────────────── - -# Maps Python snake_case field names → OData PascalCase wire key names -# so CLI output matches Postman / Bruno responses exactly. -_WIRE_KEYS: dict[str, str] = { - # DocumentRelation - "document_relation_id": "DocumentRelationID", - "business_object_node_type_unique_id": "BusinessObjectNodeTypeUniqueID", - "host_business_object_node_id": "HostBusinessObjectNodeID", - "host_business_obj_node_display_id": "HostBusinessObjNodeDisplayID", - "document_id": "DocumentID", - "document_is_active_entity": "DocumentIsActiveEntity", - "is_active_entity": "IsActiveEntity", - "has_active_entity": "HasActiveEntity", - "has_draft_entity": "HasDraftEntity", - "document_relation_is_locked": "DocumentRelationIsLocked", - "document_relation_is_deleted": "DocumentRelationIsDeleted", - "document_relation_is_output_relevant": "DocumentRelationIsOutputRelevant", - "draft_messages": "DraftMessages", - "draft_administrative_data": "DraftAdministrativeData", - # DraftAdministrativeData fields - "draft_uuid": "DraftUUID", - "creation_date_time": "CreationDateTime", - "created_by_user": "CreatedByUser", - "draft_is_created_by_me": "DraftIsCreatedByMe", - "last_change_date_time": "LastChangeDateTime", - "last_changed_by_user": "LastChangedByUser", - "in_process_by_user": "InProcessByUser", - "draft_is_processed_by_me": "DraftIsProcessedByMe", - "doc_relation_created_by_user_name": "DocRelationCreatedByUserName", - "doc_relation_created_at_date_time": "DocRelationCreatedAtDateTime", - "doc_relation_changed_by_user_name": "DocRelationChangedByUserName", - "doc_relation_changed_at_date_time": "DocRelationChangedAtDateTime", - "document": "Document", # expanded DocumentRelation → Document nav property - # Document - "document_name": "DocumentName", - "document_base_type": "DocumentBaseType", - "document_type_id": "DocumentTypeID", - "document_state": "DocumentState", - "document_mime_type": "DocumentMimeType", - "document_description": "DocumentDescription", - "document_size_in_byte": "DocumentSizeInByte", - "document_content_stream_uri": "DocumentContentStreamURI", - "document_external_content_url": "DocumentExternalContentURL", - "document_is_locked": "DocumentIsLocked", - "document_is_soft_deleted": "DocumentIsSoftDeleted", - "has_active_document_entity": "HasActiveDocumentEntity", - "has_draft_document_entity": "HasDraftDocumentEntity", - "draft_uuid": "DraftUUID", - "document_content_upload_urls": "DocumentContentUploadURLs", - "document_is_multi_referenced": "DocumentIsMultiReferenced", - "document_created_by_user_name": "DocumentCreatedByUserName", - "document_created_at_date_time": "DocumentCreatedAtDateTime", - "document_changed_by_user_name": "DocumentChangedByUserName", - "document_changed_at_date_time": "DocumentChangedAtDateTime", - # DocumentContentVersion - "doc_content_version_id": "DocContentVersionID", - "doc_content_version_state": "DocContentVersionState", - "doc_content_version_name": "DocContentVersionName", - "doc_content_version_comment": "DocContentVersionComment", - "doc_content_version_is_latest": "DocContentVersionIsLatest", - "doc_content_version_mime_type": "DocContentVersionMimeType", - "doc_content_version_size_in_byte": "DocContentVersionSizeInByte", - "doc_content_version_stream_uri": "DocContentVersionStreamURI", - "doc_content_version_content_hash": "DocContentVersionContentHash", - "doc_content_version_upload_id": "DocContentVersionUploadID", - "doc_content_version_is_soft_deleted": "DocContentVersionIsSoftDeleted", - # AllowedDomain - "allowed_domain_id": "AllowedDomainID", - "allowed_domain_host_name": "AllowedDomainHostName", - "allowed_domain_protocol": "AllowedDomainProtocol", - "allowed_domain_port": "AllowedDomainPort", - # DocumentType - "document_type_name": "DocumentTypeName", - "document_type_description": "DocumentTypeDescription", - # BusinessObjectNodeType - "business_object_node_type": "BusinessObjectNodeType", - "business_object_node_type_name": "BusinessObjectNodeTypeName", - "odm_entity_name": "ODMEntityName", - "application_tenant_id": "ApplicationTenantID", - # DocumentTypeBusinessObjectTypeMap - "document_type_bo_type_map_id": "DocumentTypeBOTypeMapID", - "is_default": "IsDefault", - # JobOutput - "job_id": "JobID", - "job_status": "JobStatus", - "job_result": "JobResult", - "job_error_details": "JobErrorDetails", - "job_progress_percentage": "JobProgressPercentage", -} - - -def _to_jsonable(obj): - """Recursively convert dataclasses / enums to JSON-serialisable dicts. - - Uses Python snake_case field names — the SDK model as application code sees it. - """ - from enum import Enum - from dataclasses import fields, is_dataclass - - if isinstance(obj, Enum): - return obj.value - elif is_dataclass(obj) and not isinstance(obj, type): - return {f.name: _to_jsonable(getattr(obj, f.name)) for f in fields(obj)} - elif isinstance(obj, list): - return [_to_jsonable(i) for i in obj] - return obj - - -def _print_json(obj) -> None: - """Print a dataclass or dict as indented JSON.""" - print(json.dumps(_to_jsonable(obj), indent=2)) - - -def _field(key: str, val: object) -> None: - print(f" {key + ':':<42} {val}") - - -def _print_document(doc) -> None: - _field("DocumentID", doc.document_id) - _field("IsActiveEntity", doc.is_active_entity) - _field("DocumentName", doc.document_name) - _field("DocumentBaseType", getattr(doc.document_base_type, "value", doc.document_base_type)) - _field("DocumentTypeID", doc.document_type_id) - _field("DocumentMimeType", doc.document_mime_type) - _field("DocumentDescription", doc.document_description) - _field("DocumentSizeInByte", doc.document_size_in_byte) - _field("DocumentContentStreamURI", doc.document_content_stream_uri) - _field("DocumentExternalContentURL", doc.document_external_content_url) - _field("DocumentIsLocked", doc.document_is_locked) - _field("DocumentIsSoftDeleted", doc.document_is_soft_deleted) - _field("HasActiveDocumentEntity", doc.has_active_document_entity) - _field("HasDraftDocumentEntity", doc.has_draft_document_entity) - _field("DraftUUID", doc.draft_uuid) - _field("DocumentContentUploadURLs", doc.document_content_upload_urls) - _field("DocumentIsMultiReferenced", doc.document_is_multi_referenced) - _field("DocumentCreatedByUserName", doc.document_created_by_user_name) - _field("DocumentCreatedAtDateTime", doc.document_created_at_date_time) - _field("DocumentChangedByUserName", doc.document_changed_by_user_name) - _field("DocumentChangedAtDateTime", doc.document_changed_at_date_time) - _field("DocumentState", getattr(doc.document_state, "value", doc.document_state)) - _field("DocumentStateText", doc.document_state_text) - _field("DocumentContentHash", doc.document_content_hash) - - -def _print_list(items, label: str) -> None: - print(f"\n{'─' * 62}") - print(f" {label} ({len(items)} items)") - print(f"{'─' * 62}") - for item in items: - _print_json(item) - print() - - -# ── raw response capture ────────────────────────────────────────────────────── -# When raw mode is on, every HTTP response body is printed verbatim (wire JSON) -# before the parsed SDK model is shown — identical to Postman output. - -_raw_mode = False -_last_raw_responses: list[str] = [] - - -def _patch_http_for_raw(client: AdmsClient) -> None: - """Monkey-patch AdmsHttp._request to capture raw response bodies.""" - from sap_cloud_sdk.adms._http import AdmsHttp - - original_request = AdmsHttp._request - - def _capturing_request(self, method, path, **kwargs): - _last_raw_responses.clear() - resp = original_request(self, method, path, **kwargs) - try: - body = resp.json() - _last_raw_responses.append(json.dumps(body, indent=2)) - except Exception: - _last_raw_responses.append(resp.text) - return resp - - AdmsHttp._request = _capturing_request # type: ignore[method-assign] - - -def _print_raw_if_enabled(label: str = "Raw API response") -> None: - """Print the most recent captured raw response(s) if raw mode is on.""" - if not _raw_mode or not _last_raw_responses: - return - print(f"\n ── {label} (wire JSON) ──") - for body in _last_raw_responses: - print(body) - print() - - -def _print_last_raw() -> None: - """Print the last captured raw wire response (always, no mode check).""" - if _last_raw_responses: - print(_last_raw_responses[-1]) - - -def _clear_raw_captures() -> None: - _last_raw_responses.clear() - - -def _prompt(prompt: str, default: Optional[str] = None) -> str: - if default: - label = f"{prompt} (optional — press Enter to skip, default {default})" - else: - label = prompt - value = input(f" {label}: ").strip() - if not value and default: - return default - return value - - -def _prompt_optional(prompt: str) -> Optional[str]: - """Prompt for an optional value — empty input returns None.""" - value = input(f" {prompt} (optional — press Enter to skip): ").strip() - return value or None - - -def _prompt_int(prompt: str, default: Optional[int] = None) -> Optional[int]: - if default is not None: - label = f"{prompt} (optional — press Enter to skip, default {default})" - else: - label = f"{prompt} (optional — press Enter to skip)" - raw = input(f" {label}: ").strip() - if not raw: - return default - try: - return int(raw) - except ValueError: - _err(f" Invalid integer: {raw!r}. Skipping.") - return None - - -def _prompt_bool(prompt: str, default: bool = False) -> bool: - default_label = "y" if default else "n" - raw = input(f" {prompt} (y/n, default {default_label}): ").strip().lower() - if not raw: - return default - return raw in ("y", "yes", "1", "true") - - -def _confirm(question: str) -> bool: - return input(f"\n {question} (y/n): ").strip().lower() == "y" - - -def _ok(msg: str) -> None: - print(f"\n ✓ {msg}\n") - - -def _err(msg: str) -> None: - print(f"\n ✗ {msg}\n", file=sys.stderr) - - -def _err_exc(label: str, exc: Exception) -> None: - """Print a user-friendly error, including the raw ADM response body when available.""" - _err(f"{label}: {exc}") - if isinstance(exc, HttpError) and exc.response_text: - print(f" ADM response: {exc.response_text}\n", file=sys.stderr) - - -# ── build client ───────────────────────────────────────────────────────────── - - -def _build_client() -> AdmsClient: - try: - config = load_from_env_or_mount("default") - except Exception as exc: - _err(f"Could not load ADMS config: {exc}") - _err("Make sure you ran: set -a && source .env.adms && set +a") - sys.exit(1) - client = create_client(config=config) - print(f" Connected to: {config.service_url}") - return client - - -# ── RELATIONS handlers ─────────────────────────────────────────────────────── - - -def cmd_relations_list(client: AdmsClient) -> None: - print("\nFetching all DocumentRelations …") - items = client.relations.get_all() - _print_list(items, "DocumentRelations") - - -def cmd_relations_get(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - print(f"\nFetching DocumentRelation {relation_id} (IsActiveEntity={str(is_active).lower()}) …") - try: - rel = client.relations.get(relation_id, is_active_entity=is_active) - _print_json(rel) - except DocumentNotFoundError: - _err(f"Relation {relation_id!r} not found.") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_relations_delete(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - if not _confirm(f"Delete relation {relation_id!r} (IsActiveEntity={str(is_active).lower()})?"): - print(" Aborted.") - return - try: - client.relations.delete(relation_id, is_active_entity=is_active) - _ok(f"Deleted {relation_id}") - except DocumentNotFoundError: - _err(f"Relation {relation_id!r} not found.") - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -def _prompt_draft_input() -> Optional[DraftInput]: - bo_type_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") - bo_node_id = _prompt("HostBusinessObjectNodeID") - if not bo_type_id or not bo_node_id: - _err("Both fields are required.") - return None - return DraftInput( - business_object_node_type_unique_id=bo_type_id, - host_business_object_node_id=bo_node_id, - ) - - -def cmd_relations_create_draft(client: AdmsClient) -> None: - print("\n── Create draft DocumentRelations for a BO node ────────────") - draft = _prompt_draft_input() - if not draft: - return - try: - items = client.relations.create_draft(draft) - _print_list(items, "Draft DocumentRelations") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_relations_validate_draft(client: AdmsClient) -> None: - print("\n── Validate draft DocumentRelations ────────────────────────") - draft = _prompt_draft_input() - if not draft: - return - try: - items = client.relations.validate_draft(draft) - _print_list(items, "Validated draft DocumentRelations") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_relations_activate_draft(client: AdmsClient) -> None: - print("\n── Activate draft DocumentRelations ────────────────────────") - bo_type_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") - bo_node_id = _prompt("HostBusinessObjectNodeID") - if not bo_type_id or not bo_node_id: - _err("Both fields are required.") - return - late = _prompt_optional("LateHostBusinessObjectNodeID") - activate = DraftActivateInput( - business_object_node_type_unique_id=bo_type_id, - host_business_object_node_id=bo_node_id, - late_host_business_object_node_id=late, - ) - try: - items = client.relations.activate_draft(activate) - _print_list(items, "Activated DocumentRelations") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_relations_discard_draft(client: AdmsClient) -> None: - print("\n── Discard draft DocumentRelations ─────────────────────────") - draft = _prompt_draft_input() - if not draft: - return - if not _confirm("Discard the draft? This cannot be undone."): - print(" Aborted.") - return - try: - client.relations.discard_draft(draft) - _ok("Draft discarded.") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_relations_upload_urls(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - is_multipart = _prompt_bool("Multipart upload?", default=False) - no_of_parts = _prompt_int("Number of parts", default=1) or 1 - print(f"\nGenerating upload URLs for {relation_id} …") - try: - doc = client.relations.generate_upload_urls( - relation_id, - is_active_entity=is_active, - is_multipart=is_multipart, - no_of_parts=no_of_parts, - ) - _ok(f"Generated {len(doc.document_content_upload_urls)} upload URL(s).") - _print_json(doc) - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_relations_complete_upload(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - print(f"\nCompleting multipart upload for {relation_id} …") - try: - client.relations.complete_multipart_upload(relation_id, is_active_entity=is_active) - _ok("Multipart upload completed.") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_relations_lock(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - try: - client.relations.lock(relation_id, is_active_entity=is_active) - _ok(f"Locked {relation_id}.") - except AdmsError as exc: - _err_exc("Lock failed", exc) - - -def cmd_relations_unlock(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - try: - client.relations.unlock(relation_id, is_active_entity=is_active) - _ok(f"Unlocked {relation_id}.") - except AdmsError as exc: - _err_exc("Unlock failed", exc) - - -def cmd_relations_full_upload(client: AdmsClient) -> None: - """Create relation + document, generate upload URL, PUT file to GCS, then complete.""" - import os - - print("\n── Full upload: create relation → generate URL → PUT file → complete ──") - - bo_type_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") - bo_node_id = _prompt("HostBusinessObjectNodeID") - if not bo_type_id or not bo_node_id: - _err("Both BO fields are required.") - return - bo_display_id = _prompt_optional("HostBusinessObjNodeDisplayID") - doc_name = _prompt("DocumentName (e.g. test.pdf)") - doc_type_id = _prompt("DocumentTypeID") - if not doc_name or not doc_type_id: - _err("DocumentName and DocumentTypeID are required.") - return - doc_desc = _prompt_optional("DocumentDescription") - doc_base_type_raw = _prompt("DocumentBaseType (D=file, F=folder, U=URL)", default="D").upper() - try: - doc_base_type = BaseType(doc_base_type_raw) - except ValueError: - _err(f"Invalid DocumentBaseType {doc_base_type_raw!r} — must be D, F, or U.") - return - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - is_multipart = _prompt_bool("Multipart upload?", default=False) - no_of_parts = _prompt_int("Number of parts", default=1) or 1 - file_path = _prompt("Local file path") - if not file_path or not os.path.isfile(file_path): - _err(f"File not found: {file_path!r}") - return - file_name = os.path.basename(file_path) - - print(f"\nStep 1 — Creating relation + document …") - try: - rel = client.relations.create( - CreateDocumentRelationInput( - business_object_node_type_unique_id=bo_type_id, - host_business_object_node_id=bo_node_id, - host_business_obj_node_display_id=bo_display_id, - is_active_entity=is_active, - document=CreateDocumentInput( - document_name=doc_name, - document_type_id=doc_type_id, - document_description=doc_desc, - document_base_type=doc_base_type, - document_is_multipart=is_multipart, - document_no_of_parts=no_of_parts if is_multipart else None, - ), - ) - ) - except AdmsError as exc: - _err_exc("Create failed", exc) - return - _ok(f"Relation created: {rel.document_relation_id}") - _print_json(rel) - - print(f"\nStep 2 — Generating upload URL(s) for {rel.document_relation_id} …") - try: - doc = client.relations.generate_upload_urls( - rel.document_relation_id, - is_active_entity=is_active, - is_multipart=is_multipart, - no_of_parts=no_of_parts, - ) - except AdmsError as exc: - _err_exc("Failed to generate upload URLs", exc) - return - - urls = doc.document_content_upload_urls - if not urls: - _err("No upload URLs returned by ADM.") - return - _ok(f"Got {len(urls)} URL(s).") - upload_url = urls[0] - - adm_filename = doc.document_name or file_name - - print(f"\nStep 3 — Uploading {file_name!r} to GCS …") - try: - import urllib.request as _urllib_request - import ssl as _ssl - import mimetypes - - _ssl_ctx = _ssl.create_default_context() - _ssl_ctx.check_hostname = False - _ssl_ctx.verify_mode = _ssl.CERT_NONE - - with open(file_path, "rb") as f: - file_bytes = f.read() - - mime_type, _ = mimetypes.guess_type(file_path) - if not mime_type: - mime_type = "application/octet-stream" - - put_url = urls[0] - - headers = {"Content-Type": mime_type} - if not is_multipart: - headers["x-goog-meta-filename"] = adm_filename - - print(f" → Content-Type: {mime_type}") - - req = _urllib_request.Request(put_url, data=file_bytes, method="PUT", headers=headers) - try: - with _urllib_request.urlopen(req, timeout=120, context=_ssl_ctx) as response: - status = response.status - except _urllib_request.HTTPError as exc: - body = exc.read().decode("utf-8", errors="replace") - _err(f"GCS upload failed: HTTP {exc.code} — {body[:400]}") - return - if status not in (200, 204): - _err(f"GCS upload failed: HTTP {status}") - return - _ok(f"Uploaded {len(file_bytes):,} bytes.") - except Exception as exc: - _err(f"Upload error: {exc}") - return - - if is_multipart: - print(f"\nStep 4 — Completing multipart upload for {rel.document_relation_id} …") - try: - client.relations.complete_multipart_upload(rel.document_relation_id, is_active_entity=is_active) - _ok("Multipart upload completed.") - except AdmsError as exc: - _err_exc("Complete upload failed", exc) - else: - _ok("Single-part upload complete — no rcu step needed.") - - -def cmd_relations_delete_bo_node(client: AdmsClient) -> None: - print("\n── Delete all DocumentRelations for a BO node ──────────────") - print(" ⚠ This permanently deletes ALL relations for the given BO node.") - draft = _prompt_draft_input() - if not draft: - return - if not _confirm("Delete ALL relations for this BO node? This is irreversible."): - print(" Aborted.") - return - try: - result = client.relations.delete_business_object_node(draft) - _ok(f"Deleted {result.relations_deleted} relation(s).") - _print_json(result) - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -def cmd_relations_change_logs(client: AdmsClient) -> None: - print("\nFetching BusinessObjectNodeChangeLog …") - items = client.relations.get_bo_node_change_logs() - _print_list(items, "BusinessObjectNodeChangeLog") - - -def cmd_relations_bo_change_logs(client: AdmsClient) -> None: - print("\nFetching BusinessObjectNodeChangeLog …") - items = client.relations.get_bo_node_change_logs() - _print_list(items, "BusinessObjectNodeChangeLog") - - -# ── DOCUMENTS handlers ─────────────────────────────────────────────────────── - - -def cmd_documents_update(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - print(" Leave any field blank to leave it unchanged.") - document_name = _prompt_optional("New document name") - document_description = _prompt_optional("New description") - document_type_id = _prompt_optional("New DocumentTypeID") - doc_content_version_comment = _prompt_optional("Content version comment") - document_external_content_url = _prompt_optional("New external URL") - is_content_update_raw = _prompt_bool("Is content update? (new file version)", default=False) - is_content_update = is_content_update_raw if is_content_update_raw else None - update = UpdateDocumentInput( - document_name=document_name, - document_description=document_description, - document_type_id=document_type_id, - doc_content_version_comment=doc_content_version_comment, - document_external_content_url=document_external_content_url, - is_content_update=is_content_update, - ) - if not any(v is not None for v in update.__dict__.values()): - _err("Nothing to update — all fields blank.") - return - try: - doc = client.documents.update(relation_id, update, is_active_entity=is_active) - _ok("Document updated.") - _print_json(doc) - except AdmsError as exc: - _err_exc("Update failed", exc) - - -def cmd_documents_download(client: AdmsClient, relation_id: str) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - version = _prompt_optional("DocContentVersionID (blank = latest)") - print("\nFetching presigned download URL …") - try: - url = client.documents.get_download_url( - document_relation_id=relation_id, - is_active_entity=is_active, - doc_content_version_id=version or None, - ) - _ok("Presigned URL (valid for a short time — do not cache):") - print(f" {url}\n") - except ScanNotCleanError as exc: - _err_exc("Download blocked — scan not CLEAN", exc) - except DocumentNotFoundError: - _err(f"Relation {relation_id!r} not found.") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_documents_restore( - client: AdmsClient, relation_id: str, version_id: str -) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - comment = _prompt_optional("Comment (optional)") - print(f"\nRestoring version {version_id} on {relation_id} …") - try: - doc = client.documents.restore_content_version( - relation_id, version_id, is_active_entity=is_active, comment=comment - ) - _ok(f"Restored version {version_id}.") - _print_json(doc) - except AdmsError as exc: - _err_exc("Restore failed", exc) - - -def cmd_documents_delete_version( - client: AdmsClient, relation_id: str, version_id: str -) -> None: - is_active = _prompt_bool("Active entity? (No = draft)", default=True) - if not _confirm(f"Soft-delete version {version_id} on relation {relation_id} (IsActiveEntity={str(is_active).lower()})?"): - print(" Aborted.") - return - try: - client.documents.delete_content_version(relation_id, version_id, is_active_entity=is_active) - _ok(f"Deleted version {version_id}.") - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -# ── CONFIGURATION handlers ─────────────────────────────────────────────────── - - -def cmd_config_domains(client: AdmsClient) -> None: - print("\nFetching AllowedDomains …") - items = client.config.get_all_allowed_domains() - _print_list(items, "AllowedDomains") - - -def cmd_config_domains_create(client: AdmsClient) -> None: - print("\n── Create AllowedDomain ────────────────────────────────────") - host = _prompt("AllowedDomainHostName * (mandatory, e.g. storage.example.com)") - proto = _prompt("AllowedDomainProtocol", default="https") - port = _prompt_int("AllowedDomainPort") - if not host or not proto: - _err("Hostname and protocol are required.") - return - try: - out = client.config.create_allowed_domain( - CreateAllowedDomainInput(host_name=host, protocol=proto, port=port) - ) - _ok(f"Created AllowedDomain {out.allowed_domain_id}") - _print_last_raw() - except AdmsError as exc: - _err_exc("Create failed", exc) - - -def cmd_config_domains_get(client: AdmsClient, allowed_domain_id: str) -> None: - print(f"\nFetching AllowedDomain {allowed_domain_id} …") - try: - out = client.config.get_allowed_domain(allowed_domain_id) - _print_last_raw() - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_config_domains_update(client: AdmsClient, allowed_domain_id: str) -> None: - print("\n── Update AllowedDomain ────────────────────────────────────") - print(" Leave any field blank to leave it unchanged.") - update = UpdateAllowedDomainInput( - host_name=_prompt_optional("New hostname"), - protocol=_prompt_optional("New protocol (https/http)"), - port=_prompt_int("New port (blank = unchanged)"), - ) - if not any(v is not None for v in update.__dict__.values()): - _err("Nothing to update — all fields blank.") - return - try: - out = client.config.update_allowed_domain(allowed_domain_id, update) - _ok(f"Updated AllowedDomain {out.allowed_domain_id}.") - _print_last_raw() - except AdmsError as exc: - _err_exc("Update failed", exc) - - -def cmd_config_domains_delete(client: AdmsClient, allowed_domain_id: str) -> None: - if not _confirm(f"Delete AllowedDomain {allowed_domain_id!r}?"): - print(" Aborted.") - return - try: - client.config.delete_allowed_domain(allowed_domain_id) - _ok(f"Deleted {allowed_domain_id}.") - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -def cmd_config_doctypes(client: AdmsClient) -> None: - print("\nFetching DocumentTypes …") - items = client.config.get_all_document_types() - _print_list(items, "DocumentTypes") - - -def cmd_config_doctypes_create(client: AdmsClient) -> None: - print("\n── Create DocumentType ─────────────────────────────────────") - type_id = _prompt("DocumentTypeID * (mandatory, max 10 chars)") - name = _prompt("DocumentTypeName * (mandatory)") - desc = _prompt_optional("DocumentTypeDescription") - if not type_id or not name: - _err("DocumentTypeID and DocumentTypeName are required.") - return - try: - out = client.config.create_document_type( - CreateDocumentTypeInput( - document_type_id=type_id, - document_type_name=name, - document_type_description=desc, - ) - ) - _ok(f"Created DocumentType {out.document_type_id}.") - _print_last_raw() - except AdmsError as exc: - _err_exc("Create failed", exc) - - -def cmd_config_doctypes_get(client: AdmsClient, document_type_id: str) -> None: - print(f"\nFetching DocumentType {document_type_id} …") - try: - out = client.config.get_document_type(document_type_id) - _print_last_raw() - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_config_doctypes_update(client: AdmsClient, document_type_id: str) -> None: - print("\n── Update DocumentType ─────────────────────────────────────") - print(" Leave any field blank to leave it unchanged.") - update = UpdateDocumentTypeInput( - document_type_name=_prompt_optional("New DocumentTypeName"), - document_type_description=_prompt_optional("New description"), - ) - if not any(v is not None for v in update.__dict__.values()): - _err("Nothing to update — all fields blank.") - return - try: - out = client.config.update_document_type(document_type_id, update) - _ok(f"Updated DocumentType {out.document_type_id}.") - _print_last_raw() - except AdmsError as exc: - _err_exc("Update failed", exc) - - -def cmd_config_doctypes_delete(client: AdmsClient, document_type_id: str) -> None: - if not _confirm(f"Delete DocumentType {document_type_id!r}?"): - print(" Aborted.") - return - try: - client.config.delete_document_type(document_type_id) - _ok(f"Deleted {document_type_id}.") - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -def cmd_config_botypes(client: AdmsClient) -> None: - print("\nFetching BusinessObjectNodeTypes …") - items = client.config.get_all_business_object_types() - _print_list(items, "BusinessObjectNodeTypes") - - -def cmd_config_botypes_create(client: AdmsClient) -> None: - print("\n── Create BusinessObjectNodeType ───────────────────────────") - bo_type = _prompt("BusinessObjectNodeType * (mandatory, short code, e.g. PO)") - bo_name = _prompt("BusinessObjectNodeTypeName * (mandatory)") - tenant_id = _prompt("ApplicationTenantID * (mandatory, UUID)") - odm_name = _prompt_optional("ODMEntityName") - if not bo_type or not bo_name or not tenant_id: - _err("BusinessObjectNodeType, name, and ApplicationTenantID are required.") - return - try: - out = client.config.create_business_object_type( - CreateBusinessObjectNodeTypeInput( - business_object_node_type=bo_type, - business_object_node_type_name=bo_name, - application_tenant_id=tenant_id, - odm_entity_name=odm_name, - ) - ) - _ok( - f"Created BusinessObjectNodeType {out.business_object_node_type_unique_id}." - ) - _print_last_raw() - except AdmsError as exc: - _err_exc("Create failed", exc) - - -def cmd_config_botypes_get(client: AdmsClient, bo_type_unique_id: str) -> None: - print(f"\nFetching BusinessObjectNodeType {bo_type_unique_id} …") - try: - out = client.config.get_business_object_type(bo_type_unique_id) - _print_last_raw() - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_config_botypes_update(client: AdmsClient, bo_type_unique_id: str) -> None: - print("\n── Update BusinessObjectNodeType ───────────────────────────") - print(" Leave any field blank to leave it unchanged.") - update = UpdateBusinessObjectNodeTypeInput( - business_object_node_type=_prompt_optional("New BusinessObjectNodeType code"), - business_object_node_type_name=_prompt_optional("New BusinessObjectNodeTypeName"), - ) - if not any(v is not None for v in update.__dict__.values()): - _err("Nothing to update — all fields blank.") - return - try: - out = client.config.update_business_object_type(bo_type_unique_id, update) - _ok(f"Updated BusinessObjectNodeType {out.business_object_node_type_unique_id}.") - _print_last_raw() - except AdmsError as exc: - _err_exc("Update failed", exc) - - -def cmd_config_botypes_delete(client: AdmsClient, bo_type_unique_id: str) -> None: - if not _confirm(f"Delete BusinessObjectNodeType {bo_type_unique_id!r}?"): - print(" Aborted.") - return - try: - client.config.delete_business_object_type(bo_type_unique_id) - _ok(f"Deleted {bo_type_unique_id}.") - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -def cmd_config_maps(client: AdmsClient) -> None: - print("\nFetching DocumentType ↔ BusinessObjectNodeType mappings …") - items = client.config.get_type_mappings() - _print_list(items, "DocumentTypeBusinessObjectTypeMaps") - - -def cmd_config_maps_create(client: AdmsClient) -> None: - print("\n── Create DocumentType ↔ BO type mapping ───────────────────") - bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID * (mandatory, UUID)") - doc_type_id = _prompt("DocumentTypeID * (mandatory)") - if not bo_unique_id or not doc_type_id: - _err("Both IDs are required.") - return - try: - out = client.config.create_type_mapping( - CreateDocumentTypeBoTypeMapInput( - business_object_node_type_unique_id=bo_unique_id, - document_type_id=doc_type_id, - ) - ) - _ok(f"Created mapping {out.document_type_id}:{out.business_object_node_type_unique_id}.") - _print_last_raw() - except AdmsError as exc: - _err_exc("Create failed", exc) - - -def cmd_config_maps_get(client: AdmsClient) -> None: - doc_type_id = _prompt("DocumentTypeID * (mandatory)") - bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID * (mandatory, UUID)") - if not doc_type_id or not bo_unique_id: - _err("Both fields are required.") - return - print(f"\nFetching mapping {doc_type_id}:{bo_unique_id} …") - try: - out = client.config.get_type_mapping(doc_type_id, bo_unique_id) - _print_last_raw() - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_config_maps_delete(client: AdmsClient) -> None: - doc_type_id = _prompt("DocumentTypeID * (mandatory)") - bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID * (mandatory, UUID)") - if not doc_type_id or not bo_unique_id: - _err("Both fields are required.") - return - if not _confirm(f"Delete mapping {doc_type_id}:{bo_unique_id}?"): - print(" Aborted.") - return - try: - client.config.delete_type_mapping(doc_type_id, bo_unique_id) - _ok(f"Deleted mapping {doc_type_id}:{bo_unique_id}.") - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -def cmd_config_maps_mark_default(client: AdmsClient) -> None: - doc_type_id = _prompt("DocumentTypeID * (mandatory)") - bo_unique_id = _prompt("BusinessObjectNodeTypeUniqueID * (mandatory, UUID)") - if not doc_type_id or not bo_unique_id: - _err("Both fields are required.") - return - try: - client.config.mark_default(doc_type_id, bo_unique_id) - _ok(f"Mapping {doc_type_id}:{bo_unique_id} marked as default.") - except AdmsError as exc: - _err_exc("Failed", exc) - - -# ── CONFIG: FileExtensionPolicy ─────────────────────────────────────────────── - - -def cmd_config_file_ext_list(client: AdmsClient) -> None: - print("\nFetching FileExtensionPolicies …") - items = client.config.get_all_file_extension_policies() - _print_list(items, "FileExtensionPolicies") - - -def cmd_config_file_ext_create(client: AdmsClient) -> None: - print("\n── Create DocumentTypeFileExtensionPolicy ──────────────────") - doc_type_id = _prompt("DocumentTypeID * (mandatory)") - ext = _prompt("FileExtension * (mandatory, e.g. pdf, exe)") - if not doc_type_id or not ext: - _err("Both fields are required.") - return - try: - out = client.config.create_file_extension_policy( - CreateFileExtensionPolicyInput( - document_type_id=doc_type_id, - file_extension=ext, - ) - ) - _ok(f"Created DocumentTypeFileExtensionPolicy {out.document_type_id}:{out.file_extension}.") - _print_last_raw() - except AdmsError as exc: - _err_exc("Create failed", exc) - - -def cmd_config_global_file_extensions(client: AdmsClient) -> None: - print("\nFetching global allowed file extensions …") - try: - resp = client.config._http.get( - "GetGlobalAllowedFileExtensions()", - service_base="/odata/v4/ConfigurationService", - ) - _print_json(resp.json()) - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_config_file_ext_delete(client: AdmsClient) -> None: - doc_type_id = _prompt("DocumentTypeID * (mandatory)") - ext = _prompt("FileExtension * (mandatory)") - if not doc_type_id or not ext: - _err("Both fields are required.") - return - if not _confirm(f"Delete DocumentTypeFileExtensionPolicy {doc_type_id}:{ext}?"): - print(" Aborted.") - return - try: - client.config.delete_file_extension_policy(doc_type_id, ext) - _ok(f"Deleted {doc_type_id}:{ext}.") - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -# ── CONFIG: ApplicationTenant ───────────────────────────────────────────────── - - -def cmd_config_tenant_list(client: AdmsClient) -> None: - subaccount_id = _prompt_optional("Subaccount ID (x-subaccount-id header)") - print("\nFetching ApplicationTenants …") - items = client.config.get_all_application_tenants(subaccount_id=subaccount_id) - _print_list(items, "ApplicationTenants") - - -def cmd_config_tenant_create(client: AdmsClient) -> None: - print("\n── Create ApplicationTenant ────────────────────────────────") - tenant_id = _prompt("ApplicationTenantID * (mandatory)") - tenant_name = _prompt("ApplicationTenantName * (mandatory)") - subaccount_id = _prompt("Subaccount ID * (mandatory, x-subaccount-id header)") - if not tenant_id or not tenant_name or not subaccount_id: - _err("ApplicationTenantID, name, and Subaccount ID are required.") - return - try: - out = client.config.create_application_tenant( - CreateApplicationTenantInput( - application_tenant_id=tenant_id, - application_tenant_name=tenant_name, - ), - subaccount_id=subaccount_id, - ) - _ok(f"Created ApplicationTenant {out.application_tenant_id}.") - _print_last_raw() - except AdmsError as exc: - _err_exc("Create failed", exc) - - -def cmd_config_tenant_get(client: AdmsClient, tenant_id: str) -> None: - subaccount_id = _prompt_optional("Subaccount ID (x-subaccount-id header)") - print(f"\nFetching ApplicationTenant {tenant_id} …") - try: - out = client.config.get_application_tenant(tenant_id, subaccount_id=subaccount_id) - _print_last_raw() - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_config_tenant_delete(client: AdmsClient, tenant_id: str) -> None: - subaccount_id = _prompt_optional("Subaccount ID (x-subaccount-id header)") - if not _confirm(f"Delete ApplicationTenant {tenant_id!r}?"): - print(" Aborted.") - return - try: - client.config.delete_application_tenant(tenant_id, subaccount_id=subaccount_id) - _ok(f"Deleted {tenant_id}.") - except AdmsError as exc: - _err_exc("Delete failed", exc) - - -# ── JOBS handlers ──────────────────────────────────────────────────────────── - - -def cmd_jobs_status( - client: AdmsClient, job_id: str, *, use_admin_service: bool = False -) -> None: - service = "AdminService" if use_admin_service else "DocumentService" - print(f"\nFetching {service} status for job {job_id} …") - try: - output = client.jobs.get_status(job_id, use_admin_service=use_admin_service) - _print_json(output) - if output.job_status and output.job_status.is_terminal(): - _ok(f"Job is in terminal state: {output.job_status.value}") - else: - print(f" ⟳ Job still running: {output.job_status}") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_jobs_zip(client: AdmsClient) -> None: - print("\n── Start ZIP_DOWNLOAD job ──────────────────────────────────") - bo_type_id = _prompt("BusinessObjectNodeTypeUniqueID (UUID)") - bo_node_id = _prompt("HostBusinessObjectNodeID") - if not bo_type_id or not bo_node_id: - _err("Both fields are required.") - return - rel_ids_raw = _prompt_optional( - "DocumentRelationIDs (comma-separated UUIDs, blank = all)" - ) - rel_ids = ( - [r.strip() for r in rel_ids_raw.split(",") if r.strip()] - if rel_ids_raw - else [] - ) - print(f"\nStarting ZIP_DOWNLOAD job for {bo_node_id} …") - try: - output = client.jobs.start_zip_download( - ZipDownloadJobParameters( - business_object_node_type_unique_id=bo_type_id, - host_business_object_node_id=bo_node_id, - document_relation_ids=rel_ids, - ) - ) - _ok(f"Job started: {output.job_id} status={output.job_status}") - _print_json(output) - print(f" → Poll with: js (Job ID: {output.job_id})") - except AdmsError as exc: - _err_exc("Failed", exc) - - -def cmd_jobs_delete_user_data(client: AdmsClient) -> None: - print("\n── Start DELETE_USER_DATA job (AdminService) ───────────────") - print(" ⚠ This is a GDPR erasure operation and is irreversible.") - user_id = _prompt("UserID to erase") - if not user_id: - _err("UserID is required.") - return - replacement = _prompt_optional("ReplacementUserID (default: SYSTEM)") - if not _confirm(f"Erase all references to user {user_id!r}? This is irreversible."): - print(" Aborted.") - return - try: - output = client.jobs.start_delete_user_data( - DeleteUserDataJobParameters( - user_id=user_id, - replacement_user_id=replacement, - ) - ) - _ok(f"Job started: {output.job_id} status={output.job_status}") - _print_json(output) - print(f" → Poll with: js (Job ID: {output.job_id})") - except AdmsError as exc: - _err_exc("Failed", exc) - - -# ── interactive menu ────────────────────────────────────────────────────────── - -_MENU = textwrap.dedent(""" - ┌──────────────────────────────────────────────────────────────────┐ - │ ADMS Interactive CLI │ - ├──────────────────────────────────────────────────────────────────┤ - │ RELATIONS (AdmsRelationsClientApi) │ - │ rl — list all DocumentRelations │ - │ rg — get relation by ID │ - │ rd — delete relation by ID │ - │ rcd — createDraft (BO type + node ID) │ - │ rvd — validateDraft (BO type + node ID) │ - │ rad — activateDraft (BO type + node ID) │ - │ rdd — discardDraft (BO type + node ID) │ - │ ru — generateUploadUrls (relation ID) │ - │ rfu — fullUpload: create relation + PUT file + complete │ - │ rcu — completeMultipartUpload (relation ID) │ - │ rlk — lock relation │ - │ ruk — unlock relation │ - │ rbn — deleteBusinessObjectNode [irreversible!] │ - │ rcl — getChangeLog (all changes) │ - │ rbl — getBusinessObjectNodeChangeLog │ - ├──────────────────────────────────────────────────────────────────┤ - │ DOCUMENTS (AdmsDocumentsClientApi) │ - │ du — update Document (rename) │ - │ dd — getDownloadUrl (pre-signed URL) │ - │ drv — restoreContentVersion │ - │ ddv — deleteContentVersion │ - ├──────────────────────────────────────────────────────────────────┤ - │ CONFIGURATION (AdmsConfigClientApi) │ - │ cd — list AllowedDomains │ - │ cdg — get AllowedDomain by ID │ - │ cda — create AllowedDomain │ - │ cdu — update AllowedDomain │ - │ cdd — delete AllowedDomain │ - │ ct — list DocumentTypes │ - │ ctg — get DocumentType by ID │ - │ cta — create DocumentType │ - │ ctu — update DocumentType │ - │ ctd — delete DocumentType │ - │ cb — list BusinessObjectNodeTypes │ - │ cbg — get BusinessObjectNodeType by ID │ - │ cba — create BusinessObjectNodeType │ - │ cbu — update BusinessObjectNodeType │ - │ cbd — delete BusinessObjectNodeType │ - │ cm — list DocType ↔ BOType mappings │ - │ cmg — get mapping by ID │ - │ cma — create mapping │ - │ cmd — delete mapping │ - │ cmk — markDefault (mapping ID) │ - │ cfl — list FileExtensionPolicies │ - │ cgf — GetGlobalAllowedFileExtensions │ - │ cfc — create FileExtensionPolicy │ - │ cfd — delete FileExtensionPolicy │ - │ cal — list ApplicationTenants │ - │ cag — get ApplicationTenant by ID │ - │ cac — create ApplicationTenant │ - │ cad — delete ApplicationTenant │ - ├──────────────────────────────────────────────────────────────────┤ - │ JOBS (AdmsJobsClientApi) │ - │ js — getStatus (job ID) │ - │ jz — startZipDownload (relation IDs CSV) │ - │ jd — startDeleteUserData (user ID) [GDPR — irreversible!] │ - ├──────────────────────────────────────────────────────────────────┤ - │ OTHER │ - │ raw — toggle raw wire JSON output (shows exact Postman response) │ - │ ? — re-print this menu │ - │ q — quit │ - └──────────────────────────────────────────────────────────────────┘ -""") - - -def _interactive(client: AdmsClient) -> None: - global _raw_mode - print(_MENU) - while True: - try: - choice = input("adms> ").strip().lower() - except (EOFError, KeyboardInterrupt): - print("\nBye.") - break - - if not choice: - continue - elif choice in ("q", "quit", "exit"): - print("Bye.") - break - elif choice in ("?", "help", "h"): - print(_MENU) - elif choice == "raw": - _raw_mode = not _raw_mode - state = "ON — wire JSON printed after each response" if _raw_mode else "OFF — SDK model printed only" - print(f"\n Raw mode: {state}\n") - continue - - _clear_raw_captures() - - # ── relations ── - if choice == "rl": - cmd_relations_list(client) - elif choice == "rg": - rid = _prompt("DocumentRelationID") - if rid: - cmd_relations_get(client, rid) - elif choice == "rd": - rid = _prompt("DocumentRelationID") - if rid: - cmd_relations_delete(client, rid) - elif choice == "rcd": - cmd_relations_create_draft(client) - elif choice == "rvd": - cmd_relations_validate_draft(client) - elif choice == "rad": - cmd_relations_activate_draft(client) - elif choice == "rdd": - cmd_relations_discard_draft(client) - elif choice == "ru": - rid = _prompt("DocumentRelationID") - if rid: - cmd_relations_upload_urls(client, rid) - elif choice == "rcu": - rid = _prompt("DocumentRelationID") - if rid: - cmd_relations_complete_upload(client, rid) - elif choice == "rlk": - rid = _prompt("DocumentRelationID") - if rid: - cmd_relations_lock(client, rid) - elif choice == "ruk": - rid = _prompt("DocumentRelationID") - if rid: - cmd_relations_unlock(client, rid) - elif choice == "rfu": - cmd_relations_full_upload(client) - elif choice == "rbn": - cmd_relations_delete_bo_node(client) - elif choice == "rcl": - cmd_relations_change_logs(client) - elif choice == "rbl": - cmd_relations_bo_change_logs(client) - - # ── documents ── - elif choice == "du": - rid = _prompt("DocumentRelationID") - if rid: - cmd_documents_update(client, rid) - elif choice == "dd": - rid = _prompt("DocumentRelationID") - if rid: - cmd_documents_download(client, rid) - elif choice == "drv": - rid = _prompt("DocumentRelationID") - ver = _prompt("DocContentVersionID to restore", default="1.0") - if rid and ver: - cmd_documents_restore(client, rid, ver) - elif choice == "ddv": - rid = _prompt("DocumentRelationID") - ver = _prompt("DocContentVersionID to delete") - if rid and ver: - cmd_documents_delete_version(client, rid, ver) - - # ── config: AllowedDomain ── - elif choice == "cd": - cmd_config_domains(client) - elif choice == "cdg": - did = _prompt("AllowedDomainID") - if did: - cmd_config_domains_get(client, did) - elif choice == "cda": - cmd_config_domains_create(client) - elif choice == "cdu": - did = _prompt("AllowedDomainID to update") - if did: - cmd_config_domains_update(client, did) - elif choice == "cdd": - did = _prompt("AllowedDomainID to delete") - if did: - cmd_config_domains_delete(client, did) - - # ── config: DocumentType ── - elif choice == "ct": - cmd_config_doctypes(client) - elif choice == "ctg": - tid = _prompt("DocumentTypeID") - if tid: - cmd_config_doctypes_get(client, tid) - elif choice == "cta": - cmd_config_doctypes_create(client) - elif choice == "ctu": - tid = _prompt("DocumentTypeID to update") - if tid: - cmd_config_doctypes_update(client, tid) - elif choice == "ctd": - tid = _prompt("DocumentTypeID to delete") - if tid: - cmd_config_doctypes_delete(client, tid) - - # ── config: BusinessObjectNodeType ── - elif choice == "cb": - cmd_config_botypes(client) - elif choice == "cbg": - bid = _prompt("BusinessObjectNodeTypeUniqueID") - if bid: - cmd_config_botypes_get(client, bid) - elif choice == "cba": - cmd_config_botypes_create(client) - elif choice == "cbu": - bid = _prompt("BusinessObjectNodeTypeUniqueID to update") - if bid: - cmd_config_botypes_update(client, bid) - elif choice == "cbd": - bid = _prompt("BusinessObjectNodeTypeUniqueID to delete") - if bid: - cmd_config_botypes_delete(client, bid) - - # ── config: type mappings ── - elif choice == "cm": - cmd_config_maps(client) - elif choice == "cmg": - cmd_config_maps_get(client) - elif choice == "cma": - cmd_config_maps_create(client) - elif choice == "cmd": - cmd_config_maps_delete(client) - elif choice == "cmk": - cmd_config_maps_mark_default(client) - - # ── config: FileExtensionPolicy ── - elif choice == "cfl": - cmd_config_file_ext_list(client) - elif choice == "cgf": - cmd_config_global_file_extensions(client) - elif choice == "cfc": - cmd_config_file_ext_create(client) - elif choice == "cfd": - cmd_config_file_ext_delete(client) - - # ── config: ApplicationTenant ── - elif choice == "cal": - cmd_config_tenant_list(client) - elif choice == "cag": - tid = _prompt("ApplicationTenantID") - if tid: - cmd_config_tenant_get(client, tid) - elif choice == "cac": - cmd_config_tenant_create(client) - elif choice == "cad": - tid = _prompt("ApplicationTenantID to delete") - if tid: - cmd_config_tenant_delete(client, tid) - - # ── jobs ── - elif choice == "js": - job_id = _prompt("Job ID") - if job_id: - cmd_jobs_status(client, job_id, use_admin_service=False) - elif choice == "jz": - cmd_jobs_zip(client) - elif choice == "jd": - cmd_jobs_delete_user_data(client) - - else: - print( - f" Unknown command: {choice!r} (type '?' for the menu, 'q' to quit)" - ) - continue - - _print_raw_if_enabled() - - -# ── CLI argument dispatch ───────────────────────────────────────────────────── - - -def _cli(client: AdmsClient, args: list[str]) -> None: - if not args: - _interactive(client) - return - - cmd = args[0] - - if cmd == "relations": - sub = args[1] if len(args) > 1 else "" - if sub == "list": - cmd_relations_list(client) - elif sub == "get" and len(args) > 2: - cmd_relations_get(client, args[2]) - elif sub == "delete" and len(args) > 2: - cmd_relations_delete(client, args[2]) - elif sub == "create-draft": - cmd_relations_create_draft(client) - elif sub == "validate-draft": - cmd_relations_validate_draft(client) - elif sub == "activate-draft": - cmd_relations_activate_draft(client) - elif sub == "discard-draft": - cmd_relations_discard_draft(client) - elif sub == "upload-urls" and len(args) > 2: - cmd_relations_upload_urls(client, args[2]) - elif sub == "complete-upload" and len(args) > 2: - cmd_relations_complete_upload(client, args[2]) - elif sub == "lock" and len(args) > 2: - cmd_relations_lock(client, args[2]) - elif sub == "unlock" and len(args) > 2: - cmd_relations_unlock(client, args[2]) - else: - _err( - "Usage: relations list | get | delete " - "| create-draft | validate-draft | activate-draft | discard-draft " - "| upload-urls | complete-upload | lock | unlock " - ) - - elif cmd == "documents": - sub = args[1] if len(args) > 1 else "" - if sub == "update" and len(args) > 2: - cmd_documents_update(client, args[2]) - elif sub == "download" and len(args) > 2: - cmd_documents_download(client, args[2]) - elif sub == "restore" and len(args) > 3: - cmd_documents_restore(client, args[2], args[3]) - elif sub == "delete-version" and len(args) > 3: - cmd_documents_delete_version(client, args[2], args[3]) - else: - _err( - "Usage: documents update | download " - "| restore | delete-version " - ) - - elif cmd == "config": - sub = args[1] if len(args) > 1 else "" - if sub == "domains": - cmd_config_domains(client) - elif sub == "domains-create": - cmd_config_domains_create(client) - elif sub == "domains-delete" and len(args) > 2: - cmd_config_domains_delete(client, args[2]) - elif sub == "doctypes": - cmd_config_doctypes(client) - elif sub == "doctypes-create": - cmd_config_doctypes_create(client) - elif sub == "doctypes-delete" and len(args) > 2: - cmd_config_doctypes_delete(client, args[2]) - elif sub == "botypes": - cmd_config_botypes(client) - elif sub == "botypes-create": - cmd_config_botypes_create(client) - elif sub == "botypes-delete" and len(args) > 2: - cmd_config_botypes_delete(client, args[2]) - elif sub == "maps": - cmd_config_maps(client) - elif sub == "maps-create": - cmd_config_maps_create(client) - elif sub == "maps-delete": - cmd_config_maps_delete(client) - else: - _err( - "Usage: config domains[-create|-delete ] " - "| doctypes[-create|-delete ] " - "| botypes[-create|-delete ] | maps[-create|-delete ]" - ) - - elif cmd == "jobs": - sub = args[1] if len(args) > 1 else "" - if sub == "status" and len(args) > 2: - cmd_jobs_status(client, args[2], use_admin_service=False) - elif sub == "zip": - cmd_jobs_zip(client) - elif sub == "delete-user-data": - cmd_jobs_delete_user_data(client) - else: - _err( - "Usage: jobs status | jobs zip | jobs delete-user-data" - ) - - else: - _err(f"Unknown command: {cmd!r}") - print("Run without arguments for the interactive menu.") - - -# ── entry point ─────────────────────────────────────────────────────────────── - -if __name__ == "__main__": - _client = _build_client() - _patch_http_for_raw(_client) - _cli(_client, sys.argv[1:]) From a8a37460908e15f0cf5aa70a1b9d5497e8df7285 Mon Sep 17 00:00:00 2001 From: I766515 Date: Thu, 2 Jul 2026 09:25:10 +0530 Subject: [PATCH 05/13] fix(adms): document composite-key rationale for get/delete/mark_default type mapping The ADM DocumentTypeBusinessObjectTypeMap entity uses a composite key (DocumentTypeID + BusinessObjectNodeTypeUniqueID). The previous single-argument signature used a fabricated DocumentTypeBOTypeMapID that was never accepted by the service (HTTP 400), so this signature change is a bug-fix, not a breaking API change. Added note in docstrings to clarify. --- src/sap_cloud_sdk/adms/_configuration_api.py | 48 +++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/src/sap_cloud_sdk/adms/_configuration_api.py b/src/sap_cloud_sdk/adms/_configuration_api.py index ef2bf0d5..842672c5 100644 --- a/src/sap_cloud_sdk/adms/_configuration_api.py +++ b/src/sap_cloud_sdk/adms/_configuration_api.py @@ -244,9 +244,23 @@ def create_type_mapping( @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_DOCTYPE_BOTYPE_MAP) def get_type_mapping( - self, document_type_id: str, business_object_node_type_unique_id: str + self, + document_type_id: str, + business_object_node_type_unique_id: str, ) -> DocumentTypeBusinessObjectTypeMap: - """Fetch a single DocumentType ↔ BusinessObjectNodeType mapping by its composite key.""" + """Fetch a single DocumentType ↔ BusinessObjectNodeType mapping by its composite key. + + Args: + document_type_id: The ``DocumentTypeID`` half of the composite key. + business_object_node_type_unique_id: The ``BusinessObjectNodeTypeUniqueID`` half. + + Note: + The ADM ``DocumentTypeBusinessObjectTypeMap`` entity uses a **composite key** + (``DocumentTypeID`` + ``BusinessObjectNodeTypeUniqueID``). The previous + single-argument overload used a fabricated ``DocumentTypeBOTypeMapID`` that + was never accepted by the service (HTTP 400), so this change is a bug-fix + rather than a breaking API change. + """ resp = self._http.get( build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id), service_base=_CONFIG_SERVICE_PATH, @@ -254,16 +268,38 @@ def get_type_mapping( return DocumentTypeBusinessObjectTypeMap.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_DOCTYPE_BOTYPE_MAP) - def delete_type_mapping(self, document_type_id: str, business_object_node_type_unique_id: str) -> None: - """Delete a DocumentType ↔ BusinessObjectNodeType mapping.""" + def delete_type_mapping( + self, + document_type_id: str, + business_object_node_type_unique_id: str, + ) -> None: + """Delete a DocumentType ↔ BusinessObjectNodeType mapping by its composite key. + + Args: + document_type_id: The ``DocumentTypeID`` half of the composite key. + business_object_node_type_unique_id: The ``BusinessObjectNodeTypeUniqueID`` half. + + Note: + Same composite-key fix as :meth:`get_type_mapping` — the previous single-argument + overload used a non-existent ``DocumentTypeBOTypeMapID`` field. + """ self._http.delete( build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id), service_base=_CONFIG_SERVICE_PATH, ) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_MARK_DEFAULT) - def mark_default(self, document_type_id: str, business_object_node_type_unique_id: str) -> None: - """Mark a DocumentType ↔ BusinessObjectNodeType mapping as the default.""" + def mark_default( + self, + document_type_id: str, + business_object_node_type_unique_id: str, + ) -> None: + """Mark a DocumentType ↔ BusinessObjectNodeType mapping as the default. + + Args: + document_type_id: The ``DocumentTypeID`` half of the composite key. + business_object_node_type_unique_id: The ``BusinessObjectNodeTypeUniqueID`` half. + """ self._http.post( f"{build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id)}/com.sap.adm.ConfigurationService.markDefault", json={}, From 2c5c976826cd351768e431000f6ab3fad6ed3dec Mon Sep 17 00:00:00 2001 From: I766515 Date: Tue, 1 Sep 2026 14:07:28 +0530 Subject: [PATCH 06/13] chore: trigger CI From 16f1fa080fea3063c56e20361bdb49842e370c4f Mon Sep 17 00:00:00 2001 From: I766515 Date: Wed, 2 Sep 2026 13:57:31 +0530 Subject: [PATCH 07/13] chore: bump version to 0.48.3 --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 2e750880..d16a202e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ [project] name = "sap-cloud-sdk" + version = "0.53.1" description = "SAP Cloud SDK for Python" readme = "README.md" From c9c18fbd54f8665a39d90e0d61b0d4b01a96ef0a Mon Sep 17 00:00:00 2001 From: I766515 Date: Wed, 2 Sep 2026 13:57:46 +0530 Subject: [PATCH 08/13] chore: restore workflow file to avoid token scope issue --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfc156cd..593f82bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,4 +65,4 @@ jobs: run: uv build - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.14.2 + uses: pypa/gh-action-pypi-publish@v1.14.0 From 1e9a2c8b31e1fdacef21bd4f998e31a4c5ea63f4 Mon Sep 17 00:00:00 2001 From: I766515 Date: Wed, 16 Sep 2026 14:43:11 +0530 Subject: [PATCH 09/13] style(adms): wrap long lines in _configuration_api.py to fix pre-commit --- src/sap_cloud_sdk/adms/_configuration_api.py | 135 ++++++++++++------- 1 file changed, 89 insertions(+), 46 deletions(-) diff --git a/src/sap_cloud_sdk/adms/_configuration_api.py b/src/sap_cloud_sdk/adms/_configuration_api.py index 842672c5..b0ee58f9 100644 --- a/src/sap_cloud_sdk/adms/_configuration_api.py +++ b/src/sap_cloud_sdk/adms/_configuration_api.py @@ -153,7 +153,9 @@ def get_all_business_object_types( """Return all registered business object node types.""" params = options.to_query_params() if options else {} resp = self._http.get( - "BusinessObjectNodeType", params=params, service_base=_CONFIG_SERVICE_PATH + "BusinessObjectNodeType", + params=params, + service_base=_CONFIG_SERVICE_PATH, ) return [ BusinessObjectNodeType.from_dict(item) @@ -262,7 +264,9 @@ def get_type_mapping( rather than a breaking API change. """ resp = self._http.get( - build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id), + build_doctype_botype_map_key_path( + document_type_id, business_object_node_type_unique_id + ), service_base=_CONFIG_SERVICE_PATH, ) return DocumentTypeBusinessObjectTypeMap.from_dict(resp.json()) @@ -280,11 +284,14 @@ def delete_type_mapping( business_object_node_type_unique_id: The ``BusinessObjectNodeTypeUniqueID`` half. Note: - Same composite-key fix as :meth:`get_type_mapping` — the previous single-argument - overload used a non-existent ``DocumentTypeBOTypeMapID`` field. + Same composite-key fix as :meth:`get_type_mapping` — the previous + single-argument overload used a non-existent ``DocumentTypeBOTypeMapID`` + field. """ self._http.delete( - build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id), + build_doctype_botype_map_key_path( + document_type_id, business_object_node_type_unique_id + ), service_base=_CONFIG_SERVICE_PATH, ) @@ -300,8 +307,11 @@ def mark_default( document_type_id: The ``DocumentTypeID`` half of the composite key. business_object_node_type_unique_id: The ``BusinessObjectNodeTypeUniqueID`` half. """ + key_path = build_doctype_botype_map_key_path( + document_type_id, business_object_node_type_unique_id + ) self._http.post( - f"{build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id)}/com.sap.adm.ConfigurationService.markDefault", + f"{key_path}/com.sap.adm.ConfigurationService.markDefault", json={}, service_base=_CONFIG_SERVICE_PATH, ) @@ -315,7 +325,9 @@ def get_all_file_extension_policies( """Return all document-type file extension policies.""" params = options.to_query_params() if options else {} resp = self._http.get( - "DocumentTypeFileExtensionPolicy", params=params, service_base=_CONFIG_SERVICE_PATH + "DocumentTypeFileExtensionPolicy", + params=params, + service_base=_CONFIG_SERVICE_PATH, ) return [ FileExtensionPolicy.from_dict(item) for item in resp.json().get("value", []) @@ -334,10 +346,15 @@ def create_file_extension_policy( return FileExtensionPolicy.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_FILE_EXT_POLICY) - def delete_file_extension_policy(self, document_type_id: str, file_extension: str) -> None: + def delete_file_extension_policy( + self, document_type_id: str, file_extension: str + ) -> None: """Delete a document-type file extension policy by composite key.""" + doc_id_key = quote_odata_string_key(document_type_id) + ext_key = quote_odata_string_key(file_extension) self._http.delete( - f"DocumentTypeFileExtensionPolicy(DocumentTypeID={quote_odata_string_key(document_type_id)},FileExtension={quote_odata_string_key(file_extension)})", + f"DocumentTypeFileExtensionPolicy(" + f"DocumentTypeID={doc_id_key},FileExtension={ext_key})", service_base=_CONFIG_SERVICE_PATH, ) @@ -446,7 +463,7 @@ async def get_all_allowed_domains( self, options: ConfigQueryOptions | None = None, ) -> list[AllowedDomain]: - """Async variant of :meth:`_ConfigurationApi.get_all_allowed_domains` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.get_all_allowed_domains`.""" params = options.to_query_params() if options else {} resp = await self._http.get( "AllowedDomain", params=params, service_base=_CONFIG_SERVICE_PATH @@ -457,7 +474,7 @@ async def get_all_allowed_domains( async def create_allowed_domain( self, payload: CreateAllowedDomainInput ) -> AllowedDomain: - """Async variant of :meth:`_ConfigurationApi.create_allowed_domain` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.create_allowed_domain`.""" resp = await self._http.post( "AllowedDomain", json=payload.to_odata_dict(), @@ -467,7 +484,7 @@ async def create_allowed_domain( @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_ALLOWED_DOMAIN) async def get_allowed_domain(self, allowed_domain_id: str) -> AllowedDomain: - """Async variant of :meth:`_ConfigurationApi.get_allowed_domain` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.get_allowed_domain`.""" resp = await self._http.get( build_allowed_domain_key_path(allowed_domain_id), service_base=_CONFIG_SERVICE_PATH, @@ -478,7 +495,7 @@ async def get_allowed_domain(self, allowed_domain_id: str) -> AllowedDomain: async def update_allowed_domain( self, allowed_domain_id: str, payload: UpdateAllowedDomainInput ) -> AllowedDomain: - """Async variant of :meth:`_ConfigurationApi.update_allowed_domain` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.update_allowed_domain`.""" resp = await self._http.patch( build_allowed_domain_key_path(allowed_domain_id), json=payload.to_odata_dict(), @@ -488,7 +505,7 @@ async def update_allowed_domain( @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_ALLOWED_DOMAIN) async def delete_allowed_domain(self, allowed_domain_id: str) -> None: - """Async variant of :meth:`_ConfigurationApi.delete_allowed_domain` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.delete_allowed_domain`.""" await self._http.delete( build_allowed_domain_key_path(allowed_domain_id), service_base=_CONFIG_SERVICE_PATH, @@ -499,7 +516,7 @@ async def get_all_document_types( self, options: ConfigQueryOptions | None = None, ) -> list[DocumentType]: - """Async variant of :meth:`_ConfigurationApi.get_all_document_types` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.get_all_document_types`.""" params = options.to_query_params() if options else {} resp = await self._http.get( "DocumentType", params=params, service_base=_CONFIG_SERVICE_PATH @@ -510,7 +527,7 @@ async def get_all_document_types( async def create_document_type( self, payload: CreateDocumentTypeInput ) -> DocumentType: - """Async variant of :meth:`_ConfigurationApi.create_document_type` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.create_document_type`.""" resp = await self._http.post( "DocumentType", json=payload.to_odata_dict(), @@ -520,7 +537,7 @@ async def create_document_type( @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_DOCUMENT_TYPE) async def get_document_type(self, document_type_id: str) -> DocumentType: - """Async variant of :meth:`_ConfigurationApi.get_document_type` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.get_document_type`.""" resp = await self._http.get( build_document_type_key_path(document_type_id), service_base=_CONFIG_SERVICE_PATH, @@ -531,7 +548,7 @@ async def get_document_type(self, document_type_id: str) -> DocumentType: async def update_document_type( self, document_type_id: str, payload: UpdateDocumentTypeInput ) -> DocumentType: - """Async variant of :meth:`_ConfigurationApi.update_document_type` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.update_document_type`.""" resp = await self._http.patch( build_document_type_key_path(document_type_id), json=payload.to_odata_dict(), @@ -541,7 +558,7 @@ async def update_document_type( @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_DOCUMENT_TYPE) async def delete_document_type(self, document_type_id: str) -> None: - """Async variant of :meth:`_ConfigurationApi.delete_document_type` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.delete_document_type`.""" await self._http.delete( build_document_type_key_path(document_type_id), service_base=_CONFIG_SERVICE_PATH, @@ -552,10 +569,12 @@ async def get_all_business_object_types( self, options: ConfigQueryOptions | None = None, ) -> list[BusinessObjectNodeType]: - """Async variant of :meth:`_ConfigurationApi.get_all_business_object_types` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.get_all_business_object_types`.""" params = options.to_query_params() if options else {} resp = await self._http.get( - "BusinessObjectNodeType", params=params, service_base=_CONFIG_SERVICE_PATH + "BusinessObjectNodeType", + params=params, + service_base=_CONFIG_SERVICE_PATH, ) return [ BusinessObjectNodeType.from_dict(item) @@ -566,7 +585,7 @@ async def get_all_business_object_types( async def create_business_object_type( self, payload: CreateBusinessObjectNodeTypeInput ) -> BusinessObjectNodeType: - """Async variant of :meth:`_ConfigurationApi.create_business_object_type` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.create_business_object_type`.""" resp = await self._http.post( "BusinessObjectNodeType", json=payload.to_odata_dict(), @@ -578,7 +597,7 @@ async def create_business_object_type( async def get_business_object_type( self, business_object_node_type_unique_id: str ) -> BusinessObjectNodeType: - """Async variant of :meth:`_ConfigurationApi.get_business_object_type` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.get_business_object_type`.""" resp = await self._http.get( build_business_object_node_type_key_path( business_object_node_type_unique_id @@ -593,7 +612,7 @@ async def update_business_object_type( business_object_node_type_unique_id: str, payload: UpdateBusinessObjectNodeTypeInput, ) -> BusinessObjectNodeType: - """Async variant of :meth:`_ConfigurationApi.update_business_object_type` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.update_business_object_type`.""" resp = await self._http.patch( build_business_object_node_type_key_path( business_object_node_type_unique_id @@ -607,7 +626,7 @@ async def update_business_object_type( async def delete_business_object_type( self, business_object_node_type_unique_id: str ) -> None: - """Async variant of :meth:`_ConfigurationApi.delete_business_object_type` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.delete_business_object_type`.""" await self._http.delete( build_business_object_node_type_key_path( business_object_node_type_unique_id @@ -620,7 +639,7 @@ async def get_type_mappings( self, options: ConfigQueryOptions | None = None, ) -> list[DocumentTypeBusinessObjectTypeMap]: - """Async variant of :meth:`_ConfigurationApi.get_type_mappings` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.get_type_mappings`.""" params = options.to_query_params() if options else {} resp = await self._http.get( "DocumentTypeBusinessObjectTypeMap", @@ -636,7 +655,7 @@ async def get_type_mappings( async def create_type_mapping( self, payload: CreateDocumentTypeBoTypeMapInput ) -> DocumentTypeBusinessObjectTypeMap: - """Async variant of :meth:`_ConfigurationApi.create_type_mapping` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.create_type_mapping`.""" resp = await self._http.post( "DocumentTypeBusinessObjectTypeMap", json=payload.to_odata_dict(), @@ -646,28 +665,45 @@ async def create_type_mapping( @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_GET_DOCTYPE_BOTYPE_MAP) async def get_type_mapping( - self, document_type_id: str, business_object_node_type_unique_id: str + self, + document_type_id: str, + business_object_node_type_unique_id: str, ) -> DocumentTypeBusinessObjectTypeMap: - """Async variant of :meth:`_ConfigurationApi.get_type_mapping` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.get_type_mapping`.""" resp = await self._http.get( - build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id), + build_doctype_botype_map_key_path( + document_type_id, business_object_node_type_unique_id + ), service_base=_CONFIG_SERVICE_PATH, ) return DocumentTypeBusinessObjectTypeMap.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_DOCTYPE_BOTYPE_MAP) - async def delete_type_mapping(self, document_type_id: str, business_object_node_type_unique_id: str) -> None: - """Async variant of :meth:`_ConfigurationApi.delete_type_mapping` — same semantics.""" + async def delete_type_mapping( + self, + document_type_id: str, + business_object_node_type_unique_id: str, + ) -> None: + """Async variant of :meth:`_ConfigurationApi.delete_type_mapping`.""" await self._http.delete( - build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id), + build_doctype_botype_map_key_path( + document_type_id, business_object_node_type_unique_id + ), service_base=_CONFIG_SERVICE_PATH, ) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_MARK_DEFAULT) - async def mark_default(self, document_type_id: str, business_object_node_type_unique_id: str) -> None: - """Async variant of :meth:`_ConfigurationApi.mark_default` — same semantics.""" + async def mark_default( + self, + document_type_id: str, + business_object_node_type_unique_id: str, + ) -> None: + """Async variant of :meth:`_ConfigurationApi.mark_default`.""" + key_path = build_doctype_botype_map_key_path( + document_type_id, business_object_node_type_unique_id + ) await self._http.post( - f"{build_doctype_botype_map_key_path(document_type_id, business_object_node_type_unique_id)}/com.sap.adm.ConfigurationService.markDefault", + f"{key_path}/com.sap.adm.ConfigurationService.markDefault", json={}, service_base=_CONFIG_SERVICE_PATH, ) @@ -676,10 +712,12 @@ async def mark_default(self, document_type_id: str, business_object_node_type_un async def get_all_file_extension_policies( self, options: ConfigQueryOptions | None = None ) -> list[FileExtensionPolicy]: - """Async variant of :meth:`_ConfigurationApi.get_all_file_extension_policies` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.get_all_file_extension_policies`.""" params = options.to_query_params() if options else {} resp = await self._http.get( - "DocumentTypeFileExtensionPolicy", params=params, service_base=_CONFIG_SERVICE_PATH + "DocumentTypeFileExtensionPolicy", + params=params, + service_base=_CONFIG_SERVICE_PATH, ) return [ FileExtensionPolicy.from_dict(item) for item in resp.json().get("value", []) @@ -689,7 +727,7 @@ async def get_all_file_extension_policies( async def create_file_extension_policy( self, payload: CreateFileExtensionPolicyInput ) -> FileExtensionPolicy: - """Async variant of :meth:`_ConfigurationApi.create_file_extension_policy` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.create_file_extension_policy`.""" resp = await self._http.post( "DocumentTypeFileExtensionPolicy", json=payload.to_odata_dict(), @@ -698,10 +736,15 @@ async def create_file_extension_policy( return FileExtensionPolicy.from_dict(resp.json()) @record_metrics(Module.ADMS, Operation.ADMS_CONFIG_DELETE_FILE_EXT_POLICY) - async def delete_file_extension_policy(self, document_type_id: str, file_extension: str) -> None: - """Async variant of :meth:`_ConfigurationApi.delete_file_extension_policy` — same semantics.""" + async def delete_file_extension_policy( + self, document_type_id: str, file_extension: str + ) -> None: + """Async variant of :meth:`_ConfigurationApi.delete_file_extension_policy`.""" + doc_id_key = quote_odata_string_key(document_type_id) + ext_key = quote_odata_string_key(file_extension) await self._http.delete( - f"DocumentTypeFileExtensionPolicy(DocumentTypeID={quote_odata_string_key(document_type_id)},FileExtension={quote_odata_string_key(file_extension)})", + f"DocumentTypeFileExtensionPolicy(" + f"DocumentTypeID={doc_id_key},FileExtension={ext_key})", service_base=_CONFIG_SERVICE_PATH, ) @@ -712,7 +755,7 @@ async def get_all_application_tenants( *, subaccount_id: str | None = None, ) -> list[ApplicationTenant]: - """Async variant of :meth:`_ConfigurationApi.get_all_application_tenants` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.get_all_application_tenants`.""" params = options.to_query_params() if options else {} resp = await self._http.get( "ApplicationTenant", @@ -731,7 +774,7 @@ async def create_application_tenant( *, subaccount_id: str | None = None, ) -> ApplicationTenant: - """Async variant of :meth:`_ConfigurationApi.create_application_tenant` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.create_application_tenant`.""" resp = await self._http.post( "ApplicationTenant", json=payload.to_odata_dict(), @@ -747,7 +790,7 @@ async def get_application_tenant( *, subaccount_id: str | None = None, ) -> ApplicationTenant: - """Async variant of :meth:`_ConfigurationApi.get_application_tenant` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.get_application_tenant`.""" resp = await self._http.get( f"ApplicationTenant(ApplicationTenantID='{application_tenant_id}')", service_base=_CONFIG_SERVICE_PATH, @@ -762,7 +805,7 @@ async def delete_application_tenant( *, subaccount_id: str | None = None, ) -> None: - """Async variant of :meth:`_ConfigurationApi.delete_application_tenant` — same semantics.""" + """Async variant of :meth:`_ConfigurationApi.delete_application_tenant`.""" await self._http.delete( f"ApplicationTenant(ApplicationTenantID='{application_tenant_id}')", service_base=_CONFIG_SERVICE_PATH, From 80ee8a6a8eadd470eeff318f535ed49954f0ebab Mon Sep 17 00:00:00 2001 From: I766515 Date: Wed, 16 Sep 2026 14:43:46 +0530 Subject: [PATCH 10/13] refactor(adms): remove unused _quote_guid helper --- src/sap_cloud_sdk/adms/_configuration_api.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/sap_cloud_sdk/adms/_configuration_api.py b/src/sap_cloud_sdk/adms/_configuration_api.py index b0ee58f9..a0b238a0 100644 --- a/src/sap_cloud_sdk/adms/_configuration_api.py +++ b/src/sap_cloud_sdk/adms/_configuration_api.py @@ -442,13 +442,6 @@ def _subaccount_header(subaccount_id: str | None) -> dict[str, str] | None: return {"X-SubaccountId": subaccount_id} if subaccount_id else None -def _quote_guid(value: str) -> str: - """Wrap a UUID value in the OData Edm.Guid format for key segments.""" - from sap_cloud_sdk.adms._http import quote_odata_guid_key - - return quote_odata_guid_key(value) - - class _AsyncConfigurationApi: """Async version of :class:`_ConfigurationApi`. From 6e71550e718043a4f36128d681ccdcf24534fe54 Mon Sep 17 00:00:00 2001 From: I766515 Date: Wed, 16 Sep 2026 14:44:45 +0530 Subject: [PATCH 11/13] fix(adms): pass subaccount_id header in async delete_application_tenant The async path was silently dropping the subaccount_id parameter. Also clarifies the user-guide multi-tenancy section to reflect that ApplicationTenant operations do support subaccount-scoped routing. --- src/sap_cloud_sdk/adms/_configuration_api.py | 1 + src/sap_cloud_sdk/adms/user-guide.md | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/sap_cloud_sdk/adms/_configuration_api.py b/src/sap_cloud_sdk/adms/_configuration_api.py index a0b238a0..864f8dbc 100644 --- a/src/sap_cloud_sdk/adms/_configuration_api.py +++ b/src/sap_cloud_sdk/adms/_configuration_api.py @@ -802,4 +802,5 @@ async def delete_application_tenant( await self._http.delete( f"ApplicationTenant(ApplicationTenantID='{application_tenant_id}')", service_base=_CONFIG_SERVICE_PATH, + extra_headers=_subaccount_header(subaccount_id), ) diff --git a/src/sap_cloud_sdk/adms/user-guide.md b/src/sap_cloud_sdk/adms/user-guide.md index ac4dd076..c74d1e80 100644 --- a/src/sap_cloud_sdk/adms/user-guide.md +++ b/src/sap_cloud_sdk/adms/user-guide.md @@ -257,9 +257,14 @@ active = client.relations.activate_draft(activate_input) ## Multi-tenancy -- **Supported:** No +- **Supported:** Partial (ApplicationTenant operations only) - **Authentication:** IAS -- **How to use:** Multi-tenancy is not supported by this service. +- **How to use:** ADM is a single-tenant service for most operations. The + `ApplicationTenant` entity is an exception: create, list, get, and delete + operations accept an optional `subaccount_id` parameter, which is forwarded + as the `X-SubaccountId` header required by ADM for subaccount-scoped tenant + management. All other ADM APIs (documents, jobs, configuration) do not + support multi-tenancy. - **Further reading:** N/A ## Error Handling From ecd7cc1c7ec62f88926a200c701fc292b100b37c Mon Sep 17 00:00:00 2001 From: I766515 Date: Wed, 16 Sep 2026 14:49:38 +0530 Subject: [PATCH 12/13] test(adms): add unit tests for FileExtensionPolicy, ApplicationTenant, and mark_default Also fixes stale tests: composite-key delete_type_mapping/get_type_mapping signatures, removed is_default from CreateDocumentTypeBoTypeMapInput, updated _MAPPING_DICT to use DocumentTypeIsDefault field name. --- tests/adms/unit/test_client.py | 344 +++++++++++++++++++++++++++++++-- 1 file changed, 332 insertions(+), 12 deletions(-) diff --git a/tests/adms/unit/test_client.py b/tests/adms/unit/test_client.py index 8ded5cb6..e2535d88 100644 --- a/tests/adms/unit/test_client.py +++ b/tests/adms/unit/test_client.py @@ -18,14 +18,17 @@ ) from sap_cloud_sdk.adms._models import ( AllowedDomain, + ApplicationTenant, BaseType, BusinessObjectNodeType, CreateAllowedDomainInput, + CreateApplicationTenantInput, CreateBusinessObjectNodeTypeInput, CreateDocumentInput, CreateDocumentRelationInput, CreateDocumentTypeBoTypeMapInput, CreateDocumentTypeInput, + CreateFileExtensionPolicyInput, DeleteUserDataJobParameters, Document, DocumentRelation, @@ -33,6 +36,7 @@ DocumentTypeBusinessObjectTypeMap, DraftActivateInput, DraftInput, + FileExtensionPolicy, JobOutput, JobStatus, ScanStatus, @@ -1127,10 +1131,9 @@ def test_discard_draft(self): } _MAPPING_DICT = { - "DocumentTypeBOTypeMapID": "44444444-4444-4444-4444-444444444444", "BusinessObjectNodeTypeUniqueID": "bo-uuid-1", "DocumentTypeID": "INVOICE", - "IsDefault": False, + "DocumentTypeIsDefault": False, } @@ -1341,10 +1344,6 @@ def test_get_type_mappings_returns_list(self): assert len(result) == 1 assert isinstance(result[0], DocumentTypeBusinessObjectTypeMap) - assert ( - result[0].document_type_bo_type_map_id - == "44444444-4444-4444-4444-444444444444" - ) assert result[0].business_object_node_type_unique_id == "bo-uuid-1" assert result[0].document_type_id == "INVOICE" assert result[0].is_default is False @@ -1355,7 +1354,6 @@ def test_create_mapping_posts_correct_payload(self): payload = CreateDocumentTypeBoTypeMapInput( business_object_node_type_unique_id="bo-uuid-1", document_type_id="INVOICE", - is_default=False, ) result = api.create_type_mapping(payload) @@ -1365,18 +1363,18 @@ def test_create_mapping_posts_correct_payload(self): assert kwargs["json"] == { "BusinessObjectNodeTypeUniqueID": "bo-uuid-1", "DocumentTypeID": "INVOICE", - "IsDefault": False, } assert isinstance(result, DocumentTypeBusinessObjectTypeMap) - def test_delete_mapping_uses_map_id(self): + def test_delete_mapping_uses_composite_key(self): http = _cfg_sync_http() api = _ConfigurationApi(http) - api.delete_type_mapping("44444444-4444-4444-4444-444444444444") + api.delete_type_mapping("INVOICE", "bo-uuid-1") http.delete.assert_called_once() call_path = http.delete.call_args[0][0] - assert "44444444-4444-4444-4444-444444444444" in call_path + assert "INVOICE" in call_path + assert "bo-uuid-1" in call_path class TestAsyncConfigurationApiAllowedDomain: @@ -1502,8 +1500,330 @@ async def test_create_mapping_posts(self): async def test_delete_called(self): http = _cfg_async_http() api = _AsyncConfigurationApi(http) - await api.delete_type_mapping("44444444-4444-4444-4444-444444444444") + await api.delete_type_mapping("INVOICE", "bo-uuid-1") + http.delete.assert_called_once() + call_path = http.delete.call_args[0][0] + assert "INVOICE" in call_path + assert "bo-uuid-1" in call_path + + +_FILE_EXT_POLICY_DICT = { + "DocumentTypeID": "INVOICE", + "FileExtension": "pdf", +} + +_APP_TENANT_DICT = { + "ApplicationTenantID": "tenant-uuid-1", + "ApplicationTenantName": "My Tenant", +} + + +class TestConfigurationApiFileExtensionPolicy: + def test_get_all_returns_list(self): + http = _cfg_sync_http(get_data={"value": [_FILE_EXT_POLICY_DICT]}) + api = _ConfigurationApi(http) + result = api.get_all_file_extension_policies() + + assert len(result) == 1 + assert isinstance(result[0], FileExtensionPolicy) + assert result[0].document_type_id == "INVOICE" + assert result[0].file_extension == "pdf" + + def test_get_all_uses_correct_entity_set(self): + http = _cfg_sync_http(get_data={"value": []}) + api = _ConfigurationApi(http) + api.get_all_file_extension_policies() + + args, _ = http.get.call_args + assert args[0] == "DocumentTypeFileExtensionPolicy" + + def test_create_posts_correct_payload(self): + http = _cfg_sync_http(post_data=_FILE_EXT_POLICY_DICT) + api = _ConfigurationApi(http) + payload = CreateFileExtensionPolicyInput( + document_type_id="INVOICE", file_extension="pdf" + ) + result = api.create_file_extension_policy(payload) + + http.post.assert_called_once() + args, kwargs = http.post.call_args + assert args[0] == "DocumentTypeFileExtensionPolicy" + assert kwargs["json"] == {"DocumentTypeID": "INVOICE", "FileExtension": "pdf"} + assert isinstance(result, FileExtensionPolicy) + + def test_delete_uses_composite_key_path(self): + http = _cfg_sync_http() + api = _ConfigurationApi(http) + api.delete_file_extension_policy("INVOICE", "pdf") + + http.delete.assert_called_once() + call_path = http.delete.call_args[0][0] + assert "DocumentTypeFileExtensionPolicy" in call_path + assert "INVOICE" in call_path + assert "pdf" in call_path + + +class TestConfigurationApiMarkDefault: + def test_posts_to_mark_default_action(self): + http = _cfg_sync_http() + api = _ConfigurationApi(http) + api.mark_default("INVOICE", "bo-uuid-1") + + http.post.assert_called_once() + call_path = http.post.call_args[0][0] + assert "com.sap.adm.ConfigurationService.markDefault" in call_path + assert "INVOICE" in call_path + assert "bo-uuid-1" in call_path + + def test_posts_empty_body(self): + http = _cfg_sync_http() + api = _ConfigurationApi(http) + api.mark_default("INVOICE", "bo-uuid-1") + + assert http.post.call_args[1]["json"] == {} + + +class TestConfigurationApiApplicationTenant: + def test_get_all_returns_list(self): + http = _cfg_sync_http(get_data={"value": [_APP_TENANT_DICT]}) + api = _ConfigurationApi(http) + result = api.get_all_application_tenants() + + assert len(result) == 1 + assert isinstance(result[0], ApplicationTenant) + assert result[0].application_tenant_id == "tenant-uuid-1" + assert result[0].application_tenant_name == "My Tenant" + + def test_get_all_forwards_subaccount_header(self): + http = _cfg_sync_http(get_data={"value": []}) + api = _ConfigurationApi(http) + api.get_all_application_tenants(subaccount_id="sub-123") + + _, kwargs = http.get.call_args + assert kwargs["extra_headers"] == {"X-SubaccountId": "sub-123"} + + def test_get_all_no_subaccount_passes_none_header(self): + http = _cfg_sync_http(get_data={"value": []}) + api = _ConfigurationApi(http) + api.get_all_application_tenants() + + _, kwargs = http.get.call_args + assert kwargs["extra_headers"] is None + + def test_create_posts_correct_payload(self): + http = _cfg_sync_http(post_data=_APP_TENANT_DICT) + api = _ConfigurationApi(http) + payload = CreateApplicationTenantInput( + application_tenant_id="tenant-uuid-1", + application_tenant_name="My Tenant", + ) + result = api.create_application_tenant(payload) + + http.post.assert_called_once() + args, kwargs = http.post.call_args + assert args[0] == "ApplicationTenant" + assert kwargs["json"] == { + "ApplicationTenantID": "tenant-uuid-1", + "ApplicationTenantName": "My Tenant", + } + assert isinstance(result, ApplicationTenant) + + def test_create_forwards_subaccount_header(self): + http = _cfg_sync_http(post_data=_APP_TENANT_DICT) + api = _ConfigurationApi(http) + payload = CreateApplicationTenantInput( + application_tenant_id="tenant-uuid-1", + application_tenant_name="My Tenant", + ) + api.create_application_tenant(payload, subaccount_id="sub-456") + + _, kwargs = http.post.call_args + assert kwargs["extra_headers"] == {"X-SubaccountId": "sub-456"} + + def test_get_single_uses_correct_path(self): + http = _cfg_sync_http(get_data=_APP_TENANT_DICT) + api = _ConfigurationApi(http) + result = api.get_application_tenant("tenant-uuid-1") + + call_path = http.get.call_args[0][0] + assert "ApplicationTenant" in call_path + assert "tenant-uuid-1" in call_path + assert isinstance(result, ApplicationTenant) + + def test_get_single_forwards_subaccount_header(self): + http = _cfg_sync_http(get_data=_APP_TENANT_DICT) + api = _ConfigurationApi(http) + api.get_application_tenant("tenant-uuid-1", subaccount_id="sub-789") + + _, kwargs = http.get.call_args + assert kwargs["extra_headers"] == {"X-SubaccountId": "sub-789"} + + def test_delete_uses_correct_path(self): + http = _cfg_sync_http() + api = _ConfigurationApi(http) + api.delete_application_tenant("tenant-uuid-1") + + http.delete.assert_called_once() + call_path = http.delete.call_args[0][0] + assert "ApplicationTenant" in call_path + assert "tenant-uuid-1" in call_path + + def test_delete_forwards_subaccount_header(self): + http = _cfg_sync_http() + api = _ConfigurationApi(http) + api.delete_application_tenant("tenant-uuid-1", subaccount_id="sub-abc") + + _, kwargs = http.delete.call_args + assert kwargs["extra_headers"] == {"X-SubaccountId": "sub-abc"} + + +class TestAsyncConfigurationApiFileExtensionPolicy: + @pytest.mark.asyncio + async def test_get_all_returns_list(self): + http = _cfg_async_http(get_data={"value": [_FILE_EXT_POLICY_DICT]}) + api = _AsyncConfigurationApi(http) + result = await api.get_all_file_extension_policies() + + assert len(result) == 1 + assert isinstance(result[0], FileExtensionPolicy) + assert result[0].document_type_id == "INVOICE" + assert result[0].file_extension == "pdf" + + @pytest.mark.asyncio + async def test_create_posts_correct_payload(self): + http = _cfg_async_http(post_data=_FILE_EXT_POLICY_DICT) + api = _AsyncConfigurationApi(http) + payload = CreateFileExtensionPolicyInput( + document_type_id="INVOICE", file_extension="pdf" + ) + result = await api.create_file_extension_policy(payload) + + http.post.assert_called_once() + args, kwargs = http.post.call_args + assert args[0] == "DocumentTypeFileExtensionPolicy" + assert kwargs["json"] == {"DocumentTypeID": "INVOICE", "FileExtension": "pdf"} + assert isinstance(result, FileExtensionPolicy) + + @pytest.mark.asyncio + async def test_delete_uses_composite_key_path(self): + http = _cfg_async_http() + api = _AsyncConfigurationApi(http) + await api.delete_file_extension_policy("INVOICE", "pdf") + http.delete.assert_called_once() + call_path = http.delete.call_args[0][0] + assert "DocumentTypeFileExtensionPolicy" in call_path + assert "INVOICE" in call_path + assert "pdf" in call_path + + +class TestAsyncConfigurationApiMarkDefault: + @pytest.mark.asyncio + async def test_posts_to_mark_default_action(self): + http = _cfg_async_http() + api = _AsyncConfigurationApi(http) + await api.mark_default("INVOICE", "bo-uuid-1") + + http.post.assert_called_once() + call_path = http.post.call_args[0][0] + assert "com.sap.adm.ConfigurationService.markDefault" in call_path + assert "INVOICE" in call_path + assert "bo-uuid-1" in call_path + + @pytest.mark.asyncio + async def test_posts_empty_body(self): + http = _cfg_async_http() + api = _AsyncConfigurationApi(http) + await api.mark_default("INVOICE", "bo-uuid-1") + + assert http.post.call_args[1]["json"] == {} + + +class TestAsyncConfigurationApiApplicationTenant: + @pytest.mark.asyncio + async def test_get_all_returns_list(self): + http = _cfg_async_http(get_data={"value": [_APP_TENANT_DICT]}) + api = _AsyncConfigurationApi(http) + result = await api.get_all_application_tenants() + + assert len(result) == 1 + assert isinstance(result[0], ApplicationTenant) + assert result[0].application_tenant_id == "tenant-uuid-1" + + @pytest.mark.asyncio + async def test_get_all_forwards_subaccount_header(self): + http = _cfg_async_http(get_data={"value": []}) + api = _AsyncConfigurationApi(http) + await api.get_all_application_tenants(subaccount_id="sub-123") + + _, kwargs = http.get.call_args + assert kwargs["extra_headers"] == {"X-SubaccountId": "sub-123"} + + @pytest.mark.asyncio + async def test_create_posts_correct_payload(self): + http = _cfg_async_http(post_data=_APP_TENANT_DICT) + api = _AsyncConfigurationApi(http) + payload = CreateApplicationTenantInput( + application_tenant_id="tenant-uuid-1", + application_tenant_name="My Tenant", + ) + result = await api.create_application_tenant(payload) + + http.post.assert_called_once() + assert isinstance(result, ApplicationTenant) + + @pytest.mark.asyncio + async def test_create_forwards_subaccount_header(self): + http = _cfg_async_http(post_data=_APP_TENANT_DICT) + api = _AsyncConfigurationApi(http) + payload = CreateApplicationTenantInput( + application_tenant_id="tenant-uuid-1", + application_tenant_name="My Tenant", + ) + await api.create_application_tenant(payload, subaccount_id="sub-456") + + _, kwargs = http.post.call_args + assert kwargs["extra_headers"] == {"X-SubaccountId": "sub-456"} + + @pytest.mark.asyncio + async def test_get_single_uses_correct_path(self): + http = _cfg_async_http(get_data=_APP_TENANT_DICT) + api = _AsyncConfigurationApi(http) + result = await api.get_application_tenant("tenant-uuid-1") + + call_path = http.get.call_args[0][0] + assert "ApplicationTenant" in call_path + assert "tenant-uuid-1" in call_path + assert isinstance(result, ApplicationTenant) + + @pytest.mark.asyncio + async def test_get_single_forwards_subaccount_header(self): + http = _cfg_async_http(get_data=_APP_TENANT_DICT) + api = _AsyncConfigurationApi(http) + await api.get_application_tenant("tenant-uuid-1", subaccount_id="sub-789") + + _, kwargs = http.get.call_args + assert kwargs["extra_headers"] == {"X-SubaccountId": "sub-789"} + + @pytest.mark.asyncio + async def test_delete_uses_correct_path(self): + http = _cfg_async_http() + api = _AsyncConfigurationApi(http) + await api.delete_application_tenant("tenant-uuid-1") + + http.delete.assert_called_once() + call_path = http.delete.call_args[0][0] + assert "ApplicationTenant" in call_path + assert "tenant-uuid-1" in call_path + + @pytest.mark.asyncio + async def test_delete_forwards_subaccount_header(self): + http = _cfg_async_http() + api = _AsyncConfigurationApi(http) + await api.delete_application_tenant("tenant-uuid-1", subaccount_id="sub-abc") + + _, kwargs = http.delete.call_args + assert kwargs["extra_headers"] == {"X-SubaccountId": "sub-abc"} # ── _JobApi (sync) ───────────────────────────────────────────────────────────── From 854731704c95c6543c0050e33337f183c0177cf3 Mon Sep 17 00:00:00 2001 From: I766515 Date: Wed, 16 Sep 2026 14:51:01 +0530 Subject: [PATCH 13/13] chore: restore workflow files to avoid token scope restriction --- .github/dependabot.yml | 32 ++++------- .github/scripts/check_version_bump.py | 47 --------------- .github/scripts/validate_prerelease.py | 56 ------------------ .github/workflows/check-version-bump.yaml | 27 +++++---- .github/workflows/release.yml | 10 +--- src/sap_cloud_sdk/destination/__init__.py | 6 +- .../destination/_local_client_base.py | 28 +++++++-- .../destination/local_certificate_client.py | 4 +- src/sap_cloud_sdk/destination/local_client.py | 50 +++++++++++++--- .../destination/local_fragment_client.py | 4 +- .../integration/test_destination_bdd.py | 15 +++++ tests/destination/unit/test_init.py | 57 +++++++++++++++++-- .../unit/test_local_certificate_client.py | 2 +- .../unit/test_local_destination_client.py | 21 ++++++- 14 files changed, 184 insertions(+), 175 deletions(-) delete mode 100644 .github/scripts/check_version_bump.py delete mode 100644 .github/scripts/validate_prerelease.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d54937ea..b31adad0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,29 +1,22 @@ version: 2 updates: - # Python pip dependencies - # Non-major (minor + patch) version updates are batched into one grouped PR; - # majors are left ungrouped so each breaking bump lands in its own PR for - # review. Security updates (enabled in repo settings) come as a separate - # grouped PR of their own + # Python pip dependencies - security updates only + # Regular version updates are disabled because we use compatible release + # constraints (~=) in pyproject.toml to pin patch versions. + # Dependabot Security Updates (enabled in repo settings) bypass these rules. - package-ecosystem: "pip" directory: "/" schedule: interval: "weekly" day: "monday" time: "09:00" - groups: - version-minor-patch: - patterns: - - "*" + # Ignore all regular version updates - security updates still come through + ignore: + - dependency-name: "*" update-types: - - "minor" - - "patch" - security: - applies-to: security-updates - patterns: - - "*" - labels: - - "dependencies" + - "version-update:semver-major" + - "version-update:semver-minor" + - "version-update:semver-patch" # Commit message configuration commit-message: prefix: "chore" @@ -46,11 +39,6 @@ updates: labels: - "dependencies" - "github-actions" - # Batch all Actions bumps (including majors) into one PR - groups: - github-actions: - patterns: - - "*" commit-message: prefix: "chore" include: "scope" diff --git a/.github/scripts/check_version_bump.py b/.github/scripts/check_version_bump.py deleted file mode 100644 index d58f4ceb..00000000 --- a/.github/scripts/check_version_bump.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Check that a project version increases according to PEP 440.""" - -import argparse -import sys - -from packaging.version import InvalidVersion, Version - - -def check_version_bump(base: str, head: str) -> str: - """Require the head version to be newer than the base version.""" - try: - base_version = Version(base) - head_version = Version(head) - except InvalidVersion as error: - raise ValueError(f"Cannot compare project versions: {error}") from error - - if head_version == base_version: - raise ValueError(f"Version was not bumped (still {head_version}).") - - if head_version < base_version: - raise ValueError( - f"Version regression detected. Base is {base_version} but PR has " - f"{head_version}." - ) - - return f"Version bump OK: {base_version} -> {head_version}" - - -def main() -> int: - """Run the version comparison CLI.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("base", help="Version on the pull request base commit") - parser.add_argument("head", help="Version on the pull request head commit") - args = parser.parse_args() - - try: - message = check_version_bump(args.base, args.head) - except ValueError as error: - print(f"ERROR: {error}", file=sys.stderr) - return 1 - - print(message) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/scripts/validate_prerelease.py b/.github/scripts/validate_prerelease.py deleted file mode 100644 index 949d7310..00000000 --- a/.github/scripts/validate_prerelease.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Validate that a package version matches the GitHub pre-release setting.""" - -import argparse -import sys - -from packaging.version import InvalidVersion, Version - - -def validate_prerelease(raw_version: str, release_is_prerelease: bool) -> str: - """Require the package and GitHub Release to agree on pre-release status.""" - try: - version = Version(raw_version) - except InvalidVersion as error: - raise ValueError( - f"'{raw_version}' is not a valid PEP 440 version." - ) from error - - if version.is_prerelease != release_is_prerelease: - expected_setting = "enabled" if version.is_prerelease else "disabled" - raise ValueError( - "The GitHub Release pre-release setting does not match version " - f"'{raw_version}'. Set pre-release to {expected_setting} and publish " - "a new release event." - ) - - return ( - f"Pre-release status is valid: version={raw_version}, " - f"pre-release={str(release_is_prerelease).lower()}" - ) - - -def main() -> int: - """Run the pre-release validation CLI.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("version", help="Package version from pyproject.toml") - parser.add_argument( - "prerelease", - choices=("true", "false"), - help="GitHub Release pre-release setting", - ) - args = parser.parse_args() - - try: - message = validate_prerelease( - args.version, args.prerelease == "true" - ) - except ValueError as error: - print(f"ERROR: {error}", file=sys.stderr) - return 1 - - print(message) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/workflows/check-version-bump.yaml b/.github/workflows/check-version-bump.yaml index 8391f443..3db4af42 100644 --- a/.github/workflows/check-version-bump.yaml +++ b/.github/workflows/check-version-bump.yaml @@ -18,30 +18,29 @@ jobs: with: fetch-depth: 0 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.11" - - - name: Install version comparison dependency - run: python -m pip install packaging - - name: Check that version was bumped if src/ was modified run: | BASE_SHA="${{ github.event.pull_request.base.sha }}" HEAD_SHA="${{ github.event.pull_request.head.sha }}" - MERGE_BASE=$(git merge-base "$BASE_SHA" "$HEAD_SHA") - CHANGED_FILES=$(git diff --name-only "$MERGE_BASE" "$HEAD_SHA") + CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA") if echo "$CHANGED_FILES" | grep -qE "^src/.*\.(py|pyi|proto)$"; then - BASE_VERSION=$(git show "$MERGE_BASE:pyproject.toml" | grep "^version = " | cut -d'"' -f2) + BASE_VERSION=$(git show "$BASE_SHA:pyproject.toml" | grep "^version = " | cut -d'"' -f2) HEAD_VERSION=$(git show "$HEAD_SHA:pyproject.toml" | grep "^version = " | cut -d'"' -f2) - # Use PEP 440 ordering because sort -V ranks RCs above final releases - python .github/scripts/check_version_bump.py \ - "$BASE_VERSION" "$HEAD_VERSION" + if [ "$BASE_VERSION" = "$HEAD_VERSION" ]; then + echo "ERROR: Source files under src/ were modified but the version in pyproject.toml was not bumped (still $HEAD_VERSION)." + exit 1 + fi + + HIGHER=$(printf '%s\n' "$BASE_VERSION" "$HEAD_VERSION" | sort -V | tail -1) + if [ "$HIGHER" != "$HEAD_VERSION" ]; then + echo "ERROR: Version regression detected. Base is $BASE_VERSION but PR has $HEAD_VERSION." + exit 1 + fi + echo "Version bump OK: $BASE_VERSION → $HEAD_VERSION" else echo "No source file changes under src/. Version bump not required." fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 593f82bc..49369e69 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,21 +29,13 @@ jobs: - name: Read package name and version from pyproject.toml id: metadata run: | - python -m pip install toml packaging + pip install toml PKG_NAME=$(python -c "import toml; print(toml.load('pyproject.toml')['project']['name'])") VERSION=$(python -c "import toml; print(toml.load('pyproject.toml')['project']['version'])") echo "Publishing $PKG_NAME version $VERSION to PyPI" echo "name=$PKG_NAME" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT - - name: Validate pre-release status - env: - PACKAGE_VERSION: ${{ steps.metadata.outputs.version }} - RELEASE_IS_PRERELEASE: ${{ github.event.release.prerelease }} - run: | - python .github/scripts/validate_prerelease.py \ - "$PACKAGE_VERSION" "$RELEASE_IS_PRERELEASE" - - name: Check if version already exists on PyPI run: | PKG_NAME="${{ steps.metadata.outputs.name }}" diff --git a/src/sap_cloud_sdk/destination/__init__.py b/src/sap_cloud_sdk/destination/__init__.py index b8b3ff0f..b82c825b 100644 --- a/src/sap_cloud_sdk/destination/__init__.py +++ b/src/sap_cloud_sdk/destination/__init__.py @@ -129,7 +129,7 @@ def create_client( ClientCreationError: If client creation fails due to configuration or initialization issues. """ try: - if os.path.isfile(_mock_file(DESTINATION_MOCK_FILE)): + if config is None and os.path.isfile(_mock_file(DESTINATION_MOCK_FILE)): logger.warning( "Local mock mode active: using LocalDevDestinationClient backed by mocks/destination.json. " "This is intended for local development only and must not be used in production." @@ -172,7 +172,7 @@ def create_fragment_client( ClientCreationError: If client creation fails due to configuration or initialization issues. """ try: - if os.path.isfile(_mock_file(FRAGMENT_MOCK_FILE)): + if config is None and os.path.isfile(_mock_file(FRAGMENT_MOCK_FILE)): logger.warning( "Local mock mode active: using LocalDevFragmentClient backed by mocks/fragments.json. " "This is intended for local development only and must not be used in production." @@ -213,7 +213,7 @@ def create_certificate_client( ClientCreationError: If client creation fails due to configuration or initialization issues. """ try: - if os.path.isfile(_mock_file(CERTIFICATE_MOCK_FILE)): + if config is None and os.path.isfile(_mock_file(CERTIFICATE_MOCK_FILE)): logger.warning( "Local mock mode active: using LocalDevCertificateClient backed by mocks/certificates.json. " "This is intended for local development only and must not be used in production." diff --git a/src/sap_cloud_sdk/destination/_local_client_base.py b/src/sap_cloud_sdk/destination/_local_client_base.py index e7d2db97..0c07ef6a 100644 --- a/src/sap_cloud_sdk/destination/_local_client_base.py +++ b/src/sap_cloud_sdk/destination/_local_client_base.py @@ -227,12 +227,23 @@ def _resolve_instance_list( When tenant is provided, returns entries matching that tenant (subscriber context). When tenant is None, returns entries without a tenant field (provider context). """ + def _safe_parse(entry: Dict[str, Any]) -> Optional[T]: + try: + return self.from_dict(entry) + except Exception: + return None + if tenant is not None: return [ - self.from_dict(e) for e in instance_list if e.get("tenant") == tenant + parsed for e in instance_list + if e.get("tenant") == tenant + for parsed in [_safe_parse(e)] if parsed is not None ] - return [self.from_dict(e) for e in instance_list if not e.get("tenant")] + return [ + parsed for e in instance_list if not e.get("tenant") + for parsed in [_safe_parse(e)] if parsed is not None + ] def _resolve_subaccount_list( self, @@ -242,18 +253,25 @@ def _resolve_subaccount_list( ) -> List[T]: """Resolve a list of entities from the subaccount list using the given access strategy.""" + def _safe_parse(entry: Dict[str, Any]) -> Optional[T]: + try: + return self.from_dict(entry) + except Exception: + return None + def list_subscriber() -> List[T]: if tenant is None: return [] return [ - self.from_dict(entry) - for entry in sub_list + parsed for entry in sub_list if entry.get("tenant") == tenant + for parsed in [_safe_parse(entry)] if parsed is not None ] def list_provider() -> List[T]: return [ - self.from_dict(entry) for entry in sub_list if not entry.get("tenant") + parsed for entry in sub_list if not entry.get("tenant") + for parsed in [_safe_parse(entry)] if parsed is not None ] order_map: Dict[AccessStrategy, tuple[Callable[[], List[T]], ...]] = { diff --git a/src/sap_cloud_sdk/destination/local_certificate_client.py b/src/sap_cloud_sdk/destination/local_certificate_client.py index 75fa1ec4..c7f19080 100644 --- a/src/sap_cloud_sdk/destination/local_certificate_client.py +++ b/src/sap_cloud_sdk/destination/local_certificate_client.py @@ -248,7 +248,7 @@ def patch_certificate_labels( def list_instance_certificates( self, tenant: Optional[str] = None, - _filter: Optional[Any] = None, + filter: Optional[Any] = None, ) -> PagedResult[Certificate]: """List all certificates from the service instance scope. @@ -281,7 +281,7 @@ def list_subaccount_certificates( self, access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, tenant: Optional[str] = None, - _filter: Optional[Any] = None, + filter: Optional[Any] = None, ) -> PagedResult[Certificate]: """List certificates from the subaccount scope with an access strategy. diff --git a/src/sap_cloud_sdk/destination/local_client.py b/src/sap_cloud_sdk/destination/local_client.py index 5d1a6a26..247c40e0 100644 --- a/src/sap_cloud_sdk/destination/local_client.py +++ b/src/sap_cloud_sdk/destination/local_client.py @@ -11,12 +11,43 @@ Destination, Label, Level, + ListOptions, PatchLabels, ) from sap_cloud_sdk.destination.utils._pagination import PagedResult from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError +def _apply_list_options( + items: List[Destination], filter: Optional[ListOptions] +) -> List[Destination]: + """Apply filter_names from ListOptions to a list of destinations.""" + if not filter or not filter.filter_names: + return items + name_set = set(filter.filter_names) + return [d for d in items if d.name in name_set] + + +def _filter_raw_by_labels( + raw_list: List[Dict[str, Any]], filter: Optional[ListOptions] +) -> List[Dict[str, Any]]: + """Pre-filter raw dicts by filter_labels before parsing.""" + if not filter or not filter.filter_labels: + return raw_list + + def _matches(entry: Dict[str, Any]) -> bool: + entry_labels: Dict[str, List[str]] = { + lbl["key"]: lbl.get("values", []) + for lbl in entry.get("labels", []) + } + for label in filter.filter_labels: + if not any(v in entry_labels.get(label.key, []) for v in label.values): + return False + return True + + return [e for e in raw_list if _matches(e)] + + class LocalDevDestinationClient(LocalDevClientBase[Destination]): """ Local development client that mocks DestinationClient by manipulating a JSON file. @@ -246,7 +277,7 @@ def delete_destination( def list_instance_destinations( self, tenant: Optional[str] = None, - _filter: Optional[Any] = None, + filter: Optional[ListOptions] = None, ) -> PagedResult[Destination]: """List all destinations from the service instance scope. @@ -254,7 +285,8 @@ def list_instance_destinations( tenant: Optional subscriber tenant subdomain. When provided, returns only entries matching that tenant (subscriber context); otherwise returns provider-level entries (no tenant field). - _filter: Optional ListDestinationsFilter (ignored in local dev mode). + filter: Optional ListOptions. filter_names and filter_labels are applied in local mode; + pagination options are ignored. Returns: PagedResult[Destination] containing destinations and pagination info. @@ -266,7 +298,9 @@ def list_instance_destinations( """ try: data = self._read() - items = self._resolve_instance_list(tenant, data.get("instance", [])) + raw = _filter_raw_by_labels(data.get("instance", []), filter) + items = self._resolve_instance_list(tenant, raw) + items = _apply_list_options(items, filter) return PagedResult(items=items) except DestinationOperationError: raise @@ -279,7 +313,7 @@ def list_subaccount_destinations( self, access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, tenant: Optional[str] = None, - _filter: Optional[Any] = None, + filter: Optional[ListOptions] = None, ) -> PagedResult[Destination]: """List destinations from the subaccount scope with an access strategy. @@ -292,7 +326,8 @@ def list_subaccount_destinations( Args: access_strategy: Strategy controlling precedence between subscriber and provider contexts. tenant: Subscriber tenant subdomain, required for subscriber access strategies. - filter: Optional ListDestinationsFilter (ignored in local dev mode). + filter: Optional ListOptions. filter_names and filter_labels are applied in local mode; + pagination options are ignored. Returns: PagedResult[Destination] containing destinations and pagination info. @@ -305,8 +340,9 @@ def list_subaccount_destinations( self._validate_subscriber_access(access_strategy, tenant, "destinations") try: data = self._read() - sub_list = data.get("subaccount", []) - items = self._resolve_subaccount_list(access_strategy, tenant, sub_list) + raw = _filter_raw_by_labels(data.get("subaccount", []), filter) + items = self._resolve_subaccount_list(access_strategy, tenant, raw) + items = _apply_list_options(items, filter) return PagedResult(items=items) except DestinationOperationError: raise diff --git a/src/sap_cloud_sdk/destination/local_fragment_client.py b/src/sap_cloud_sdk/destination/local_fragment_client.py index 290b1ea8..a31a9098 100644 --- a/src/sap_cloud_sdk/destination/local_fragment_client.py +++ b/src/sap_cloud_sdk/destination/local_fragment_client.py @@ -141,7 +141,7 @@ def get_subaccount_fragment( def list_instance_fragments( self, tenant: Optional[str] = None, - _filter: Optional[Any] = None, + filter: Optional[Any] = None, ) -> List[Fragment]: """List all fragments from the service instance scope. @@ -168,7 +168,7 @@ def list_subaccount_fragments( self, access_strategy: AccessStrategy = AccessStrategy.SUBSCRIBER_FIRST, tenant: Optional[str] = None, - _filter: Optional[Any] = None, + filter: Optional[Any] = None, ) -> List[Fragment]: """List fragments from the subaccount scope with an access strategy. diff --git a/tests/destination/integration/test_destination_bdd.py b/tests/destination/integration/test_destination_bdd.py index 58ceea2e..6df31dd6 100644 --- a/tests/destination/integration/test_destination_bdd.py +++ b/tests/destination/integration/test_destination_bdd.py @@ -314,6 +314,11 @@ def create_all_instance_destinations(context, destination_client): """Create all destinations at instance level.""" context.concurrent_results = [] for dest in context.destinations: + # Idempotent cleanup before create + try: + destination_client.delete_destination(dest.name, level=Level.SERVICE_INSTANCE) + except Exception: + pass try: destination_client.create_destination(dest, level=Level.SERVICE_INSTANCE) context.concurrent_results.append(True) @@ -839,6 +844,11 @@ def list_subaccount_fragments_with_label_filter(context, fragment_client, strate @when("I create the certificate at subaccount level") def create_certificate_subaccount(context, certificate_client): """Create certificate at subaccount level.""" + try: + certificate_client.delete_certificate(context.certificate.name, level=Level.SUB_ACCOUNT) + except Exception: + pass + try: certificate_client.create_certificate(context.certificate, level=Level.SUB_ACCOUNT) context.operation_success = True @@ -857,6 +867,11 @@ def create_all_subaccount_certificates(context, certificate_client, sample_pem_c # Set content if not already set if not cert.content: cert.content = sample_pem_certificate + # Idempotent cleanup before create + try: + certificate_client.delete_certificate(cert.name, level=Level.SERVICE_INSTANCE) + except Exception: + pass try: certificate_client.create_certificate(cert, level=Level.SERVICE_INSTANCE) context.concurrent_results.append(True) diff --git a/tests/destination/unit/test_init.py b/tests/destination/unit/test_init.py index 1b5ba4d2..e4cf95ad 100644 --- a/tests/destination/unit/test_init.py +++ b/tests/destination/unit/test_init.py @@ -9,6 +9,7 @@ CERTIFICATE_MOCK_FILE, ) from sap_cloud_sdk.destination import create_client, create_fragment_client, create_certificate_client +from sap_cloud_sdk.destination._models import ListOptions, Label, AccessStrategy from sap_cloud_sdk.destination.client import DestinationClient from sap_cloud_sdk.destination.fragment_client import FragmentClient from sap_cloud_sdk.destination.certificate_client import CertificateClient @@ -112,8 +113,23 @@ def test_falls_through_to_cloud_when_no_mock_file(self, mock_build_http): client = create_client() assert isinstance(client, DestinationClient) - -class TestCreateFragmentClient: + @patch("sap_cloud_sdk.destination._local_client_base.os.path.abspath") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: True) + @patch("sap_cloud_sdk.destination.TokenProvider") + @patch("sap_cloud_sdk.destination.DestinationHttp") + def test_explicit_config_bypasses_mock_file(self, mock_http, mock_tp, mock_abspath, tmp_path): + mock_abspath.return_value = str(tmp_path) + mock_tp.return_value = Mock() + mock_http.return_value = Mock() + config = DestinationConfig( + url="https://destination.example.com", + token_url="https://auth.example.com/oauth/token", + client_id="test-client", + client_secret="test-secret", + identityzone="provider-zone", + ) + client = create_client(config=config) + assert isinstance(client, DestinationClient) """Tests for create_fragment_client cloud mode.""" @_NO_MOCK_FILE @@ -202,8 +218,23 @@ def test_falls_through_to_cloud_when_no_mock_file(self, mock_build_http): client = create_fragment_client() assert isinstance(client, FragmentClient) - -class TestCreateCertificateClient: + @patch("sap_cloud_sdk.destination._local_client_base.os.path.abspath") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: True) + @patch("sap_cloud_sdk.destination.TokenProvider") + @patch("sap_cloud_sdk.destination.DestinationHttp") + def test_explicit_config_bypasses_mock_file(self, mock_http, mock_tp, mock_abspath, tmp_path): + mock_abspath.return_value = str(tmp_path) + mock_tp.return_value = Mock() + mock_http.return_value = Mock() + config = DestinationConfig( + url="https://destination.example.com", + token_url="https://auth.example.com/oauth/token", + client_id="test-client", + client_secret="test-secret", + identityzone="provider-zone", + ) + client = create_fragment_client(config=config) + assert isinstance(client, FragmentClient) """Tests for create_certificate_client cloud mode.""" @_NO_MOCK_FILE @@ -292,6 +323,24 @@ def test_falls_through_to_cloud_when_no_mock_file(self, mock_build_http): client = create_certificate_client() assert isinstance(client, CertificateClient) + @patch("sap_cloud_sdk.destination._local_client_base.os.path.abspath") + @patch("sap_cloud_sdk.destination.os.path.isfile", new=lambda _: True) + @patch("sap_cloud_sdk.destination.TokenProvider") + @patch("sap_cloud_sdk.destination.DestinationHttp") + def test_explicit_config_bypasses_mock_file(self, mock_http, mock_tp, mock_abspath, tmp_path): + mock_abspath.return_value = str(tmp_path) + mock_tp.return_value = Mock() + mock_http.return_value = Mock() + config = DestinationConfig( + url="https://destination.example.com", + token_url="https://auth.example.com/oauth/token", + client_id="test-client", + client_secret="test-secret", + identityzone="provider-zone", + ) + client = create_certificate_client(config=config) + assert isinstance(client, CertificateClient) + class TestCreateClientTelemetrySource: """Verify _telemetry_source kwarg is stored on the client.""" diff --git a/tests/destination/unit/test_local_certificate_client.py b/tests/destination/unit/test_local_certificate_client.py index d0f9e1bd..19bb8846 100644 --- a/tests/destination/unit/test_local_certificate_client.py +++ b/tests/destination/unit/test_local_certificate_client.py @@ -131,7 +131,7 @@ def test_returns_empty_for_empty_store(self, client): def test_filter_param_is_accepted_and_ignored(self, client): _write_store(client, {"instance": [{"Name": "cert.pem", "Content": "c1"}], "subaccount": []}) - result = client.list_instance_certificates(_filter=object()) + result = client.list_instance_certificates(filter=object()) assert len(result.items) == 1 def test_does_not_include_subaccount_entries(self, client): diff --git a/tests/destination/unit/test_local_destination_client.py b/tests/destination/unit/test_local_destination_client.py index 1b200493..8e61e278 100644 --- a/tests/destination/unit/test_local_destination_client.py +++ b/tests/destination/unit/test_local_destination_client.py @@ -6,7 +6,7 @@ from sap_cloud_sdk.destination.local_client import LocalDevDestinationClient from sap_cloud_sdk.destination._local_client_base import DESTINATION_MOCK_FILE -from sap_cloud_sdk.destination._models import AccessStrategy, Destination, Label, Level, PatchLabels +from sap_cloud_sdk.destination._models import AccessStrategy, Destination, Label, Level, ListOptions, PatchLabels from sap_cloud_sdk.destination.utils._pagination import PagedResult from sap_cloud_sdk.destination.exceptions import DestinationOperationError, HttpError @@ -151,9 +151,24 @@ def test_returns_empty_for_empty_store(self, client): assert len(result.items) == 0 def test_filter_param_is_accepted_and_ignored(self, client): - _write_store(client, {"instance": [{"name": "d1", "type": "HTTP"}], "subaccount": []}) - result = client.list_instance_destinations(_filter=object()) + _write_store(client, {"instance": [{"name": "d1", "type": "HTTP"}, {"name": "d2", "type": "HTTP"}], "subaccount": []}) + result = client.list_instance_destinations(filter=ListOptions()) + assert len(result.items) == 2 + + def test_filter_names_applied(self, client): + _write_store(client, {"instance": [{"name": "d1", "type": "HTTP"}, {"name": "d2", "type": "HTTP"}], "subaccount": []}) + result = client.list_instance_destinations(filter=ListOptions(filter_names=["d1"])) + assert len(result.items) == 1 + assert result.items[0].name == "d1" + + def test_filter_labels_applied(self, client): + _write_store(client, {"instance": [ + {"name": "labeled", "type": "HTTP", "labels": [{"key": "env", "values": ["prod"]}]}, + {"name": "unlabeled", "type": "HTTP"}, + ], "subaccount": []}) + result = client.list_instance_destinations(filter=ListOptions(filter_labels=[Label(key="env", values=["prod"])])) assert len(result.items) == 1 + assert result.items[0].name == "labeled" def test_does_not_include_subaccount_entries(self, client): _write_store(client, {