diff --git a/docs/content/en/latest/workspace-content/localization/_index.md b/docs/content/en/latest/workspace-content/localization/_index.md new file mode 100644 index 000000000..3c1c3bc02 --- /dev/null +++ b/docs/content/en/latest/workspace-content/localization/_index.md @@ -0,0 +1,67 @@ +--- +title: "Metadata Localization" +linkTitle: "Metadata Localization" +weight: 20 +--- + +Manage metadata localization for workspaces. + +## Methods + +* [get_metadata_localization](./get_metadata_localization/) +* [set_metadata_localization](./set_metadata_localization/) +* [clean_metadata_localization](./clean_metadata_localization/) +* [add_metadata_locale](./add_metadata_locale/) +* [save_metadata_locale_to_disk](./save_metadata_locale_to_disk/) +* [set_metadata_locale_from_disk](./set_metadata_locale_from_disk/) + +## Example + +```python +from gooddata_sdk import GoodDataSdk +from pathlib import Path + +# GoodData base URL, e.g. "https://www.example.com" +host = "https://www.example.com" +# GoodData user token +token = "some_user_token" +sdk = GoodDataSdk.create(host, token) + +# Example usage for getting metadata localization +localization = sdk.catalog_workspace.get_metadata_localization( + workspace_id="123", + target_language="de-DE" +) + +# Example usage for setting metadata localization +sdk.catalog_workspace.set_metadata_localization( + workspace_id="123", + encoded_xml=b"..." +) + +# Example usage for cleaning metadata localization +sdk.catalog_workspace.clean_metadata_localization( + workspace_id="123", + target_language="de-DE" +) + +# Example usage for adding metadata locale +sdk.catalog_workspace.add_metadata_locale( + workspace_id="123", + target_language="de-DE", + translator_func=my_translation_function, + set_locale=True +) + +# Example usage for saving metadata locale to disk +sdk.catalog_workspace.save_metadata_locale_to_disk( + workspace_id="123", + target_language="de-DE", + file_path=Path("/path/to/file.xliff") +) + +# Example usage for setting metadata locale from disk +sdk.catalog_workspace.set_metadata_locale_from_disk( + workspace_id="123", + file_path=Path("/path/to/file.xliff") +) diff --git a/docs/content/en/latest/workspace-content/localization/add_metadata_locale.md b/docs/content/en/latest/workspace-content/localization/add_metadata_locale.md new file mode 100644 index 000000000..2709b8a0f --- /dev/null +++ b/docs/content/en/latest/workspace-content/localization/add_metadata_locale.md @@ -0,0 +1,39 @@ +--- +title: "add_metadata_locale" +linkTitle: "add_metadata_locale" +weight: 25 +superheading: "catalog_workspace." +--- + +``add_metadata_locale(workspace_id: str, target_language: str, translator_func: Callable, set_locale: bool = True) -> None`` + +Add and optionally set the metadata localization for a workspace in a target language. + +{{% parameters-block title="Parameters" %}} +{{< parameter p_name="workspace_id" p_type="string" >}} +The ID of the workspace. +{{< /parameter >}} +{{< parameter p_name="target_language" p_type="string" >}} +The target language for the metadata localization. +{{< /parameter >}} +{{< parameter p_name="translator_func" p_type="Callable" >}} +A function to translate the source text. +{{< /parameter >}} +{{< parameter p_name="set_locale" p_type="bool" >}} +Flag to indicate if the locale settings should be updated in the workspace. +{{< /parameter >}} +{{% /parameters-block %}} + +{{% parameters-block title="Returns" None="yes" %}} +{{% /parameters-block %}} + +## Example + +```python +# Add and set the metadata localization for a workspace using a translation function. +sdk.catalog_workspace.add_metadata_locale( + workspace_id="123", + target_language="de-DE", + translator_func=my_translation_function, + set_locale=True +) diff --git a/docs/content/en/latest/workspace-content/localization/clean_metadata_localization.md b/docs/content/en/latest/workspace-content/localization/clean_metadata_localization.md new file mode 100644 index 000000000..caad71e87 --- /dev/null +++ b/docs/content/en/latest/workspace-content/localization/clean_metadata_localization.md @@ -0,0 +1,39 @@ +--- +title: "add_metadata_locale" +linkTitle: "add_metadata_locale" +weight: 55 +superheading: "catalog_workspace." +--- + +``add_metadata_locale(workspace_id: str, target_language: str, translator_func: Callable, set_locale: bool = True) -> None`` + +Add and optionally set the metadata localization for a workspace in a target language. + +{{% parameters-block title="Parameters" %}} +{{< parameter p_name="workspace_id" p_type="string" >}} +The ID of the workspace. +{{< /parameter >}} +{{< parameter p_name="target_language" p_type="string" >}} +The target language for the metadata localization. +{{< /parameter >}} +{{< parameter p_name="translator_func" p_type="Callable" >}} +A function to translate the source text. +{{< /parameter >}} +{{< parameter p_name="set_locale" p_type="bool" >}} +Flag to indicate if the locale settings should be updated in the workspace. +{{< /parameter >}} +{{% /parameters-block %}} + +{{% parameters-block title="Returns" None="yes" %}} +{{% /parameters-block %}} + +## Example + +```python +# Add and set the metadata localization for a workspace using a translation function. +sdk.catalog_workspace.add_metadata_locale( + workspace_id="123", + target_language="de-DE", + translator_func=my_translation_function, + set_locale=True +) diff --git a/docs/content/en/latest/workspace-content/localization/get_metadata_localization.md b/docs/content/en/latest/workspace-content/localization/get_metadata_localization.md new file mode 100644 index 000000000..c192d7ace --- /dev/null +++ b/docs/content/en/latest/workspace-content/localization/get_metadata_localization.md @@ -0,0 +1,34 @@ +--- +title: "get_metadata_localization" +linkTitle: "get_metadata_localization" +weight: 52 +superheading: "catalog_workspace." +--- + +``get_metadata_localization(workspace_id: str, target_language: str) -> bytes`` + +Retrieve the metadata localization for a workspace. + +{{% parameters-block title="Parameters" %}} +{{< parameter p_name="workspace_id" p_type="string" >}} +The ID of the workspace for which to retrieve the metadata localization. +{{< /parameter >}} +{{< parameter p_name="target_language" p_type="string" >}} +The target language code for the localization. +{{< /parameter >}} +{{% /parameters-block %}} + +{{% parameters-block title="Returns" %}} +{{< parameter p_type="bytes" >}} +Object Containing declarative Analytical Model. +{{< /parameter >}}The encoded metadata localization in the target language. +{{% /parameters-block %}} + +## Example + +```python +# Retrieve metadata localization for a workspace in the specified language. +localization = sdk.catalog_workspace.get_metadata_localization( + workspace_id="123", + target_language="de-DE" +) diff --git a/docs/content/en/latest/workspace-content/localization/save_metadata_locale_to_disk.md b/docs/content/en/latest/workspace-content/localization/save_metadata_locale_to_disk.md new file mode 100644 index 000000000..4f878e0bc --- /dev/null +++ b/docs/content/en/latest/workspace-content/localization/save_metadata_locale_to_disk.md @@ -0,0 +1,35 @@ +--- +title: "save_metadata_locale_to_disk" +linkTitle: "save_metadata_locale_to_disk" +weight: 56 +superheading: "catalog_workspace." +--- + +``save_metadata_locale_to_disk(workspace_id: str, target_language: str, file_path: Path) -> None`` + +Save the metadata localization for a workspace to a file. + +{{% parameters-block title="Parameters" %}} +{{< parameter p_name="workspace_id" p_type="string" >}} +The ID of the workspace. +{{< /parameter >}} +{{< parameter p_name="target_language" p_type="string" >}} +The target language for the metadata localization. +{{< /parameter >}} +{{< parameter p_name="file_path" p_type="Path" >}} +The path to the file where the XLIFF content will be saved. +{{< /parameter >}} +{{% /parameters-block %}} + +{{% parameters-block title="Returns" None="yes" %}} +{{% /parameters-block %}} + +## Example + +```python +# Save the metadata localization for a workspace to a file. +sdk.catalog_workspace.save_metadata_locale_to_disk( + workspace_id="123", + target_language="de-DE", + file_path=Path("/path/to/file.xliff") +) diff --git a/docs/content/en/latest/workspace-content/localization/set_metadata_locale_from_disk.md b/docs/content/en/latest/workspace-content/localization/set_metadata_locale_from_disk.md new file mode 100644 index 000000000..514d29009 --- /dev/null +++ b/docs/content/en/latest/workspace-content/localization/set_metadata_locale_from_disk.md @@ -0,0 +1,33 @@ +--- +title: "set_metadata_locale_from_disk" +linkTitle: "set_metadata_locale_from_disk" +weight: 57 +superheading: "catalog_workspace." +--- + +``set_metadata_locale_from_disk(workspace_id: str, file_path: Path) -> None`` + +Load and set the metadata localization for a workspace from a file. + +{{% parameters-block title="Parameters" %}} +{{< parameter p_name="workspace_id" p_type="string" >}} +The ID of the workspace to which the metadata localization applies. +{{< /parameter >}} +{{< parameter p_name="file_path" p_type="Path" >}} +The path to the file containing the encoded XML metadata. +{{< /parameter >}} +{{% /parameters-block %}} + +{{% parameters-block title="Returns" None="yes" %}} +{{% /parameters-block %}} + +## Example + +```python +# Load and set the metadata localization for a workspace from a file. +from pathlib import Path + +sdk.catalog_workspace.set_metadata_locale_from_disk( + workspace_id="123", + file_path=Path("/path/to/file.xliff") +) diff --git a/docs/content/en/latest/workspace-content/localization/set_metadata_localization.md b/docs/content/en/latest/workspace-content/localization/set_metadata_localization.md new file mode 100644 index 000000000..7260b85cc --- /dev/null +++ b/docs/content/en/latest/workspace-content/localization/set_metadata_localization.md @@ -0,0 +1,31 @@ +--- +title: "set_metadata_localization" +linkTitle: "set_metadata_localization" +weight: 53 +superheading: "catalog_workspace." +--- + +``set_metadata_localization(workspace_id: str, encoded_xml: bytes) -> None`` + +Set the metadata localization for a workspace. + +{{% parameters-block title="Parameters" %}} +{{< parameter p_name="workspace_id" p_type="string" >}} +The ID of the workspace to which the metadata localization applies. +{{< /parameter >}} +{{< parameter p_name="encoded_xml" p_type="bytes" >}} +The encoded XML metadata to be set. +{{< /parameter >}} +{{% /parameters-block %}} + +{{% parameters-block title="Returns" None="yes" %}} +{{% /parameters-block %}} + +## Example + +```python +# Set the metadata localization for a workspace using encoded XML. +sdk.catalog_workspace.set_metadata_localization( + workspace_id="123", + encoded_xml=b"..." +) diff --git a/gooddata-sdk/gooddata_sdk/catalog/workspace/service.py b/gooddata-sdk/gooddata_sdk/catalog/workspace/service.py index 82175dc27..b4f5e81cd 100644 --- a/gooddata-sdk/gooddata_sdk/catalog/workspace/service.py +++ b/gooddata-sdk/gooddata_sdk/catalog/workspace/service.py @@ -10,8 +10,10 @@ from pathlib import Path from time import time from typing import Any, Callable, Dict, List, Optional, Set +from xml.etree import ElementTree as ET import attrs +from gooddata_api_client.api.translations_api import LocaleRequest from gooddata_api_client.exceptions import NotFoundException from gooddata_api_client.model.resolve_settings_request import ResolveSettingsRequest @@ -32,7 +34,9 @@ from gooddata_sdk.catalog.workspace.entity_model.workspace import CatalogWorkspace from gooddata_sdk.client import GoodDataApiClient from gooddata_sdk.utils import ( + HttpMethod, create_directory, + get_namespace_from_xliff, load_all_entities, load_all_entities_dict, read_layout_from_file, @@ -806,6 +810,182 @@ def set_translated_texts( if "description" in section["header"]: section["header"]["description"] = translated.get(section["header"]["description"]) + @staticmethod + def _add_target_tags(xliff_content: str, translate_func: Callable) -> bytes: + """Add target tags to the XLIFF content for translation purposes. + + Args: + xliff_content (str): The XLIFF content as a string. + translate_func (Optional[Callable]): + A function that translates the source text. It can take an optional argument + `already_translated` and `old_translation` for updating existing translations. + + Returns: + bytes: The modified XLIFF content with target tags, encoded as UTF-8. + """ + namespace = get_namespace_from_xliff(xliff_content) + + ET.register_namespace("", namespace["ns"]) + tree = ET.ElementTree(ET.fromstring(xliff_content)) + root = tree.getroot() + + # Segment is always parent of source/target - no need to find parents + for segment in root.findall(".//ns:segment", namespaces=namespace): + source = segment.find("ns:source", namespaces=namespace) + if source is not None and "".join(source.itertext()).strip(): + to_translate = "".join(source.itertext()).strip() + target = segment.find("ns:target", namespaces=namespace) + if target is None: + target = ET.Element("target") + segment.append(target) + if not target.text or not target.text.strip(): + target.text = translate_func(to_translate) + else: + old_translation = "".join(target.itertext()).strip() + target.text = translate_func( + to_translate=to_translate, + already_translated=True, + old_translation=old_translation, + ) + + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + + def get_metadata_localization( + self, + workspace_id: str, + target_language: str, + ) -> bytes: + """Retrieve the metadata localization for a workspace. + + Args: + workspace_id (str): The ID of the workspace for which to retrieve the metadata localization. + target_language (str): The target language code for the localization. + + Returns: + bytes: The encoded metadata localization in the target language. + """ + ans = self._actions_api.retrieve_translations( + workspace_id=workspace_id, + locale_request=LocaleRequest(locale=target_language), + _preload_content=False, + ) + return ans.data + + def set_metadata_localization( + self, + workspace_id: str, + encoded_xml: bytes, + ) -> None: + """Set the metadata localization for a workspace. + + Args: + workspace_id (str): The ID of the workspace to which the metadata localization applies. + encoded_xml (bytes): The encoded XML metadata to be set. + + Returns: + None + """ + self._client.do_request( + method=HttpMethod.POST, + endpoint=f"api/v1/actions/workspaces/{workspace_id}/translations/set", + content_type="application/xml", + data=encoded_xml, + ) + + def clean_metadata_localization( + self, + workspace_id: str, + target_language: str, + ) -> None: + """Clean the metadata localization for a workspace. + + Args: + workspace_id (str): The ID of the workspace for which to clean the metadata localization. + target_language (str): The target language code for the localization to be cleaned. + + Returns: + None + """ + self._client.actions_api.clean_translations( + workspace_id=workspace_id, locale_request=LocaleRequest(target_language) + ) + + def add_metadata_locale( + self, + workspace_id: str, + target_language: str, + translator_func: Callable, + set_locale: bool = True, + ) -> None: + """Add and optionally set the metadata localization for a workspace in a target language. + + Args: + workspace_id (str): The ID of the workspace. + target_language (str): The target language for the metadata localization. + translator_func (Optional[Callable]): A function to translate the source text. + set_locale (bool): Flag to indicate if the locale settings should be updated in the workspace. + + Returns: + None + """ + ans = self._actions_api.retrieve_translations( + workspace_id=workspace_id, + locale_request=LocaleRequest(locale=target_language), + _preload_content=False, + ) + + encoded_xml = self._add_target_tags(ans.data.decode(), translator_func) + + self.set_metadata_localization(workspace_id=workspace_id, encoded_xml=encoded_xml) + if set_locale: + metadata_locale = "METADATA_LOCALE" + locale = "LOCALE" + + self.create_or_update_workspace_setting( + workspace_id, + CatalogWorkspaceSetting( + id=metadata_locale, setting_type=metadata_locale, content={"value": target_language} + ), + ) + self.create_or_update_workspace_setting( + workspace_id, + CatalogWorkspaceSetting(id=locale, setting_type=locale, content={"value": target_language}), + ) + + def save_metadata_locale_to_disk(self, workspace_id: str, target_language: str, file_path: Path) -> None: + """Save the metadata localization for a workspace to a file. + + Args: + workspace_id (str): The ID of the workspace. + target_language (str): The target language for the metadata localization. + file_path (Path): The path to the file where the XLIFF content will be saved. + + Returns: + None + """ + xliff_content = self.get_metadata_localization(workspace_id, target_language) + + ns = get_namespace_from_xliff(xliff_content.decode()) + + ET.register_namespace("", ns["ns"]) + tree = ET.ElementTree(ET.fromstring(xliff_content)) + + tree.write(file_path, "utf-8") + + def set_metadata_locale_from_disk(self, workspace_id: str, file_path: Path) -> None: + """Load and set the metadata localization for a workspace from a file. + + Args: + workspace_id (str): The ID of the workspace to which the metadata localization applies. + file_path (Path): The path to the file containing the encoded XML metadata. + + Returns: + None + """ + with open(file_path, "rb") as f: + encoded_xml = f.read() + self.set_metadata_localization(workspace_id=workspace_id, encoded_xml=encoded_xml) + # Declarative methods - workspace data filters def get_declarative_workspace_data_filters(self) -> CatalogDeclarativeWorkspaceDataFilters: diff --git a/gooddata-sdk/gooddata_sdk/client.py b/gooddata-sdk/gooddata_sdk/client.py index 617898c0b..d1eae14a4 100644 --- a/gooddata-sdk/gooddata_sdk/client.py +++ b/gooddata-sdk/gooddata_sdk/client.py @@ -3,12 +3,15 @@ from __future__ import annotations +from builtins import bytes from typing import Optional import gooddata_api_client as api_client +import requests from gooddata_api_client import apis from gooddata_sdk import __version__ +from gooddata_sdk.utils import HttpMethod USER_AGENT = f"gooddata-python-sdk/{__version__}" @@ -56,6 +59,62 @@ def __init__( self._actions_api = apis.ActionsApi(self._api_client) self._user_management_api = apis.UserManagementApi(self._api_client) + def _do_post_request( + self, + data: bytes, + endpoint: str, + content_type: str, + ) -> requests.Response: + """Perform a POST request to a specified endpoint. + + Args: + data (bytes): The data to be sent in the POST request. + endpoint (str): The endpoint URL to which the request is made. + content_type (str): The content type of the data being sent. + + Returns: + None + """ + if not self._hostname.endswith("/"): + endpoint = f"/{endpoint}" + + response = requests.post( + url=f"{self._hostname}{endpoint}", + headers={ + "Content-Type": content_type, + "Authorization": f"Bearer {self._token}", + }, + data=data, + ) + + return response + + def do_request( + self, + data: bytes, + endpoint: str, + content_type: str, + method: HttpMethod, + ) -> requests.Response: + """Perform an HTTP request using the specified method. + + Args: + data (bytes): The data to be sent in the request. + endpoint (str): The endpoint URL to which the request is made. + content_type (str): The content type of the data being sent. + method (HttpMethod): The HTTP method to be used for the request. + + Returns: + None + + Raises: + NotImplementedError: If the specified HTTP method is not supported. + """ + if method == HttpMethod.POST: + self._do_post_request(data, endpoint, content_type) + else: + raise NotImplementedError("Currently only supports the POST method.") + @staticmethod def _set_default_headers(headers: dict) -> None: headers["X-Requested-With"] = "XMLHttpRequest" diff --git a/gooddata-sdk/gooddata_sdk/utils.py b/gooddata-sdk/gooddata_sdk/utils.py index 03923d08d..d0b6eab08 100644 --- a/gooddata-sdk/gooddata_sdk/utils.py +++ b/gooddata-sdk/gooddata_sdk/utils.py @@ -5,9 +5,11 @@ import os import re from collections.abc import KeysView +from enum import Enum, auto from pathlib import Path from shutil import rmtree from typing import Any, Callable, Dict, List, NamedTuple, Tuple, Union, cast, no_type_check +from xml.etree import ElementTree as ET import yaml from gooddata_api_client import ApiAttributeError @@ -25,6 +27,16 @@ SDK_PROFILE_KEYS = SDK_PROFILE_MANDATORY_KEYS + ["custom_headers", "extra_user_agent"] +class HttpMethod(Enum): + """Enum representing HTTP methods.""" + + GET = auto() + POST = auto() + PUT = auto() + DELETE = auto() + PATCH = auto() + + def id_obj_to_key(id_obj: IdObjType) -> str: """ Given an object containing an id+type pair, this function will return a string key. @@ -306,3 +318,17 @@ def safeget_list(var: Any, path: List[str]) -> List[Any]: return [] else: raise Exception("safeget_list: result is not iterable! result={0}".format(result)) + + +def get_namespace_from_xliff(xliff_content: str) -> Dict: + """Extract the XML namespace from the given XLIFF content. + + Args: + xliff_content (str): The XLIFF content as bytes. + + Returns: + dict: A dictionary containing the namespace with the key 'ns'. + """ + tree = ET.ElementTree(ET.fromstring(xliff_content)) + root = tree.getroot() + return {"ns": root.tag.split("}")[0].strip("{")} diff --git a/gooddata-sdk/mypy.ini b/gooddata-sdk/mypy.ini index 60d2a0406..1ff031b52 100644 --- a/gooddata-sdk/mypy.ini +++ b/gooddata-sdk/mypy.ini @@ -11,3 +11,6 @@ ignore_missing_imports = True [mypy-urllib3.*] ignore_missing_imports = True + +[mypy-requests.*] +ignore_missing_imports = True diff --git a/gooddata-sdk/requirements.txt b/gooddata-sdk/requirements.txt index e4bb49f1b..8c43c3b1b 100644 --- a/gooddata-sdk/requirements.txt +++ b/gooddata-sdk/requirements.txt @@ -3,3 +3,4 @@ pyyaml>=5.1 attrs>=21.4.0,<=23.2.0 cattrs>=22.1.0,<=23.2.3 brotli==1.1.0 +requests~=2.31.0 diff --git a/gooddata-sdk/setup.py b/gooddata-sdk/setup.py index 25d9d575b..7b970d010 100644 --- a/gooddata-sdk/setup.py +++ b/gooddata-sdk/setup.py @@ -13,6 +13,7 @@ "attrs>=21.4.0,<=23.2.0", "cattrs>=22.1.0,<=23.2.3", "brotli==1.1.0", + "requests~=2.31.0", ] setup(