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 bfc156cd..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 }}" @@ -65,4 +57,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 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" diff --git a/src/sap_cloud_sdk/adms/__init__.py b/src/sap_cloud_sdk/adms/__init__.py index c420626d..d1df3308 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,6 +80,7 @@ DraftActivateInput, DraftAdministrativeData, DraftInput, + FileExtensionPolicy, JobInput, JobOutput, JobStatus, @@ -113,8 +120,11 @@ "ScanNotCleanError", # models — core "BaseType", + "ChangeLog", + "BusinessObjectNodeChangeLog", "CreateDocumentInput", "CreateDocumentRelationInput", + "DeleteBusinessObjectNodeResult", "DeleteUserDataJobParameters", "Document", "DocumentContentVersion", @@ -131,17 +141,21 @@ "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..864f8dbc 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, @@ -152,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) @@ -243,42 +246,88 @@ 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. + + 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_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: - """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_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: - """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. + """ + 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_bo_type_map_id)}/markDefault", + f"{key_path}/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 +337,24 @@ 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.""" + doc_id_key = quote_odata_string_key(document_type_id) + ext_key = quote_odata_string_key(file_extension) self._http.delete( - f"FileExtensionPolicy(FileExtensionPolicyID={_quote_guid(file_extension_policy_id)})", + f"DocumentTypeFileExtensionPolicy(" + f"DocumentTypeID={doc_id_key},FileExtension={ext_key})", service_base=_CONFIG_SERVICE_PATH, ) @@ -319,12 +362,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,39 +387,59 @@ 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 _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) +def _subaccount_header(subaccount_id: str | None) -> dict[str, str] | None: + """Build the X-SubaccountId header dict, or None if not provided.""" + return {"X-SubaccountId": subaccount_id} if subaccount_id else None class _AsyncConfigurationApi: @@ -381,7 +456,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 @@ -392,7 +467,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(), @@ -402,7 +477,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, @@ -413,7 +488,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(), @@ -423,7 +498,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, @@ -434,7 +509,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 @@ -445,7 +520,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(), @@ -455,7 +530,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, @@ -466,7 +541,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(), @@ -476,7 +551,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, @@ -487,10 +562,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) @@ -501,7 +578,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(), @@ -513,7 +590,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 @@ -528,7 +605,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 @@ -542,7 +619,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 @@ -555,7 +632,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", @@ -571,7 +648,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(), @@ -581,28 +658,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_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.""" + """Async variant of :meth:`_ConfigurationApi.get_type_mapping`.""" 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 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_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 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_bo_type_map_id)}/markDefault", + f"{key_path}/com.sap.adm.ConfigurationService.markDefault", json={}, service_base=_CONFIG_SERVICE_PATH, ) @@ -611,10 +705,12 @@ async def mark_default(self, document_type_bo_type_map_id: str) -> None: 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( - "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", []) @@ -624,41 +720,41 @@ 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( - "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 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"FileExtensionPolicy(FileExtensionPolicyID={_quote_guid(file_extension_policy_id)})", + f"DocumentTypeFileExtensionPolicy(" + f"DocumentTypeID={doc_id_key},FileExtension={ext_key})", service_base=_CONFIG_SERVICE_PATH, ) @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.""" + """Async variant of :meth:`_ConfigurationApi.get_all_application_tenants`.""" 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,31 +762,45 @@ 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.""" + """Async variant of :meth:`_ConfigurationApi.create_application_tenant`.""" 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.""" + """Async variant of :meth:`_ConfigurationApi.get_application_tenant`.""" 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 variant of :meth:`_ConfigurationApi.delete_application_tenant` — same semantics.""" + async def delete_application_tenant( + self, + application_tenant_id: str, + *, + subaccount_id: str | None = None, + ) -> None: + """Async variant of :meth:`_ConfigurationApi.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/_document_api.py b/src/sap_cloud_sdk/adms/_document_api.py index ab1bebdd..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}/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) - + "/UpdateDocument" + + "/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) @@ -217,7 +213,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 +242,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, @@ -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}/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", "") @@ -357,15 +356,10 @@ 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) - 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) @@ -379,7 +373,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 +393,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..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)})" ) @@ -211,8 +212,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 +229,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 +246,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 +263,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 +282,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 +295,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 +306,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 +510,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 +528,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 +546,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 +564,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 +583,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 +606,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..18941a5a 100644 --- a/src/sap_cloud_sdk/adms/_job_api.py +++ b/src/sap_cloud_sdk/adms/_job_api.py @@ -16,6 +16,11 @@ 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. +# Unbound action/function import names — no namespace prefix (per EDMX ActionImport/FunctionImport). +_START_JOB_DOC_SERVICE = "StartJob" +_START_JOB_ADMIN_SERVICE = "StartJob" + class _JobApi: """Async job operations for the ADMS module. @@ -42,7 +47,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 +69,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()) @@ -108,7 +117,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 +133,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()) 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, } 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/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 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/adms/unit/test_client.py b/tests/adms/unit/test_client.py index f789428a..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, @@ -734,7 +738,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 +763,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 +777,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 +1025,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 +1035,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 +1043,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() @@ -1121,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, } @@ -1335,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 @@ -1349,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) @@ -1359,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: @@ -1496,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) ───────────────────────────────────────────────────────────── 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, {