From 8abda4bafdd5526aefa19bc2113afed150658309 Mon Sep 17 00:00:00 2001 From: Vitor Honna Date: Thu, 15 May 2025 16:07:41 -0300 Subject: [PATCH 01/20] Fix typo in update_datasource_data.py --- samples/update_datasource_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/update_datasource_data.py b/samples/update_datasource_data.py index f6bc92022..1b0160b87 100644 --- a/samples/update_datasource_data.py +++ b/samples/update_datasource_data.py @@ -76,7 +76,7 @@ def main(): print("Waiting for job...") # `wait_for_job` will throw if the job isn't executed successfully job = server.jobs.wait_for_job(job) - print("Job finished succesfully") + print("Job finished successfully") if __name__ == "__main__": From 4147c7376ef8bc298a62084efae5df041b67b9a6 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 25 May 2025 08:23:39 -0500 Subject: [PATCH 02/20] fix: repr for auth objects PersonalAccessTokenAuth repr was malformed. Fixing it to be more representative and show the actual class name used in cases where the client user decides to subclass. --- tableauserverclient/models/tableau_auth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 7d7981433..82bebe385 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -87,7 +87,7 @@ def __repr__(self): uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" else: uid = "" - return f"" + return f"<{self.__class__.__qualname__} username={self.username} password=redacted (site={self.site_id}{uid})>" # A Tableau-generated Personal Access Token @@ -155,8 +155,8 @@ def __repr__(self): else: uid = "" return ( - f"" + f"<{self.__class__.__qualname__}(name={self.token_name} token={self.personal_access_token[:2]}..." + f"site={self.site_id}{uid}) >" ) From 88985fe979a6b3968c76a0e6c16477d3b0376c9f Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Mon, 14 Jul 2025 21:07:55 -0700 Subject: [PATCH 03/20] Updated TSC with new API's --- samples/update_connection_auth.py | 62 +++++++++++++++++ samples/update_connections_auth.py | 65 ++++++++++++++++++ tableauserverclient/models/connection_item.py | 12 +++- .../server/endpoint/datasources_endpoint.py | 66 +++++++++++++++++++ .../server/endpoint/workbooks_endpoint.py | 66 +++++++++++++++++++ test/assets/datasource_connections_update.xml | 21 ++++++ test/assets/workbook_update_connections.xml | 21 ++++++ test/test_datasource.py | 41 ++++++++++++ test/test_workbook.py | 36 +++++++++- 9 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 samples/update_connection_auth.py create mode 100644 samples/update_connections_auth.py create mode 100644 test/assets/datasource_connections_update.xml create mode 100644 test/assets/workbook_update_connections.xml diff --git a/samples/update_connection_auth.py b/samples/update_connection_auth.py new file mode 100644 index 000000000..c5ccd54d6 --- /dev/null +++ b/samples/update_connection_auth.py @@ -0,0 +1,62 @@ +import argparse +import logging +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description="Update a single connection on a datasource or workbook to embed credentials") + + # Common options + parser.add_argument("--server", "-s", help="Server address", required=True) + parser.add_argument("--site", "-S", help="Site name", required=True) + parser.add_argument("--token-name", "-p", help="Personal access token name", required=True) + parser.add_argument("--token-value", "-v", help="Personal access token value", required=True) + parser.add_argument( + "--logging-level", "-l", + choices=["debug", "info", "error"], + default="error", + help="Logging level (default: error)", + ) + + # Resource and connection details + parser.add_argument("resource_type", choices=["workbook", "datasource"]) + parser.add_argument("resource_id", help="Workbook or datasource ID") + parser.add_argument("connection_id", help="Connection ID to update") + parser.add_argument("datasource_username", help="Username to set for the connection") + parser.add_argument("datasource_password", help="Password to set for the connection") + parser.add_argument("authentication_type", help="Authentication type") + + args = parser.parse_args() + + # Logging setup + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) + + with server.auth.sign_in(tableau_auth): + endpoint = { + "workbook": server.workbooks, + "datasource": server.datasources + }.get(args.resource_type) + + update_function = endpoint.update_connection + resource = endpoint.get_by_id(args.resource_id) + endpoint.populate_connections(resource) + + connections = [conn for conn in resource.connections if conn.id == args.connection_id] + assert len(connections) == 1, f"Connection ID '{args.connection_id}' not found." + + connection = connections[0] + connection.username = args.datasource_username + connection.password = args.datasource_password + connection.authentication_type = args.authentication_type + connection.embed_password = True + + updated_connection = update_function(resource, connection) + print(f"Updated connection: {updated_connection.__dict__}") + + +if __name__ == "__main__": + main() diff --git a/samples/update_connections_auth.py b/samples/update_connections_auth.py new file mode 100644 index 000000000..563ca898e --- /dev/null +++ b/samples/update_connections_auth.py @@ -0,0 +1,65 @@ +import argparse +import logging +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description="Bulk update all workbook or datasource connections") + + # Common options + parser.add_argument("--server", "-s", help="Server address", required=True) + parser.add_argument("--site", "-S", help="Site name", required=True) + parser.add_argument("--username", "-p", help="Personal access token name", required=True) + parser.add_argument("--password", "-v", help="Personal access token value", required=True) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="Logging level (default: error)", + ) + + # Resource-specific + parser.add_argument("resource_type", choices=["workbook", "datasource"]) + parser.add_argument("resource_id") + parser.add_argument("datasource_username") + parser.add_argument("authentication_type") + parser.add_argument("--datasource_password", default=None, help="Datasource password (optional)") + parser.add_argument("--embed_password", default="true", choices=["true", "false"], help="Embed password (default: true)") + + args = parser.parse_args() + + # Set logging level + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.TableauAuth(args.username, args.password, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) + + with server.auth.sign_in(tableau_auth): + endpoint = { + "workbook": server.workbooks, + "datasource": server.datasources + }.get(args.resource_type) + + resource = endpoint.get_by_id(args.resource_id) + endpoint.populate_connections(resource) + + connection_luids = [conn.id for conn in resource.connections] + embed_password = args.embed_password.lower() == "true" + + # Call unified update_connections method + updated_ids = endpoint.update_connections( + resource, + connection_luids=connection_luids, + authentication_type=args.authentication_type, + username=args.datasource_username, + password=args.datasource_password, + embed_password=embed_password + ) + + print(f"Updated connections on {args.resource_type} {args.resource_id}: {updated_ids}") + + +if __name__ == "__main__": + main() diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 6a8244fb1..5282bb6ad 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -41,6 +41,9 @@ class ConnectionItem: server_port: str The port used for the connection. + auth_type: str + Specifies the type of authentication used by the connection. + connection_credentials: ConnectionCredentials The Connection Credentials object containing authentication details for the connection. Replaces username/password/embed_password when @@ -59,6 +62,7 @@ def __init__(self): self.username: Optional[str] = None self.connection_credentials: Optional[ConnectionCredentials] = None self._query_tagging: Optional[bool] = None + self._auth_type: Optional[str] = None @property def datasource_id(self) -> Optional[str]: @@ -80,6 +84,10 @@ def connection_type(self) -> Optional[str]: def query_tagging(self) -> Optional[bool]: return self._query_tagging + @property + def auth_type(self) -> Optional[str]: + return self._auth_type + @query_tagging.setter @property_is_boolean def query_tagging(self, value: Optional[bool]): @@ -92,7 +100,7 @@ def query_tagging(self, value: Optional[bool]): self._query_tagging = value def __repr__(self): - return "".format( + return "".format( **self.__dict__ ) @@ -112,6 +120,7 @@ def from_response(cls, resp, ns) -> list["ConnectionItem"]: connection_item._query_tagging = ( string_to_bool(s) if (s := connection_xml.get("queryTagging", None)) else None ) + connection_item._auth_type = connection_xml.get("authenticationType", None) datasource_elem = connection_xml.find(".//t:datasource", namespaces=ns) if datasource_elem is not None: connection_item._datasource_id = datasource_elem.get("id", None) @@ -139,6 +148,7 @@ def from_xml_element(cls, parsed_response, ns) -> list["ConnectionItem"]: connection_item.server_address = connection_xml.get("serverAddress", None) connection_item.server_port = connection_xml.get("serverPort", None) + connection_item._auth_type = connection_xml.get("authenticationType", None) connection_credentials = connection_xml.find(".//t:connectionCredentials", namespaces=ns) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 168446974..0f489a18a 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -319,6 +319,72 @@ def update_connection( logger.info(f"Updated datasource item (ID: {datasource_item.id} & connection item {connection_item.id}") return connection + @api(version="3.26") + def update_connections( + self, datasource_item: DatasourceItem, connection_luids: list[str], authentication_type: str, username: Optional[str] = None, password: Optional[str] = None, embed_password: Optional[bool] = None + ) -> list[str]: + """ + Bulk updates one or more datasource connections by LUID. + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item containing the connections. + + connection_luids : list of str + The connection LUIDs to update. + + authentication_type : str + The authentication type to use (e.g., 'auth-keypair'). + + username : str, optional + The username to set. + + password : str, optional + The password or secret to set. + + embed_password : bool, optional + Whether to embed the password. + + Returns + ------- + list of str + The connection LUIDs that were updated. + """ + from xml.etree.ElementTree import Element, SubElement, tostring + + url = f"{self.baseurl}/{datasource_item.id}/connections" + print("Method URL:", url) + + ts_request = Element("tsRequest") + + # + conn_luids_elem = SubElement(ts_request, "connectionLuids") + for luid in connection_luids: + SubElement(conn_luids_elem, "connectionLuid").text = luid + + # + connection_elem = SubElement(ts_request, "connection") + connection_elem.set("authenticationType", authentication_type) + + if username: + connection_elem.set("userName", username) + + if password: + connection_elem.set("password", password) + + if embed_password is not None: + connection_elem.set("embedPassword", str(embed_password).lower()) + + request_body = tostring(ts_request) + + response = self.put_request(url, request_body) + + logger.info( + f"Updated connections for datasource {datasource_item.id}: {', '.join(connection_luids)}" + ) + return connection_luids + @api(version="2.8") def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> JobItem: """ diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index bf4088b9f..d7a32027b 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -325,6 +325,72 @@ def update_connection(self, workbook_item: WorkbookItem, connection_item: Connec logger.info(f"Updated workbook item (ID: {workbook_item.id} & connection item {connection_item.id})") return connection + # Update workbook_connections + @api(version="3.26") + def update_connections(self, workbook_item: WorkbookItem, connection_luids: list[str], authentication_type: str, username: Optional[str] = None, password: Optional[str] = None, embed_password: Optional[bool] = None + ) -> list[str]: + """ + Bulk updates one or more workbook connections by LUID, including authenticationType, username, password, and embedPassword. + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item containing the connections. + + connection_luids : list of str + The connection LUIDs to update. + + authentication_type : str + The authentication type to use (e.g., 'AD Service Principal'). + + username : str, optional + The username to set (e.g., client ID for keypair auth). + + password : str, optional + The password or secret to set. + + embed_password : bool, optional + Whether to embed the password. + + Returns + ------- + list of str + The connection LUIDs that were updated. + """ + from xml.etree.ElementTree import Element, SubElement, tostring + + url = f"{self.baseurl}/{workbook_item.id}/connections" + + ts_request = Element("tsRequest") + + # + conn_luids_elem = SubElement(ts_request, "connectionLuids") + for luid in connection_luids: + SubElement(conn_luids_elem, "connectionLuid").text = luid + + # + connection_elem = SubElement(ts_request, "connection") + connection_elem.set("authenticationType", authentication_type) + + if username: + connection_elem.set("userName", username) + + if password: + connection_elem.set("password", password) + + if embed_password is not None: + connection_elem.set("embedPassword", str(embed_password).lower()) + + request_body = tostring(ts_request) + + # Send request + response = self.put_request(url, request_body) + + logger.info( + f"Updated connections for workbook {workbook_item.id}: {', '.join(connection_luids)}" + ) + return connection_luids + # Download workbook contents with option of passing in filepath @api(version="2.0") @parameter_added_in(no_extract="2.5") diff --git a/test/assets/datasource_connections_update.xml b/test/assets/datasource_connections_update.xml new file mode 100644 index 000000000..5cc8ac001 --- /dev/null +++ b/test/assets/datasource_connections_update.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/test/assets/workbook_update_connections.xml b/test/assets/workbook_update_connections.xml new file mode 100644 index 000000000..1e9b3342e --- /dev/null +++ b/test/assets/workbook_update_connections.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index a604ba8b0..05cbfff5d 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -30,6 +30,7 @@ UPDATE_XML = "datasource_update.xml" UPDATE_HYPER_DATA_XML = "datasource_data_update.xml" UPDATE_CONNECTION_XML = "datasource_connection_update.xml" +UPDATE_CONNECTIONS_XML = "datasource_connections_update.xml" class DatasourceTests(unittest.TestCase): @@ -217,6 +218,46 @@ def test_update_connection(self) -> None: self.assertEqual("9876", new_connection.server_port) self.assertEqual("foo", new_connection.username) + def test_update_connections(self) -> None: + populate_xml, response_xml = read_xml_assets( + POPULATE_CONNECTIONS_XML, + UPDATE_CONNECTIONS_XML + ) + + with requests_mock.Mocker() as m: + + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + connection_luids = [ + "be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", + "a1b2c3d4-e5f6-7a8b-9c0d-123456789abc" + ] + + datasource = TSC.DatasourceItem(datasource_id) + datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + self.server.version = "3.26" + + url = f"{self.server.baseurl}/{datasource.id}/connections" + m.get("http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", text=populate_xml) + m.put("http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", text=response_xml) + + + + print("BASEURL:", self.server.baseurl) + print("Calling PUT on:", f"{self.server.baseurl}/{datasource.id}/connections") + + updated_luids = self.server.datasources.update_connections( + datasource_item=datasource, + connection_luids=connection_luids, + authentication_type="auth-keypair", + username="testuser", + password="testpass", + embed_password=True + ) + + self.assertEqual(updated_luids, connection_luids) + + def test_populate_permissions(self) -> None: with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: response_xml = f.read().decode("utf-8") diff --git a/test/test_workbook.py b/test/test_workbook.py index 84afd7fcb..ff6f423f1 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -14,7 +14,7 @@ from tableauserverclient.models import UserItem, GroupItem, PermissionsRule from tableauserverclient.server.endpoint.exceptions import InternalServerError, UnsupportedAttributeError from tableauserverclient.server.request_factory import RequestFactory -from ._utils import asset +from ._utils import read_xml_asset, read_xml_assets, asset TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -39,6 +39,7 @@ REVISION_XML = os.path.join(TEST_ASSET_DIR, "workbook_revision.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_update.xml") UPDATE_PERMISSIONS = os.path.join(TEST_ASSET_DIR, "workbook_update_permissions.xml") +UPDATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_connections.xml") class WorkbookTests(unittest.TestCase): @@ -980,6 +981,39 @@ def test_odata_connection(self) -> None: assert xml_connection is not None self.assertEqual(xml_connection.get("serverAddress"), url) + def test_update_workbook_connections(self) -> None: + populate_xml, response_xml = read_xml_assets( + POPULATE_CONNECTIONS_XML, + UPDATE_CONNECTIONS_XML + ) + + + with requests_mock.Mocker() as m: + workbook_id = "1a2b3c4d-5e6f-7a8b-9c0d-112233445566" + connection_luids = [ + "abc12345-def6-7890-gh12-ijklmnopqrst", + "1234abcd-5678-efgh-ijkl-0987654321mn" + ] + + workbook = TSC.WorkbookItem(workbook_id) + workbook._id = workbook_id + self.server.version = "3.26" + url = f"{self.server.baseurl}/{workbook_id}/connections" + m.get("http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks/1a2b3c4d-5e6f-7a8b-9c0d-112233445566/connections", text=populate_xml) + m.put("http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks/1a2b3c4d-5e6f-7a8b-9c0d-112233445566/connections", text=response_xml) + + + updated_luids = self.server.workbooks.update_connections( + workbook_item=workbook, + connection_luids=connection_luids, + authentication_type="AD Service Principal", + username="svc-client", + password="secret-token", + embed_password=True + ) + + self.assertEqual(updated_luids, connection_luids) + def test_get_workbook_all_fields(self) -> None: self.server.version = "3.21" baseurl = self.server.workbooks.baseurl From 746b34502df44ff62b11089003938d4fee5cf8d2 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 19 Jul 2025 21:57:46 -0500 Subject: [PATCH 04/20] chore: refactor XML payload into RequestFactory Also correct the type hints to clarify that it accepts any Iterable. --- tableauserverclient/models/connection_item.py | 12 ++- .../server/endpoint/datasources_endpoint.py | 48 ++++------ .../server/endpoint/workbooks_endpoint.py | 94 +++++++++---------- tableauserverclient/server/request_factory.py | 52 ++++++++++ test/test_datasource.py | 25 +++-- test/test_workbook.py | 24 +++-- 6 files changed, 142 insertions(+), 113 deletions(-) diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 5282bb6ad..3e8c6d290 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -84,10 +84,6 @@ def connection_type(self) -> Optional[str]: def query_tagging(self) -> Optional[bool]: return self._query_tagging - @property - def auth_type(self) -> Optional[str]: - return self._auth_type - @query_tagging.setter @property_is_boolean def query_tagging(self, value: Optional[bool]): @@ -99,6 +95,14 @@ def query_tagging(self, value: Optional[bool]): return self._query_tagging = value + @property + def auth_type(self) -> Optional[str]: + return self._auth_type + + @auth_type.setter + def auth_type(self, value: Optional[str]): + self._auth_type = value + def __repr__(self): return "".format( **self.__dict__ diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 0f489a18a..7494a4052 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -321,8 +321,14 @@ def update_connection( @api(version="3.26") def update_connections( - self, datasource_item: DatasourceItem, connection_luids: list[str], authentication_type: str, username: Optional[str] = None, password: Optional[str] = None, embed_password: Optional[bool] = None - ) -> list[str]: + self, + datasource_item: DatasourceItem, + connection_luids: Iterable[str], + authentication_type: str, + username: Optional[str] = None, + password: Optional[str] = None, + embed_password: Optional[bool] = None, + ) -> Iterable[str]: """ Bulk updates one or more datasource connections by LUID. @@ -331,7 +337,7 @@ def update_connections( datasource_item : DatasourceItem The datasource item containing the connections. - connection_luids : list of str + connection_luids : Iterable of str The connection LUIDs to update. authentication_type : str @@ -348,41 +354,23 @@ def update_connections( Returns ------- - list of str + Iterable of str The connection LUIDs that were updated. """ - from xml.etree.ElementTree import Element, SubElement, tostring url = f"{self.baseurl}/{datasource_item.id}/connections" print("Method URL:", url) - ts_request = Element("tsRequest") - - # - conn_luids_elem = SubElement(ts_request, "connectionLuids") - for luid in connection_luids: - SubElement(conn_luids_elem, "connectionLuid").text = luid - - # - connection_elem = SubElement(ts_request, "connection") - connection_elem.set("authenticationType", authentication_type) - - if username: - connection_elem.set("userName", username) - - if password: - connection_elem.set("password", password) - - if embed_password is not None: - connection_elem.set("embedPassword", str(embed_password).lower()) - - request_body = tostring(ts_request) - + request_body = RequestFactory.Datasource.update_connections_req( + connection_luids=connection_luids, + authentication_type=authentication_type, + username=username, + password=password, + embed_password=embed_password, + ) response = self.put_request(url, request_body) - logger.info( - f"Updated connections for datasource {datasource_item.id}: {', '.join(connection_luids)}" - ) + logger.info(f"Updated connections for datasource {datasource_item.id}: {', '.join(connection_luids)}") return connection_luids @api(version="2.8") diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index d7a32027b..9afe04880 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -327,69 +327,59 @@ def update_connection(self, workbook_item: WorkbookItem, connection_item: Connec # Update workbook_connections @api(version="3.26") - def update_connections(self, workbook_item: WorkbookItem, connection_luids: list[str], authentication_type: str, username: Optional[str] = None, password: Optional[str] = None, embed_password: Optional[bool] = None - ) -> list[str]: - """ - Bulk updates one or more workbook connections by LUID, including authenticationType, username, password, and embedPassword. - - Parameters - ---------- - workbook_item : WorkbookItem - The workbook item containing the connections. - - connection_luids : list of str - The connection LUIDs to update. - - authentication_type : str - The authentication type to use (e.g., 'AD Service Principal'). - - username : str, optional - The username to set (e.g., client ID for keypair auth). - - password : str, optional - The password or secret to set. - - embed_password : bool, optional - Whether to embed the password. + def update_connections( + self, + workbook_item: WorkbookItem, + connection_luids: Iterable[str], + authentication_type: str, + username: Optional[str] = None, + password: Optional[str] = None, + embed_password: Optional[bool] = None, + ) -> Iterable[str]: + """ + Bulk updates one or more workbook connections by LUID, including authenticationType, username, password, and embedPassword. - Returns - ------- - list of str - The connection LUIDs that were updated. - """ - from xml.etree.ElementTree import Element, SubElement, tostring + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item containing the connections. - url = f"{self.baseurl}/{workbook_item.id}/connections" + connection_luids : Iterable of str + The connection LUIDs to update. - ts_request = Element("tsRequest") + authentication_type : str + The authentication type to use (e.g., 'AD Service Principal'). - # - conn_luids_elem = SubElement(ts_request, "connectionLuids") - for luid in connection_luids: - SubElement(conn_luids_elem, "connectionLuid").text = luid + username : str, optional + The username to set (e.g., client ID for keypair auth). - # - connection_elem = SubElement(ts_request, "connection") - connection_elem.set("authenticationType", authentication_type) + password : str, optional + The password or secret to set. - if username: - connection_elem.set("userName", username) + embed_password : bool, optional + Whether to embed the password. - if password: - connection_elem.set("password", password) + Returns + ------- + Iterable of str + The connection LUIDs that were updated. + """ - if embed_password is not None: - connection_elem.set("embedPassword", str(embed_password).lower()) + url = f"{self.baseurl}/{workbook_item.id}/connections" - request_body = tostring(ts_request) + request_body = RequestFactory.Workbook.update_connections_req( + connection_luids, + authentication_type, + username=username, + password=password, + embed_password=embed_password, + ) - # Send request - response = self.put_request(url, request_body) + # Send request + response = self.put_request(url, request_body) - logger.info( - f"Updated connections for workbook {workbook_item.id}: {', '.join(connection_luids)}" - ) - return connection_luids + logger.info(f"Updated connections for workbook {workbook_item.id}: {', '.join(connection_luids)}") + return connection_luids # Download workbook contents with option of passing in filepath @api(version="2.0") diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index c898004f7..45da66054 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -244,6 +244,32 @@ def publish_req_chunked(self, datasource_item, connection_credentials=None, conn parts = {"request_payload": ("", xml_request, "text/xml")} return _add_multipart(parts) + @_tsrequest_wrapped + def update_connections_req( + self, + element: ET.Element, + connection_luids: Iterable[str], + authentication_type: str, + username: Optional[str] = None, + password: Optional[str] = None, + embed_password: Optional[bool] = None, + ): + conn_luids_elem = ET.SubElement(element, "connectionLUIDs") + for luid in connection_luids: + ET.SubElement(conn_luids_elem, "connectionLUID").text = luid + + connection_elem = ET.SubElement(element, "connection") + connection_elem.set("authenticationType", authentication_type) + + if username is not None: + connection_elem.set("userName", username) + + if password is not None: + connection_elem.set("password", password) + + if embed_password is not None: + connection_elem.set("embedPassword", str(embed_password).lower()) + class DQWRequest: def add_req(self, dqw_item): @@ -1092,6 +1118,32 @@ def embedded_extract_req( if (id_ := datasource_item.id) is not None: datasource_element.attrib["id"] = id_ + @_tsrequest_wrapped + def update_connections_req( + self, + element: ET.Element, + connection_luids: Iterable[str], + authentication_type: str, + username: Optional[str] = None, + password: Optional[str] = None, + embed_password: Optional[bool] = None, + ): + conn_luids_elem = ET.SubElement(element, "connectionLUIDs") + for luid in connection_luids: + ET.SubElement(conn_luids_elem, "connectionLUID").text = luid + + connection_elem = ET.SubElement(element, "connection") + connection_elem.set("authenticationType", authentication_type) + + if username is not None: + connection_elem.set("userName", username) + + if password is not None: + connection_elem.set("password", password) + + if embed_password is not None: + connection_elem.set("embedPassword", str(embed_password).lower()) + class Connection: @_tsrequest_wrapped diff --git a/test/test_datasource.py b/test/test_datasource.py index 05cbfff5d..a0953aafa 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -219,18 +219,12 @@ def test_update_connection(self) -> None: self.assertEqual("foo", new_connection.username) def test_update_connections(self) -> None: - populate_xml, response_xml = read_xml_assets( - POPULATE_CONNECTIONS_XML, - UPDATE_CONNECTIONS_XML - ) + populate_xml, response_xml = read_xml_assets(POPULATE_CONNECTIONS_XML, UPDATE_CONNECTIONS_XML) with requests_mock.Mocker() as m: datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - connection_luids = [ - "be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", - "a1b2c3d4-e5f6-7a8b-9c0d-123456789abc" - ] + connection_luids = ["be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", "a1b2c3d4-e5f6-7a8b-9c0d-123456789abc"] datasource = TSC.DatasourceItem(datasource_id) datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" @@ -238,10 +232,14 @@ def test_update_connections(self) -> None: self.server.version = "3.26" url = f"{self.server.baseurl}/{datasource.id}/connections" - m.get("http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", text=populate_xml) - m.put("http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", text=response_xml) - - + m.get( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", + text=populate_xml, + ) + m.put( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", + text=response_xml, + ) print("BASEURL:", self.server.baseurl) print("Calling PUT on:", f"{self.server.baseurl}/{datasource.id}/connections") @@ -252,12 +250,11 @@ def test_update_connections(self) -> None: authentication_type="auth-keypair", username="testuser", password="testpass", - embed_password=True + embed_password=True, ) self.assertEqual(updated_luids, connection_luids) - def test_populate_permissions(self) -> None: with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: response_xml = f.read().decode("utf-8") diff --git a/test/test_workbook.py b/test/test_workbook.py index ff6f423f1..cfcf70fec 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -982,26 +982,24 @@ def test_odata_connection(self) -> None: self.assertEqual(xml_connection.get("serverAddress"), url) def test_update_workbook_connections(self) -> None: - populate_xml, response_xml = read_xml_assets( - POPULATE_CONNECTIONS_XML, - UPDATE_CONNECTIONS_XML - ) - + populate_xml, response_xml = read_xml_assets(POPULATE_CONNECTIONS_XML, UPDATE_CONNECTIONS_XML) with requests_mock.Mocker() as m: workbook_id = "1a2b3c4d-5e6f-7a8b-9c0d-112233445566" - connection_luids = [ - "abc12345-def6-7890-gh12-ijklmnopqrst", - "1234abcd-5678-efgh-ijkl-0987654321mn" - ] + connection_luids = ["abc12345-def6-7890-gh12-ijklmnopqrst", "1234abcd-5678-efgh-ijkl-0987654321mn"] workbook = TSC.WorkbookItem(workbook_id) workbook._id = workbook_id self.server.version = "3.26" url = f"{self.server.baseurl}/{workbook_id}/connections" - m.get("http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks/1a2b3c4d-5e6f-7a8b-9c0d-112233445566/connections", text=populate_xml) - m.put("http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks/1a2b3c4d-5e6f-7a8b-9c0d-112233445566/connections", text=response_xml) - + m.get( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks/1a2b3c4d-5e6f-7a8b-9c0d-112233445566/connections", + text=populate_xml, + ) + m.put( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks/1a2b3c4d-5e6f-7a8b-9c0d-112233445566/connections", + text=response_xml, + ) updated_luids = self.server.workbooks.update_connections( workbook_item=workbook, @@ -1009,7 +1007,7 @@ def test_update_workbook_connections(self) -> None: authentication_type="AD Service Principal", username="svc-client", password="secret-token", - embed_password=True + embed_password=True, ) self.assertEqual(updated_luids, connection_luids) From 75f5f4cb733bd2ff8190b5475b548d68ffc3202a Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 19 Jul 2025 22:03:54 -0500 Subject: [PATCH 05/20] style: black samples --- samples/update_connection_auth.py | 12 ++++++------ samples/update_connections_auth.py | 11 +++++------ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/samples/update_connection_auth.py b/samples/update_connection_auth.py index c5ccd54d6..661a5e275 100644 --- a/samples/update_connection_auth.py +++ b/samples/update_connection_auth.py @@ -4,7 +4,9 @@ def main(): - parser = argparse.ArgumentParser(description="Update a single connection on a datasource or workbook to embed credentials") + parser = argparse.ArgumentParser( + description="Update a single connection on a datasource or workbook to embed credentials" + ) # Common options parser.add_argument("--server", "-s", help="Server address", required=True) @@ -12,7 +14,8 @@ def main(): parser.add_argument("--token-name", "-p", help="Personal access token name", required=True) parser.add_argument("--token-value", "-v", help="Personal access token value", required=True) parser.add_argument( - "--logging-level", "-l", + "--logging-level", + "-l", choices=["debug", "info", "error"], default="error", help="Logging level (default: error)", @@ -36,10 +39,7 @@ def main(): server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - endpoint = { - "workbook": server.workbooks, - "datasource": server.datasources - }.get(args.resource_type) + endpoint = {"workbook": server.workbooks, "datasource": server.datasources}.get(args.resource_type) update_function = endpoint.update_connection resource = endpoint.get_by_id(args.resource_id) diff --git a/samples/update_connections_auth.py b/samples/update_connections_auth.py index 563ca898e..7aad64a62 100644 --- a/samples/update_connections_auth.py +++ b/samples/update_connections_auth.py @@ -25,7 +25,9 @@ def main(): parser.add_argument("datasource_username") parser.add_argument("authentication_type") parser.add_argument("--datasource_password", default=None, help="Datasource password (optional)") - parser.add_argument("--embed_password", default="true", choices=["true", "false"], help="Embed password (default: true)") + parser.add_argument( + "--embed_password", default="true", choices=["true", "false"], help="Embed password (default: true)" + ) args = parser.parse_args() @@ -37,10 +39,7 @@ def main(): server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - endpoint = { - "workbook": server.workbooks, - "datasource": server.datasources - }.get(args.resource_type) + endpoint = {"workbook": server.workbooks, "datasource": server.datasources}.get(args.resource_type) resource = endpoint.get_by_id(args.resource_id) endpoint.populate_connections(resource) @@ -55,7 +54,7 @@ def main(): authentication_type=args.authentication_type, username=args.datasource_username, password=args.datasource_password, - embed_password=embed_password + embed_password=embed_password, ) print(f"Updated connections on {args.resource_type} {args.resource_id}: {updated_ids}") From 1fb57d5b2530ce236b0c26bf1be6b2757ebd3f45 Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Mon, 21 Jul 2025 11:01:35 -0700 Subject: [PATCH 06/20] Updated token name, value --- samples/update_connections_auth.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/samples/update_connections_auth.py b/samples/update_connections_auth.py index 7aad64a62..d0acffcd0 100644 --- a/samples/update_connections_auth.py +++ b/samples/update_connections_auth.py @@ -9,8 +9,8 @@ def main(): # Common options parser.add_argument("--server", "-s", help="Server address", required=True) parser.add_argument("--site", "-S", help="Site name", required=True) - parser.add_argument("--username", "-p", help="Personal access token name", required=True) - parser.add_argument("--password", "-v", help="Personal access token value", required=True) + parser.add_argument("--token-name", "-p", help="Personal access token name", required=True) + parser.add_argument("--token-value", "-v", help="Personal access token value", required=True) parser.add_argument( "--logging-level", "-l", @@ -35,7 +35,7 @@ def main(): logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) - tableau_auth = TSC.TableauAuth(args.username, args.password, site_id=args.site) + tableau_auth = TSC.TableauAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): From 9c2bec3cde4712914599bb43aeb0c7062976c2d2 Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Mon, 21 Jul 2025 11:06:25 -0700 Subject: [PATCH 07/20] Clean up --- tableauserverclient/server/endpoint/datasources_endpoint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 7494a4052..55f8ad1d0 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -359,7 +359,6 @@ def update_connections( """ url = f"{self.baseurl}/{datasource_item.id}/connections" - print("Method URL:", url) request_body = RequestFactory.Datasource.update_connections_req( connection_luids=connection_luids, From a40d774c328db02f5547d9637f39c2b37790944e Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Mon, 21 Jul 2025 13:47:55 -0700 Subject: [PATCH 08/20] Added response parsing --- samples/update_connections_auth.py | 4 ++-- .../server/endpoint/datasources_endpoint.py | 8 +++++--- tableauserverclient/server/endpoint/workbooks_endpoint.py | 8 +++++--- test/assets/datasource_connections_update.xml | 6 +++--- test/assets/workbook_update_connections.xml | 6 +++--- test/test_datasource.py | 5 +++-- test/test_workbook.py | 5 +++-- 7 files changed, 24 insertions(+), 18 deletions(-) diff --git a/samples/update_connections_auth.py b/samples/update_connections_auth.py index d0acffcd0..6ae27e333 100644 --- a/samples/update_connections_auth.py +++ b/samples/update_connections_auth.py @@ -48,7 +48,7 @@ def main(): embed_password = args.embed_password.lower() == "true" # Call unified update_connections method - updated_ids = endpoint.update_connections( + connection_items = endpoint.update_connections( resource, connection_luids=connection_luids, authentication_type=args.authentication_type, @@ -57,7 +57,7 @@ def main(): embed_password=embed_password, ) - print(f"Updated connections on {args.resource_type} {args.resource_id}: {updated_ids}") + print(f"Updated connections on {args.resource_type} {args.resource_id}: {connection_items}") if __name__ == "__main__": diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 55f8ad1d0..bf6107dd2 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -367,10 +367,12 @@ def update_connections( password=password, embed_password=embed_password, ) - response = self.put_request(url, request_body) + server_response = self.put_request(url, request_body) + connection_items = list(ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)) + updated_ids = [conn.id for conn in connection_items] - logger.info(f"Updated connections for datasource {datasource_item.id}: {', '.join(connection_luids)}") - return connection_luids + logger.info(f"Updated connections for datasource {datasource_item.id}: {', '.join(updated_ids)}") + return connection_items @api(version="2.8") def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> JobItem: diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 9afe04880..feb4a5dde 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -376,10 +376,12 @@ def update_connections( ) # Send request - response = self.put_request(url, request_body) + server_response = self.put_request(url, request_body) + connection_items = list(ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)) + updated_ids = [conn.id for conn in connection_items] - logger.info(f"Updated connections for workbook {workbook_item.id}: {', '.join(connection_luids)}") - return connection_luids + logger.info(f"Updated connections for workbook {workbook_item.id}: {', '.join(updated_ids)}") + return connection_items # Download workbook contents with option of passing in filepath @api(version="2.0") diff --git a/test/assets/datasource_connections_update.xml b/test/assets/datasource_connections_update.xml index 5cc8ac001..d726aad25 100644 --- a/test/assets/datasource_connections_update.xml +++ b/test/assets/datasource_connections_update.xml @@ -1,7 +1,7 @@ + xsi:schemaLocation="http://tableau.com/api https://help.tableau.com/samples/en-us/rest_api/ts-api_3_25.xsd"> + authenticationType="auth-keypair" /> + authenticationType="auth-keypair" /> diff --git a/test/assets/workbook_update_connections.xml b/test/assets/workbook_update_connections.xml index 1e9b3342e..ce6ca227f 100644 --- a/test/assets/workbook_update_connections.xml +++ b/test/assets/workbook_update_connections.xml @@ -1,7 +1,7 @@ + xsi:schemaLocation="http://tableau.com/api https://help.tableau.com/samples/en-us/rest_api/ts-api_3_25.xsd"> + authenticationType="AD Service Principal" /> + authenticationType="AD Service Principal" /> diff --git a/test/test_datasource.py b/test/test_datasource.py index a0953aafa..5e7e91358 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -244,7 +244,7 @@ def test_update_connections(self) -> None: print("BASEURL:", self.server.baseurl) print("Calling PUT on:", f"{self.server.baseurl}/{datasource.id}/connections") - updated_luids = self.server.datasources.update_connections( + connection_items = self.server.datasources.update_connections( datasource_item=datasource, connection_luids=connection_luids, authentication_type="auth-keypair", @@ -252,8 +252,9 @@ def test_update_connections(self) -> None: password="testpass", embed_password=True, ) + updated_ids = [conn.id for conn in connection_items] - self.assertEqual(updated_luids, connection_luids) + self.assertEqual(updated_ids, connection_luids) def test_populate_permissions(self) -> None: with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: diff --git a/test/test_workbook.py b/test/test_workbook.py index cfcf70fec..f6c494f96 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -1001,7 +1001,7 @@ def test_update_workbook_connections(self) -> None: text=response_xml, ) - updated_luids = self.server.workbooks.update_connections( + connection_items = self.server.workbooks.update_connections( workbook_item=workbook, connection_luids=connection_luids, authentication_type="AD Service Principal", @@ -1009,8 +1009,9 @@ def test_update_workbook_connections(self) -> None: password="secret-token", embed_password=True, ) + updated_ids = [conn.id for conn in connection_items] - self.assertEqual(updated_luids, connection_luids) + self.assertEqual(updated_ids, connection_luids) def test_get_workbook_all_fields(self) -> None: self.server.version = "3.21" From b4075901ec515cb57c4f8580da279b1c585879bf Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Mon, 21 Jul 2025 14:06:06 -0700 Subject: [PATCH 09/20] Fixed issues --- tableauserverclient/server/endpoint/datasources_endpoint.py | 6 +++--- tableauserverclient/server/endpoint/workbooks_endpoint.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index bf6107dd2..ba242c8ec 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -328,7 +328,7 @@ def update_connections( username: Optional[str] = None, password: Optional[str] = None, embed_password: Optional[bool] = None, - ) -> Iterable[str]: + ) -> list[ConnectionItem]: """ Bulk updates one or more datasource connections by LUID. @@ -368,8 +368,8 @@ def update_connections( embed_password=embed_password, ) server_response = self.put_request(url, request_body) - connection_items = list(ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)) - updated_ids = [conn.id for conn in connection_items] + connection_items = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + updated_ids: list[str] = [conn.id for conn in connection_items] logger.info(f"Updated connections for datasource {datasource_item.id}: {', '.join(updated_ids)}") return connection_items diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index feb4a5dde..907d2d99e 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -335,7 +335,7 @@ def update_connections( username: Optional[str] = None, password: Optional[str] = None, embed_password: Optional[bool] = None, - ) -> Iterable[str]: + ) -> list[ConnectionItem]: """ Bulk updates one or more workbook connections by LUID, including authenticationType, username, password, and embedPassword. @@ -377,8 +377,8 @@ def update_connections( # Send request server_response = self.put_request(url, request_body) - connection_items = list(ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)) - updated_ids = [conn.id for conn in connection_items] + connection_items = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + updated_ids: list[str] = [conn.id for conn in connection_items] logger.info(f"Updated connections for workbook {workbook_item.id}: {', '.join(updated_ids)}") return connection_items From d8922ed3ee6b4d49c593711185854867403c8306 Mon Sep 17 00:00:00 2001 From: Vineeth Sai Surya Chavatapalli Date: Mon, 21 Jul 2025 16:00:38 -0700 Subject: [PATCH 10/20] New APIs: Update multiple connections in a single workbook/datasource (#1638) Update multiple connections in a single workbook - Takes multiple connection, authType and credentials as input Update multiple connections in a single datasource - Takes multiple connection, authType and credentials as input --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- samples/update_connection_auth.py | 62 ++++++++++++++++++ samples/update_connections_auth.py | 64 +++++++++++++++++++ tableauserverclient/models/connection_item.py | 16 ++++- .../server/endpoint/datasources_endpoint.py | 55 ++++++++++++++++ .../server/endpoint/workbooks_endpoint.py | 58 +++++++++++++++++ tableauserverclient/server/request_factory.py | 52 +++++++++++++++ test/assets/datasource_connections_update.xml | 21 ++++++ test/assets/workbook_update_connections.xml | 21 ++++++ test/test_datasource.py | 39 +++++++++++ test/test_workbook.py | 35 +++++++++- 10 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 samples/update_connection_auth.py create mode 100644 samples/update_connections_auth.py create mode 100644 test/assets/datasource_connections_update.xml create mode 100644 test/assets/workbook_update_connections.xml diff --git a/samples/update_connection_auth.py b/samples/update_connection_auth.py new file mode 100644 index 000000000..661a5e275 --- /dev/null +++ b/samples/update_connection_auth.py @@ -0,0 +1,62 @@ +import argparse +import logging +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser( + description="Update a single connection on a datasource or workbook to embed credentials" + ) + + # Common options + parser.add_argument("--server", "-s", help="Server address", required=True) + parser.add_argument("--site", "-S", help="Site name", required=True) + parser.add_argument("--token-name", "-p", help="Personal access token name", required=True) + parser.add_argument("--token-value", "-v", help="Personal access token value", required=True) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="Logging level (default: error)", + ) + + # Resource and connection details + parser.add_argument("resource_type", choices=["workbook", "datasource"]) + parser.add_argument("resource_id", help="Workbook or datasource ID") + parser.add_argument("connection_id", help="Connection ID to update") + parser.add_argument("datasource_username", help="Username to set for the connection") + parser.add_argument("datasource_password", help="Password to set for the connection") + parser.add_argument("authentication_type", help="Authentication type") + + args = parser.parse_args() + + # Logging setup + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) + + with server.auth.sign_in(tableau_auth): + endpoint = {"workbook": server.workbooks, "datasource": server.datasources}.get(args.resource_type) + + update_function = endpoint.update_connection + resource = endpoint.get_by_id(args.resource_id) + endpoint.populate_connections(resource) + + connections = [conn for conn in resource.connections if conn.id == args.connection_id] + assert len(connections) == 1, f"Connection ID '{args.connection_id}' not found." + + connection = connections[0] + connection.username = args.datasource_username + connection.password = args.datasource_password + connection.authentication_type = args.authentication_type + connection.embed_password = True + + updated_connection = update_function(resource, connection) + print(f"Updated connection: {updated_connection.__dict__}") + + +if __name__ == "__main__": + main() diff --git a/samples/update_connections_auth.py b/samples/update_connections_auth.py new file mode 100644 index 000000000..6ae27e333 --- /dev/null +++ b/samples/update_connections_auth.py @@ -0,0 +1,64 @@ +import argparse +import logging +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description="Bulk update all workbook or datasource connections") + + # Common options + parser.add_argument("--server", "-s", help="Server address", required=True) + parser.add_argument("--site", "-S", help="Site name", required=True) + parser.add_argument("--token-name", "-p", help="Personal access token name", required=True) + parser.add_argument("--token-value", "-v", help="Personal access token value", required=True) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="Logging level (default: error)", + ) + + # Resource-specific + parser.add_argument("resource_type", choices=["workbook", "datasource"]) + parser.add_argument("resource_id") + parser.add_argument("datasource_username") + parser.add_argument("authentication_type") + parser.add_argument("--datasource_password", default=None, help="Datasource password (optional)") + parser.add_argument( + "--embed_password", default="true", choices=["true", "false"], help="Embed password (default: true)" + ) + + args = parser.parse_args() + + # Set logging level + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.TableauAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) + + with server.auth.sign_in(tableau_auth): + endpoint = {"workbook": server.workbooks, "datasource": server.datasources}.get(args.resource_type) + + resource = endpoint.get_by_id(args.resource_id) + endpoint.populate_connections(resource) + + connection_luids = [conn.id for conn in resource.connections] + embed_password = args.embed_password.lower() == "true" + + # Call unified update_connections method + connection_items = endpoint.update_connections( + resource, + connection_luids=connection_luids, + authentication_type=args.authentication_type, + username=args.datasource_username, + password=args.datasource_password, + embed_password=embed_password, + ) + + print(f"Updated connections on {args.resource_type} {args.resource_id}: {connection_items}") + + +if __name__ == "__main__": + main() diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 6a8244fb1..3e8c6d290 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -41,6 +41,9 @@ class ConnectionItem: server_port: str The port used for the connection. + auth_type: str + Specifies the type of authentication used by the connection. + connection_credentials: ConnectionCredentials The Connection Credentials object containing authentication details for the connection. Replaces username/password/embed_password when @@ -59,6 +62,7 @@ def __init__(self): self.username: Optional[str] = None self.connection_credentials: Optional[ConnectionCredentials] = None self._query_tagging: Optional[bool] = None + self._auth_type: Optional[str] = None @property def datasource_id(self) -> Optional[str]: @@ -91,8 +95,16 @@ def query_tagging(self, value: Optional[bool]): return self._query_tagging = value + @property + def auth_type(self) -> Optional[str]: + return self._auth_type + + @auth_type.setter + def auth_type(self, value: Optional[str]): + self._auth_type = value + def __repr__(self): - return "".format( + return "".format( **self.__dict__ ) @@ -112,6 +124,7 @@ def from_response(cls, resp, ns) -> list["ConnectionItem"]: connection_item._query_tagging = ( string_to_bool(s) if (s := connection_xml.get("queryTagging", None)) else None ) + connection_item._auth_type = connection_xml.get("authenticationType", None) datasource_elem = connection_xml.find(".//t:datasource", namespaces=ns) if datasource_elem is not None: connection_item._datasource_id = datasource_elem.get("id", None) @@ -139,6 +152,7 @@ def from_xml_element(cls, parsed_response, ns) -> list["ConnectionItem"]: connection_item.server_address = connection_xml.get("serverAddress", None) connection_item.server_port = connection_xml.get("serverPort", None) + connection_item._auth_type = connection_xml.get("authenticationType", None) connection_credentials = connection_xml.find(".//t:connectionCredentials", namespaces=ns) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 168446974..ba242c8ec 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -319,6 +319,61 @@ def update_connection( logger.info(f"Updated datasource item (ID: {datasource_item.id} & connection item {connection_item.id}") return connection + @api(version="3.26") + def update_connections( + self, + datasource_item: DatasourceItem, + connection_luids: Iterable[str], + authentication_type: str, + username: Optional[str] = None, + password: Optional[str] = None, + embed_password: Optional[bool] = None, + ) -> list[ConnectionItem]: + """ + Bulk updates one or more datasource connections by LUID. + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item containing the connections. + + connection_luids : Iterable of str + The connection LUIDs to update. + + authentication_type : str + The authentication type to use (e.g., 'auth-keypair'). + + username : str, optional + The username to set. + + password : str, optional + The password or secret to set. + + embed_password : bool, optional + Whether to embed the password. + + Returns + ------- + Iterable of str + The connection LUIDs that were updated. + """ + + url = f"{self.baseurl}/{datasource_item.id}/connections" + + request_body = RequestFactory.Datasource.update_connections_req( + connection_luids=connection_luids, + authentication_type=authentication_type, + username=username, + password=password, + embed_password=embed_password, + ) + server_response = self.put_request(url, request_body) + connection_items = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + updated_ids: list[str] = [conn.id for conn in connection_items] + + logger.info(f"Updated connections for datasource {datasource_item.id}: {', '.join(updated_ids)}") + return connection_items + @api(version="2.8") def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> JobItem: """ diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index bf4088b9f..907d2d99e 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -325,6 +325,64 @@ def update_connection(self, workbook_item: WorkbookItem, connection_item: Connec logger.info(f"Updated workbook item (ID: {workbook_item.id} & connection item {connection_item.id})") return connection + # Update workbook_connections + @api(version="3.26") + def update_connections( + self, + workbook_item: WorkbookItem, + connection_luids: Iterable[str], + authentication_type: str, + username: Optional[str] = None, + password: Optional[str] = None, + embed_password: Optional[bool] = None, + ) -> list[ConnectionItem]: + """ + Bulk updates one or more workbook connections by LUID, including authenticationType, username, password, and embedPassword. + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item containing the connections. + + connection_luids : Iterable of str + The connection LUIDs to update. + + authentication_type : str + The authentication type to use (e.g., 'AD Service Principal'). + + username : str, optional + The username to set (e.g., client ID for keypair auth). + + password : str, optional + The password or secret to set. + + embed_password : bool, optional + Whether to embed the password. + + Returns + ------- + Iterable of str + The connection LUIDs that were updated. + """ + + url = f"{self.baseurl}/{workbook_item.id}/connections" + + request_body = RequestFactory.Workbook.update_connections_req( + connection_luids, + authentication_type, + username=username, + password=password, + embed_password=embed_password, + ) + + # Send request + server_response = self.put_request(url, request_body) + connection_items = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + updated_ids: list[str] = [conn.id for conn in connection_items] + + logger.info(f"Updated connections for workbook {workbook_item.id}: {', '.join(updated_ids)}") + return connection_items + # Download workbook contents with option of passing in filepath @api(version="2.0") @parameter_added_in(no_extract="2.5") diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index c898004f7..45da66054 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -244,6 +244,32 @@ def publish_req_chunked(self, datasource_item, connection_credentials=None, conn parts = {"request_payload": ("", xml_request, "text/xml")} return _add_multipart(parts) + @_tsrequest_wrapped + def update_connections_req( + self, + element: ET.Element, + connection_luids: Iterable[str], + authentication_type: str, + username: Optional[str] = None, + password: Optional[str] = None, + embed_password: Optional[bool] = None, + ): + conn_luids_elem = ET.SubElement(element, "connectionLUIDs") + for luid in connection_luids: + ET.SubElement(conn_luids_elem, "connectionLUID").text = luid + + connection_elem = ET.SubElement(element, "connection") + connection_elem.set("authenticationType", authentication_type) + + if username is not None: + connection_elem.set("userName", username) + + if password is not None: + connection_elem.set("password", password) + + if embed_password is not None: + connection_elem.set("embedPassword", str(embed_password).lower()) + class DQWRequest: def add_req(self, dqw_item): @@ -1092,6 +1118,32 @@ def embedded_extract_req( if (id_ := datasource_item.id) is not None: datasource_element.attrib["id"] = id_ + @_tsrequest_wrapped + def update_connections_req( + self, + element: ET.Element, + connection_luids: Iterable[str], + authentication_type: str, + username: Optional[str] = None, + password: Optional[str] = None, + embed_password: Optional[bool] = None, + ): + conn_luids_elem = ET.SubElement(element, "connectionLUIDs") + for luid in connection_luids: + ET.SubElement(conn_luids_elem, "connectionLUID").text = luid + + connection_elem = ET.SubElement(element, "connection") + connection_elem.set("authenticationType", authentication_type) + + if username is not None: + connection_elem.set("userName", username) + + if password is not None: + connection_elem.set("password", password) + + if embed_password is not None: + connection_elem.set("embedPassword", str(embed_password).lower()) + class Connection: @_tsrequest_wrapped diff --git a/test/assets/datasource_connections_update.xml b/test/assets/datasource_connections_update.xml new file mode 100644 index 000000000..d726aad25 --- /dev/null +++ b/test/assets/datasource_connections_update.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/test/assets/workbook_update_connections.xml b/test/assets/workbook_update_connections.xml new file mode 100644 index 000000000..ce6ca227f --- /dev/null +++ b/test/assets/workbook_update_connections.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index a604ba8b0..5e7e91358 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -30,6 +30,7 @@ UPDATE_XML = "datasource_update.xml" UPDATE_HYPER_DATA_XML = "datasource_data_update.xml" UPDATE_CONNECTION_XML = "datasource_connection_update.xml" +UPDATE_CONNECTIONS_XML = "datasource_connections_update.xml" class DatasourceTests(unittest.TestCase): @@ -217,6 +218,44 @@ def test_update_connection(self) -> None: self.assertEqual("9876", new_connection.server_port) self.assertEqual("foo", new_connection.username) + def test_update_connections(self) -> None: + populate_xml, response_xml = read_xml_assets(POPULATE_CONNECTIONS_XML, UPDATE_CONNECTIONS_XML) + + with requests_mock.Mocker() as m: + + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + connection_luids = ["be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", "a1b2c3d4-e5f6-7a8b-9c0d-123456789abc"] + + datasource = TSC.DatasourceItem(datasource_id) + datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + self.server.version = "3.26" + + url = f"{self.server.baseurl}/{datasource.id}/connections" + m.get( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", + text=populate_xml, + ) + m.put( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", + text=response_xml, + ) + + print("BASEURL:", self.server.baseurl) + print("Calling PUT on:", f"{self.server.baseurl}/{datasource.id}/connections") + + connection_items = self.server.datasources.update_connections( + datasource_item=datasource, + connection_luids=connection_luids, + authentication_type="auth-keypair", + username="testuser", + password="testpass", + embed_password=True, + ) + updated_ids = [conn.id for conn in connection_items] + + self.assertEqual(updated_ids, connection_luids) + def test_populate_permissions(self) -> None: with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: response_xml = f.read().decode("utf-8") diff --git a/test/test_workbook.py b/test/test_workbook.py index 84afd7fcb..f6c494f96 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -14,7 +14,7 @@ from tableauserverclient.models import UserItem, GroupItem, PermissionsRule from tableauserverclient.server.endpoint.exceptions import InternalServerError, UnsupportedAttributeError from tableauserverclient.server.request_factory import RequestFactory -from ._utils import asset +from ._utils import read_xml_asset, read_xml_assets, asset TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -39,6 +39,7 @@ REVISION_XML = os.path.join(TEST_ASSET_DIR, "workbook_revision.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_update.xml") UPDATE_PERMISSIONS = os.path.join(TEST_ASSET_DIR, "workbook_update_permissions.xml") +UPDATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_connections.xml") class WorkbookTests(unittest.TestCase): @@ -980,6 +981,38 @@ def test_odata_connection(self) -> None: assert xml_connection is not None self.assertEqual(xml_connection.get("serverAddress"), url) + def test_update_workbook_connections(self) -> None: + populate_xml, response_xml = read_xml_assets(POPULATE_CONNECTIONS_XML, UPDATE_CONNECTIONS_XML) + + with requests_mock.Mocker() as m: + workbook_id = "1a2b3c4d-5e6f-7a8b-9c0d-112233445566" + connection_luids = ["abc12345-def6-7890-gh12-ijklmnopqrst", "1234abcd-5678-efgh-ijkl-0987654321mn"] + + workbook = TSC.WorkbookItem(workbook_id) + workbook._id = workbook_id + self.server.version = "3.26" + url = f"{self.server.baseurl}/{workbook_id}/connections" + m.get( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks/1a2b3c4d-5e6f-7a8b-9c0d-112233445566/connections", + text=populate_xml, + ) + m.put( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks/1a2b3c4d-5e6f-7a8b-9c0d-112233445566/connections", + text=response_xml, + ) + + connection_items = self.server.workbooks.update_connections( + workbook_item=workbook, + connection_luids=connection_luids, + authentication_type="AD Service Principal", + username="svc-client", + password="secret-token", + embed_password=True, + ) + updated_ids = [conn.id for conn in connection_items] + + self.assertEqual(updated_ids, connection_luids) + def test_get_workbook_all_fields(self) -> None: self.server.version = "3.21" baseurl = self.server.workbooks.baseurl From dc92d17682f2a8291bd7fcef683e1863789f932f Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Tue, 22 Jul 2025 16:53:31 -0700 Subject: [PATCH 11/20] Minor fixes to request payloads --- samples/update_connection_auth.py | 2 +- tableauserverclient/server/request_factory.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/samples/update_connection_auth.py b/samples/update_connection_auth.py index 661a5e275..19134e60c 100644 --- a/samples/update_connection_auth.py +++ b/samples/update_connection_auth.py @@ -51,7 +51,7 @@ def main(): connection = connections[0] connection.username = args.datasource_username connection.password = args.datasource_password - connection.authentication_type = args.authentication_type + connection.auth_type = args.authentication_type connection.embed_password = True updated_connection = update_function(resource, connection) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 45da66054..f17d579ab 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1162,6 +1162,8 @@ def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") connection_element.attrib["userName"] = connection_item.username if connection_item.password is not None: connection_element.attrib["password"] = connection_item.password + if connection_item.auth_type is not None: + connection_element.attrib["authenticationType"] = connection_item.auth_type if connection_item.embed_password is not None: connection_element.attrib["embedPassword"] = str(connection_item.embed_password).lower() if connection_item.query_tagging is not None: From a75bb4092649a7764ef8d9cc236747970add414f Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Wed, 23 Jul 2025 10:33:17 -0700 Subject: [PATCH 12/20] Added assertions for test cases --- test/test_datasource.py | 1 + test/test_workbook.py | 1 + 2 files changed, 2 insertions(+) diff --git a/test/test_datasource.py b/test/test_datasource.py index 5e7e91358..1ec535ea1 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -255,6 +255,7 @@ def test_update_connections(self) -> None: updated_ids = [conn.id for conn in connection_items] self.assertEqual(updated_ids, connection_luids) + self.assertEqual("auth-keypair",connection_items[0].auth_type) def test_populate_permissions(self) -> None: with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: diff --git a/test/test_workbook.py b/test/test_workbook.py index f6c494f96..a4242c210 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -1012,6 +1012,7 @@ def test_update_workbook_connections(self) -> None: updated_ids = [conn.id for conn in connection_items] self.assertEqual(updated_ids, connection_luids) + self.assertEqual("AD Service Principal", connection_items[0].auth_type) def test_get_workbook_all_fields(self) -> None: self.server.version = "3.21" From 9a1c675af5f68eabe836df16323d011217f87900 Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Wed, 23 Jul 2025 10:38:10 -0700 Subject: [PATCH 13/20] style fix --- test/test_datasource.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_datasource.py b/test/test_datasource.py index 1ec535ea1..d36ddab75 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -255,7 +255,7 @@ def test_update_connections(self) -> None: updated_ids = [conn.id for conn in connection_items] self.assertEqual(updated_ids, connection_luids) - self.assertEqual("auth-keypair",connection_items[0].auth_type) + self.assertEqual("auth-keypair", connection_items[0].auth_type) def test_populate_permissions(self) -> None: with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: From b90473d49693564542af7629476e7c480f564a2f Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Thu, 24 Jul 2025 11:11:39 -0700 Subject: [PATCH 14/20] Fixed the login method --- samples/update_connections_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/update_connections_auth.py b/samples/update_connections_auth.py index 6ae27e333..f0c8dd852 100644 --- a/samples/update_connections_auth.py +++ b/samples/update_connections_auth.py @@ -35,7 +35,7 @@ def main(): logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) - tableau_auth = TSC.TableauAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): From 755ddeca96a470a0f0afe195def31606e3394def Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 1 Aug 2025 01:50:20 -0500 Subject: [PATCH 15/20] feat: enable toggling attribute capture for a site (#1619) * feat: enable toggling attribute capture for a site According to https://help.tableau.com/current/api/embedding_api/en-us/docs/embedding_api_user_attributes.html#:~:text=For%20security%20purposes%2C%20user%20attributes,a%20site%20admin%20(on%20Tableau setting this site setting to `true` is required to enable use of user attributes with Tableau Server and embedding workflows. * chore: fix mypy error --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/site_item.py | 15 ++++++++ tableauserverclient/server/request_factory.py | 4 ++ test/_utils.py | 14 +++++++ test/test_site.py | 38 +++++++++++++++++++ 4 files changed, 71 insertions(+) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index ab65b97b5..ab32ad09e 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -85,6 +85,9 @@ class SiteItem: state: str Shows the current state of the site (Active or Suspended). + attribute_capture_enabled: Optional[str] + Enables user attributes for all Tableau Server embedding workflows. + """ _user_quota: Optional[int] = None @@ -164,6 +167,7 @@ def __init__( time_zone=None, auto_suspend_refresh_enabled: bool = True, auto_suspend_refresh_inactivity_window: int = 30, + attribute_capture_enabled: Optional[bool] = None, ): self._admin_mode = None self._id: Optional[str] = None @@ -217,6 +221,7 @@ def __init__( self.time_zone = time_zone self.auto_suspend_refresh_enabled = auto_suspend_refresh_enabled self.auto_suspend_refresh_inactivity_window = auto_suspend_refresh_inactivity_window + self.attribute_capture_enabled = attribute_capture_enabled @property def admin_mode(self) -> Optional[str]: @@ -720,6 +725,7 @@ def _parse_common_tags(self, site_xml, ns): time_zone, auto_suspend_refresh_enabled, auto_suspend_refresh_inactivity_window, + attribute_capture_enabled, ) = self._parse_element(site_xml, ns) self._set_values( @@ -774,6 +780,7 @@ def _parse_common_tags(self, site_xml, ns): time_zone, auto_suspend_refresh_enabled, auto_suspend_refresh_inactivity_window, + attribute_capture_enabled, ) return self @@ -830,6 +837,7 @@ def _set_values( time_zone, auto_suspend_refresh_enabled, auto_suspend_refresh_inactivity_window, + attribute_capture_enabled, ): if id is not None: self._id = id @@ -937,6 +945,7 @@ def _set_values( self.auto_suspend_refresh_enabled = auto_suspend_refresh_enabled if auto_suspend_refresh_inactivity_window is not None: self.auto_suspend_refresh_inactivity_window = auto_suspend_refresh_inactivity_window + self.attribute_capture_enabled = attribute_capture_enabled @classmethod def from_response(cls, resp, ns) -> list["SiteItem"]: @@ -996,6 +1005,7 @@ def from_response(cls, resp, ns) -> list["SiteItem"]: time_zone, auto_suspend_refresh_enabled, auto_suspend_refresh_inactivity_window, + attribute_capture_enabled, ) = cls._parse_element(site_xml, ns) site_item = cls(name, content_url) @@ -1051,6 +1061,7 @@ def from_response(cls, resp, ns) -> list["SiteItem"]: time_zone, auto_suspend_refresh_enabled, auto_suspend_refresh_inactivity_window, + attribute_capture_enabled, ) all_site_items.append(site_item) return all_site_items @@ -1132,6 +1143,9 @@ def _parse_element(site_xml, ns): flows_enabled = string_to_bool(site_xml.get("flowsEnabled", "")) cataloging_enabled = string_to_bool(site_xml.get("catalogingEnabled", "")) + attribute_capture_enabled = ( + string_to_bool(ace) if (ace := site_xml.get("attributeCaptureEnabled")) is not None else None + ) return ( id, @@ -1185,6 +1199,7 @@ def _parse_element(site_xml, ns): time_zone, auto_suspend_refresh_enabled, auto_suspend_refresh_inactivity_window, + attribute_capture_enabled, ) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index f17d579ab..318a93836 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -741,6 +741,8 @@ def update_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = Non site_element.attrib["autoSuspendRefreshInactivityWindow"] = str( site_item.auto_suspend_refresh_inactivity_window ) + if site_item.attribute_capture_enabled is not None: + site_element.attrib["attributeCaptureEnabled"] = str(site_item.attribute_capture_enabled).lower() return ET.tostring(xml_request) @@ -845,6 +847,8 @@ def create_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = Non site_element.attrib["autoSuspendRefreshInactivityWindow"] = str( site_item.auto_suspend_refresh_inactivity_window ) + if site_item.attribute_capture_enabled is not None: + site_element.attrib["attributeCaptureEnabled"] = str(site_item.attribute_capture_enabled).lower() return ET.tostring(xml_request) diff --git a/test/_utils.py b/test/_utils.py index b4ee93bc3..a23f37b57 100644 --- a/test/_utils.py +++ b/test/_utils.py @@ -1,5 +1,6 @@ import os.path import unittest +from typing import Optional from xml.etree import ElementTree as ET from contextlib import contextmanager @@ -32,6 +33,19 @@ def server_response_error_factory(code: str, summary: str, detail: str) -> str: return ET.tostring(root, encoding="utf-8").decode("utf-8") +def server_response_factory(tag: str, **attributes: str) -> bytes: + ns = "http://tableau.com/api" + ET.register_namespace("", ns) + root = ET.Element( + f"{{{ns}}}tsResponse", + ) + if attributes is None: + attributes = {} + + elem = ET.SubElement(root, f"{{{ns}}}{tag}", attrib=attributes) + return ET.tostring(root, encoding="utf-8") + + @contextmanager def mocked_time(): mock_time = 0 diff --git a/test/test_site.py b/test/test_site.py index 243810254..034e7c840 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -1,10 +1,15 @@ +from itertools import product import os.path import unittest +from defusedxml import ElementTree as ET import pytest import requests_mock import tableauserverclient as TSC +from tableauserverclient.server.request_factory import RequestFactory + +from . import _utils TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -286,3 +291,36 @@ def test_list_auth_configurations(self) -> None: assert configs[1].idp_configuration_id == "11111111-1111-1111-1111-111111111111" assert configs[1].idp_configuration_name == "Initial SAML" assert configs[1].known_provider_alias is None + + +@pytest.mark.parametrize("capture", [True, False, None]) +def test_parsing_attr_capture(capture): + server = TSC.Server("http://test", False) + server.version = "3.10" + attrs = {"contentUrl": "test", "name": "test"} + if capture is not None: + attrs |= {"attributeCaptureEnabled": str(capture).lower()} + xml = _utils.server_response_factory("site", **attrs) + site = TSC.SiteItem.from_response(xml, server.namespace)[0] + + assert site.attribute_capture_enabled is capture, "Attribute capture not captured correctly" + + +@pytest.mark.filterwarnings("ignore:FlowsEnabled has been removed") +@pytest.mark.parametrize("req, capture", product(["create_req", "update_req"], [True, False, None])) +def test_encoding_attr_capture(req, capture): + site = TSC.SiteItem( + content_url="test", + name="test", + attribute_capture_enabled=capture, + ) + xml = getattr(RequestFactory.Site, req)(site) + site_elem = ET.fromstring(xml).find(".//site") + assert site_elem is not None, "Site element missing from XML body." + + if capture is not None: + assert ( + site_elem.attrib["attributeCaptureEnabled"] == str(capture).lower() + ), "Attribute capture not encoded correctly" + else: + assert "attributeCaptureEnabled" not in site_elem.attrib, "Attribute capture should not be encoded when None" From bca08ba6d534d874f57cb1b621bee6bd11cce23a Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 1 Aug 2025 01:51:12 -0500 Subject: [PATCH 16/20] fix: put special fields first (#1622) Closes #1620 Sorting the fields prior to putting them in the query string assures that '_all_' and '_default_' appear first in the field list, satisfying the criteria of Tableau Server/Cloud to process those first. Order of other fields appeared to be irrelevant, so the test simply ensures their presence. Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/server/request_options.py | 5 +++- test/test_request_option.py | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 4a104255f..45a4f6df0 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -96,7 +96,10 @@ def get_query_params(self) -> dict: if self.pagesize: params["pageSize"] = self.pagesize if self.fields: - params["fields"] = ",".join(self.fields) + if "_all_" in self.fields: + params["fields"] = "_all_" + else: + params["fields"] = ",".join(sorted(self.fields)) return params def page_size(self, page_size): diff --git a/test/test_request_option.py b/test/test_request_option.py index 57dfdc2a0..dbf6dc996 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -378,3 +378,27 @@ def test_queryset_only_fields(self) -> None: loop = self.server.users.only_fields("id") assert "id" in loop.request_options.fields assert "_default_" not in loop.request_options.fields + + def test_queryset_field_order(self) -> None: + with requests_mock.mock() as m: + m.get(self.server.views.baseurl, text=SLICING_QUERYSET_PAGE_1.read_text()) + loop = self.server.views.fields("id", "name") + list(loop) + history = m.request_history[0] + + fields = history.qs.get("fields", [""])[0].split(",") + + assert fields[0] == "_default_" + assert "id" in fields + assert "name" in fields + + def test_queryset_field_all(self) -> None: + with requests_mock.mock() as m: + m.get(self.server.views.baseurl, text=SLICING_QUERYSET_PAGE_1.read_text()) + loop = self.server.views.fields("id", "name", "_all_") + list(loop) + history = m.request_history[0] + + fields = history.qs.get("fields", [""])[0] + + assert fields == "_all_" From 61062dcd0a1a81b791f48b6e35e3b197404aa1bc Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 1 Aug 2025 01:55:51 -0500 Subject: [PATCH 17/20] feat: support OIDC endpoints (#1630) * feat: support OIDC endpoints Add support for remaining OIDC endpoints, including getting an OIDC configuration by ID, removing the configuration, creating, and updating configurations. * feat: add str and repr to oidc item --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/__init__.py | 2 + tableauserverclient/models/__init__.py | 3 + tableauserverclient/models/oidc_item.py | 82 +++++++++ .../server/endpoint/__init__.py | 2 + .../server/endpoint/oidc_endpoint.py | 157 ++++++++++++++++++ tableauserverclient/server/request_factory.py | 117 +++++++++++++ tableauserverclient/server/server.py | 2 + test/assets/oidc_create.xml | 30 ++++ test/assets/oidc_get.xml | 30 ++++ test/assets/oidc_update.xml | 30 ++++ test/test_oidc.py | 153 +++++++++++++++++ 11 files changed, 608 insertions(+) create mode 100644 tableauserverclient/models/oidc_item.py create mode 100644 tableauserverclient/server/endpoint/oidc_endpoint.py create mode 100644 test/assets/oidc_create.xml create mode 100644 test/assets/oidc_get.xml create mode 100644 test/assets/oidc_update.xml create mode 100644 test/test_oidc.py diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 21e2c4760..c15e1a6eb 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -37,6 +37,7 @@ RevisionItem, ScheduleItem, SiteAuthConfiguration, + SiteOIDCConfiguration, SiteItem, ServerInfoItem, SubscriptionItem, @@ -125,6 +126,7 @@ "ServerResponseError", "SiteItem", "SiteAuthConfiguration", + "SiteOIDCConfiguration", "Sort", "SubscriptionItem", "TableauAuth", diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 30cd88104..5ad7ec1c4 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -30,6 +30,7 @@ ) from tableauserverclient.models.location_item import LocationItem from tableauserverclient.models.metric_item import MetricItem +from tableauserverclient.models.oidc_item import SiteOIDCConfiguration from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.models.permissions_item import PermissionsRule, Permission from tableauserverclient.models.project_item import ProjectItem @@ -79,6 +80,7 @@ "BackgroundJobItem", "LocationItem", "MetricItem", + "SiteOIDCConfiguration", "PaginationItem", "Permission", "PermissionsRule", @@ -88,6 +90,7 @@ "ServerInfoItem", "SiteAuthConfiguration", "SiteItem", + "SiteOIDCConfiguration", "SubscriptionItem", "TableItem", "TableauAuth", diff --git a/tableauserverclient/models/oidc_item.py b/tableauserverclient/models/oidc_item.py new file mode 100644 index 000000000..6e9626ed1 --- /dev/null +++ b/tableauserverclient/models/oidc_item.py @@ -0,0 +1,82 @@ +from typing import Optional +from defusedxml.ElementTree import fromstring + + +class SiteOIDCConfiguration: + def __init__(self) -> None: + self.enabled: bool = False + self.test_login_url: Optional[str] = None + self.known_provider_alias: Optional[str] = None + self.allow_embedded_authentication: bool = False + self.use_full_name: bool = False + self.idp_configuration_name: Optional[str] = None + self.idp_configuration_id: Optional[str] = None + self.client_id: Optional[str] = None + self.client_secret: Optional[str] = None + self.authorization_endpoint: Optional[str] = None + self.token_endpoint: Optional[str] = None + self.userinfo_endpoint: Optional[str] = None + self.jwks_uri: Optional[str] = None + self.end_session_endpoint: Optional[str] = None + self.custom_scope: Optional[str] = None + self.essential_acr_values: Optional[str] = None + self.email_mapping: Optional[str] = None + self.first_name_mapping: Optional[str] = None + self.last_name_mapping: Optional[str] = None + self.full_name_mapping: Optional[str] = None + self.prompt: Optional[str] = None + self.client_authentication: Optional[str] = None + self.voluntary_acr_values: Optional[str] = None + + def __str__(self) -> str: + return ( + f"{self.__class__.__qualname__}(enabled={self.enabled}, " + f"test_login_url={self.test_login_url}, " + f"idp_configuration_name={self.idp_configuration_name}, " + f"idp_configuration_id={self.idp_configuration_id}, " + f"client_id={self.client_id})" + ) + + def __repr__(self) -> str: + return f"<{str(self)}>" + + @classmethod + def from_response(cls, raw_xml: bytes, ns) -> "SiteOIDCConfiguration": + """ + Parses the raw XML bytes and returns a SiteOIDCConfiguration object. + """ + root = fromstring(raw_xml) + elem = root.find("t:siteOIDCConfiguration", namespaces=ns) + if elem is None: + raise ValueError("No siteOIDCConfiguration element found in the XML.") + config = cls() + + config.enabled = str_to_bool(elem.get("enabled", "false")) + config.test_login_url = elem.get("testLoginUrl") + config.known_provider_alias = elem.get("knownProviderAlias") + config.allow_embedded_authentication = str_to_bool(elem.get("allowEmbeddedAuthentication", "false").lower()) + config.use_full_name = str_to_bool(elem.get("useFullName", "false").lower()) + config.idp_configuration_name = elem.get("idpConfigurationName") + config.idp_configuration_id = elem.get("idpConfigurationId") + config.client_id = elem.get("clientId") + config.client_secret = elem.get("clientSecret") + config.authorization_endpoint = elem.get("authorizationEndpoint") + config.token_endpoint = elem.get("tokenEndpoint") + config.userinfo_endpoint = elem.get("userinfoEndpoint") + config.jwks_uri = elem.get("jwksUri") + config.end_session_endpoint = elem.get("endSessionEndpoint") + config.custom_scope = elem.get("customScope") + config.essential_acr_values = elem.get("essentialAcrValues") + config.email_mapping = elem.get("emailMapping") + config.first_name_mapping = elem.get("firstNameMapping") + config.last_name_mapping = elem.get("lastNameMapping") + config.full_name_mapping = elem.get("fullNameMapping") + config.prompt = elem.get("prompt") + config.client_authentication = elem.get("clientAuthentication") + config.voluntary_acr_values = elem.get("voluntaryAcrValues") + + return config + + +def str_to_bool(s: str) -> bool: + return s == "true" diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index b05b9addd..3c1266f90 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -17,6 +17,7 @@ from tableauserverclient.server.endpoint.linked_tasks_endpoint import LinkedTasks from tableauserverclient.server.endpoint.metadata_endpoint import Metadata from tableauserverclient.server.endpoint.metrics_endpoint import Metrics +from tableauserverclient.server.endpoint.oidc_endpoint import OIDC from tableauserverclient.server.endpoint.projects_endpoint import Projects from tableauserverclient.server.endpoint.schedules_endpoint import Schedules from tableauserverclient.server.endpoint.server_info_endpoint import ServerInfo @@ -52,6 +53,7 @@ "LinkedTasks", "Metadata", "Metrics", + "OIDC", "Projects", "Schedules", "ServerInfo", diff --git a/tableauserverclient/server/endpoint/oidc_endpoint.py b/tableauserverclient/server/endpoint/oidc_endpoint.py new file mode 100644 index 000000000..d16008095 --- /dev/null +++ b/tableauserverclient/server/endpoint/oidc_endpoint.py @@ -0,0 +1,157 @@ +from typing import Protocol, Union, TYPE_CHECKING +from tableauserverclient.models.oidc_item import SiteOIDCConfiguration +from tableauserverclient.server.endpoint import Endpoint +from tableauserverclient.server.request_factory import RequestFactory +from tableauserverclient.server.endpoint.endpoint import api + +if TYPE_CHECKING: + from tableauserverclient.models.site_item import SiteAuthConfiguration + from tableauserverclient.server.server import Server + + +class IDPAttributes(Protocol): + idp_configuration_id: str + + +class IDPProperty(Protocol): + @property + def idp_configuration_id(self) -> str: ... + + +HasIdpConfigurationID = Union[str, IDPAttributes] + + +class OIDC(Endpoint): + def __init__(self, server: "Server") -> None: + self.parent_srv = server + + @property + def baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/site-oidc-configuration" + + @api(version="3.24") + def get(self) -> list["SiteAuthConfiguration"]: + """ + Get all OpenID Connect (OIDC) configurations for the currently + authenticated Tableau Cloud site. To get all of the configuration + details, use the get_by_id method. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_identity_pools.htm#AuthnService_ListAuthConfigurations + + Returns + ------- + list[SiteAuthConfiguration] + """ + return self.parent_srv.sites.list_auth_configurations() + + @api(version="3.24") + def get_by_id(self, id: Union[str, HasIdpConfigurationID]) -> SiteOIDCConfiguration: + """ + Get details about a specific OpenID Connect (OIDC) configuration on the + current Tableau Cloud site. Only retrieves configurations for the + currently authenticated site. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_openid_connect.htm#get_openid_connect_configuration + + Parameters + ---------- + id : Union[str, HasID] + The ID of the OIDC configuration to retrieve. Can be either the + ID string or an object with an id attribute. + + Returns + ------- + SiteOIDCConfiguration + The OIDC configuration for the specified site. + """ + target = getattr(id, "idp_configuration_id", id) + url = f"{self.baseurl}/{target}" + response = self.get_request(url) + return SiteOIDCConfiguration.from_response(response.content, self.parent_srv.namespace) + + @api(version="3.22") + def create(self, config_item: SiteOIDCConfiguration) -> SiteOIDCConfiguration: + """ + Create the OpenID Connect (OIDC) configuration for the currently + authenticated Tableau Cloud site. The config_item must have the + following attributes set, others are optional: + + idp_configuration_name + client_id + client_secret + authorization_endpoint + token_endpoint + userinfo_endpoint + enabled + jwks_uri + + The secret in the returned config will be masked. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_openid_connect.htm#create_openid_connect_configuration + + Parameters + ---------- + config : SiteOIDCConfiguration + The OIDC configuration to create. + + Returns + ------- + SiteOIDCConfiguration + The created OIDC configuration. + """ + url = self.baseurl + create_req = RequestFactory.OIDC.create_req(config_item) + response = self.put_request(url, create_req) + return SiteOIDCConfiguration.from_response(response.content, self.parent_srv.namespace) + + @api(version="3.24") + def delete_configuration(self, config: Union[str, HasIdpConfigurationID]) -> None: + """ + Delete the OpenID Connect (OIDC) configuration for the currently + authenticated Tableau Cloud site. The config parameter can be either + the ID of the configuration or the configuration object itself. + + **Important**: Before removing the OIDC configuration, make sure that + users who are set to authenticate with OIDC are set to use a different + authentication type. Users who are not set with a different + authentication type before removing the OIDC configuration will not be + able to sign in to Tableau Cloud. + + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_openid_connect.htm#remove_openid_connect_configuration + + Parameters + ---------- + config : Union[str, HasID] + The OIDC configuration to delete. Can be either the ID of the + configuration or the configuration object itself. + """ + + target = getattr(config, "idp_configuration_id", config) + + url = f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/disable-site-oidc-configuration?idpConfigurationId={target}" + _ = self.put_request(url) + return None + + @api(version="3.22") + def update(self, config: SiteOIDCConfiguration) -> SiteOIDCConfiguration: + """ + Update the Tableau Cloud site's OpenID Connect (OIDC) configuration. The + secret in the returned config will be masked. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_openid_connect.htm#update_openid_connect_configuration + + Parameters + ---------- + config : SiteOIDCConfiguration + The OIDC configuration to update. Must have the id attribute set. + + Returns + ------- + SiteOIDCConfiguration + The updated OIDC configuration. + """ + url = f"{self.baseurl}/{config.idp_configuration_id}" + update_req = RequestFactory.OIDC.update_req(config) + response = self.put_request(url, update_req) + return SiteOIDCConfiguration.from_response(response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 318a93836..1df47f670 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1504,6 +1504,122 @@ def publish(self, xml_request: ET.Element, virtual_connection: VirtualConnection return ET.tostring(xml_request) +class OIDCRequest: + @_tsrequest_wrapped + def create_req(self, xml_request: ET.Element, oidc_item: SiteOIDCConfiguration) -> bytes: + oidc_element = ET.SubElement(xml_request, "siteOIDCConfiguration") + + # Check required attributes first + + if oidc_item.idp_configuration_name is None: + raise ValueError(f"OIDC Item missing idp_configuration_name: {oidc_item}") + if oidc_item.client_id is None: + raise ValueError(f"OIDC Item missing client_id: {oidc_item}") + if oidc_item.client_secret is None: + raise ValueError(f"OIDC Item missing client_secret: {oidc_item}") + if oidc_item.authorization_endpoint is None: + raise ValueError(f"OIDC Item missing authorization_endpoint: {oidc_item}") + if oidc_item.token_endpoint is None: + raise ValueError(f"OIDC Item missing token_endpoint: {oidc_item}") + if oidc_item.userinfo_endpoint is None: + raise ValueError(f"OIDC Item missing userinfo_endpoint: {oidc_item}") + if not isinstance(oidc_item.enabled, bool): + raise ValueError(f"OIDC Item missing enabled: {oidc_item}") + if oidc_item.jwks_uri is None: + raise ValueError(f"OIDC Item missing jwks_uri: {oidc_item}") + + oidc_element.attrib["name"] = oidc_item.idp_configuration_name + oidc_element.attrib["clientId"] = oidc_item.client_id + oidc_element.attrib["clientSecret"] = oidc_item.client_secret + oidc_element.attrib["authorizationEndpoint"] = oidc_item.authorization_endpoint + oidc_element.attrib["tokenEndpoint"] = oidc_item.token_endpoint + oidc_element.attrib["userInfoEndpoint"] = oidc_item.userinfo_endpoint + oidc_element.attrib["enabled"] = str(oidc_item.enabled).lower() + oidc_element.attrib["jwksUri"] = oidc_item.jwks_uri + + if oidc_item.allow_embedded_authentication is not None: + oidc_element.attrib["allowEmbeddedAuthentication"] = str(oidc_item.allow_embedded_authentication).lower() + if oidc_item.custom_scope is not None: + oidc_element.attrib["customScope"] = oidc_item.custom_scope + if oidc_item.prompt is not None: + oidc_element.attrib["prompt"] = oidc_item.prompt + if oidc_item.client_authentication is not None: + oidc_element.attrib["clientAuthentication"] = oidc_item.client_authentication + if oidc_item.essential_acr_values is not None: + oidc_element.attrib["essentialAcrValues"] = oidc_item.essential_acr_values + if oidc_item.voluntary_acr_values is not None: + oidc_element.attrib["voluntaryAcrValues"] = oidc_item.voluntary_acr_values + if oidc_item.email_mapping is not None: + oidc_element.attrib["emailMapping"] = oidc_item.email_mapping + if oidc_item.first_name_mapping is not None: + oidc_element.attrib["firstNameMapping"] = oidc_item.first_name_mapping + if oidc_item.last_name_mapping is not None: + oidc_element.attrib["lastNameMapping"] = oidc_item.last_name_mapping + if oidc_item.full_name_mapping is not None: + oidc_element.attrib["fullNameMapping"] = oidc_item.full_name_mapping + if oidc_item.use_full_name is not None: + oidc_element.attrib["useFullName"] = str(oidc_item.use_full_name).lower() + + return ET.tostring(xml_request) + + @_tsrequest_wrapped + def update_req(self, xml_request: ET.Element, oidc_item: SiteOIDCConfiguration) -> bytes: + oidc_element = ET.SubElement(xml_request, "siteOIDCConfiguration") + + # Check required attributes first + + if oidc_item.idp_configuration_name is None: + raise ValueError(f"OIDC Item missing idp_configuration_name: {oidc_item}") + if oidc_item.client_id is None: + raise ValueError(f"OIDC Item missing client_id: {oidc_item}") + if oidc_item.client_secret is None: + raise ValueError(f"OIDC Item missing client_secret: {oidc_item}") + if oidc_item.authorization_endpoint is None: + raise ValueError(f"OIDC Item missing authorization_endpoint: {oidc_item}") + if oidc_item.token_endpoint is None: + raise ValueError(f"OIDC Item missing token_endpoint: {oidc_item}") + if oidc_item.userinfo_endpoint is None: + raise ValueError(f"OIDC Item missing userinfo_endpoint: {oidc_item}") + if not isinstance(oidc_item.enabled, bool): + raise ValueError(f"OIDC Item missing enabled: {oidc_item}") + if oidc_item.jwks_uri is None: + raise ValueError(f"OIDC Item missing jwks_uri: {oidc_item}") + + oidc_element.attrib["name"] = oidc_item.idp_configuration_name + oidc_element.attrib["clientId"] = oidc_item.client_id + oidc_element.attrib["clientSecret"] = oidc_item.client_secret + oidc_element.attrib["authorizationEndpoint"] = oidc_item.authorization_endpoint + oidc_element.attrib["tokenEndpoint"] = oidc_item.token_endpoint + oidc_element.attrib["userInfoEndpoint"] = oidc_item.userinfo_endpoint + oidc_element.attrib["enabled"] = str(oidc_item.enabled).lower() + oidc_element.attrib["jwksUri"] = oidc_item.jwks_uri + + if oidc_item.allow_embedded_authentication is not None: + oidc_element.attrib["allowEmbeddedAuthentication"] = str(oidc_item.allow_embedded_authentication).lower() + if oidc_item.custom_scope is not None: + oidc_element.attrib["customScope"] = oidc_item.custom_scope + if oidc_item.prompt is not None: + oidc_element.attrib["prompt"] = oidc_item.prompt + if oidc_item.client_authentication is not None: + oidc_element.attrib["clientAuthentication"] = oidc_item.client_authentication + if oidc_item.essential_acr_values is not None: + oidc_element.attrib["essentialAcrValues"] = oidc_item.essential_acr_values + if oidc_item.voluntary_acr_values is not None: + oidc_element.attrib["voluntaryAcrValues"] = oidc_item.voluntary_acr_values + if oidc_item.email_mapping is not None: + oidc_element.attrib["emailMapping"] = oidc_item.email_mapping + if oidc_item.first_name_mapping is not None: + oidc_element.attrib["firstNameMapping"] = oidc_item.first_name_mapping + if oidc_item.last_name_mapping is not None: + oidc_element.attrib["lastNameMapping"] = oidc_item.last_name_mapping + if oidc_item.full_name_mapping is not None: + oidc_element.attrib["fullNameMapping"] = oidc_item.full_name_mapping + if oidc_item.use_full_name is not None: + oidc_element.attrib["useFullName"] = str(oidc_item.use_full_name).lower() + + return ET.tostring(xml_request) + + class RequestFactory: Auth = AuthRequest() Connection = Connection() @@ -1521,6 +1637,7 @@ class RequestFactory: Group = GroupRequest() GroupSet = GroupSetRequest() Metric = MetricRequest() + OIDC = OIDCRequest() Permission = PermissionRequest() Project = ProjectRequest() Schedule = ScheduleRequest() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index d5d163db3..9202e3e63 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -38,6 +38,7 @@ GroupSets, Tags, VirtualConnections, + OIDC, ) from tableauserverclient.server.exceptions import ( ServerInfoEndpointNotFoundError, @@ -183,6 +184,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.group_sets = GroupSets(self) self.tags = Tags(self) self.virtual_connections = VirtualConnections(self) + self.oidc = OIDC(self) self._session = self._session_factory() self._http_options = dict() # must set this before making a server call diff --git a/test/assets/oidc_create.xml b/test/assets/oidc_create.xml new file mode 100644 index 000000000..cbe632f3b --- /dev/null +++ b/test/assets/oidc_create.xml @@ -0,0 +1,30 @@ + + + + diff --git a/test/assets/oidc_get.xml b/test/assets/oidc_get.xml new file mode 100644 index 000000000..cbe632f3b --- /dev/null +++ b/test/assets/oidc_get.xml @@ -0,0 +1,30 @@ + + + + diff --git a/test/assets/oidc_update.xml b/test/assets/oidc_update.xml new file mode 100644 index 000000000..cbe632f3b --- /dev/null +++ b/test/assets/oidc_update.xml @@ -0,0 +1,30 @@ + + + + diff --git a/test/test_oidc.py b/test/test_oidc.py new file mode 100644 index 000000000..4c3187355 --- /dev/null +++ b/test/test_oidc.py @@ -0,0 +1,153 @@ +import unittest +import requests_mock +from pathlib import Path + +import tableauserverclient as TSC + +assets = Path(__file__).parent / "assets" +OIDC_GET = assets / "oidc_get.xml" +OIDC_GET_BY_ID = assets / "oidc_get_by_id.xml" +OIDC_UPDATE = assets / "oidc_update.xml" +OIDC_CREATE = assets / "oidc_create.xml" + + +class Testoidc(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + + # Fake signin + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + self.server.version = "3.24" + + self.baseurl = self.server.oidc.baseurl + + def test_oidc_get_by_id(self) -> None: + luid = "6561daa3-20e8-407f-ba09-709b178c0b4a" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{luid}", text=OIDC_GET.read_text()) + oidc = self.server.oidc.get_by_id(luid) + + assert oidc.enabled is True + assert ( + oidc.test_login_url + == "https://sso.online.tableau.com/public/testLogin?alias=8a04d825-e5d4-408f-bbc2-1042b8bb4818&authSetting=OIDC&idpConfigurationId=78c985b4-5494-4436-bcee-f595e287ba4a" + ) + assert oidc.known_provider_alias == "Google" + assert oidc.allow_embedded_authentication is False + assert oidc.use_full_name is False + assert oidc.idp_configuration_name == "GoogleOIDC" + assert oidc.idp_configuration_id == "78c985b4-5494-4436-bcee-f595e287ba4a" + assert oidc.client_id == "ICcGeDt3XHwzZ1D0nCZt" + assert oidc.client_secret == "omit" + assert oidc.authorization_endpoint == "https://myidp.com/oauth2/v1/authorize" + assert oidc.token_endpoint == "https://myidp.com/oauth2/v1/token" + assert oidc.userinfo_endpoint == "https://myidp.com/oauth2/v1/userinfo" + assert oidc.jwks_uri == "https://myidp.com/oauth2/v1/keys" + assert oidc.end_session_endpoint == "https://myidp.com/oauth2/v1/logout" + assert oidc.custom_scope == "openid, email, profile" + assert oidc.prompt == "login,consent" + assert oidc.client_authentication == "client_secret_basic" + assert oidc.essential_acr_values == "phr" + assert oidc.email_mapping == "email" + assert oidc.first_name_mapping == "given_name" + assert oidc.last_name_mapping == "family_name" + assert oidc.full_name_mapping == "name" + + def test_oidc_delete(self) -> None: + luid = "6561daa3-20e8-407f-ba09-709b178c0b4a" + with requests_mock.mock() as m: + m.put(f"{self.server.baseurl}/sites/{self.server.site_id}/disable-site-oidc-configuration") + self.server.oidc.delete_configuration(luid) + history = m.request_history[0] + + assert "idpconfigurationid" in history.qs + assert history.qs["idpconfigurationid"][0] == luid + + def test_oidc_update(self) -> None: + luid = "6561daa3-20e8-407f-ba09-709b178c0b4a" + oidc = TSC.SiteOIDCConfiguration() + oidc.idp_configuration_id = luid + + # Only include the required fields for updates + oidc.enabled = True + oidc.idp_configuration_name = "GoogleOIDC" + oidc.client_id = "ICcGeDt3XHwzZ1D0nCZt" + oidc.client_secret = "omit" + oidc.authorization_endpoint = "https://myidp.com/oauth2/v1/authorize" + oidc.token_endpoint = "https://myidp.com/oauth2/v1/token" + oidc.userinfo_endpoint = "https://myidp.com/oauth2/v1/userinfo" + oidc.jwks_uri = "https://myidp.com/oauth2/v1/keys" + + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{luid}", text=OIDC_UPDATE.read_text()) + oidc = self.server.oidc.update(oidc) + + assert oidc.enabled is True + assert ( + oidc.test_login_url + == "https://sso.online.tableau.com/public/testLogin?alias=8a04d825-e5d4-408f-bbc2-1042b8bb4818&authSetting=OIDC&idpConfigurationId=78c985b4-5494-4436-bcee-f595e287ba4a" + ) + assert oidc.known_provider_alias == "Google" + assert oidc.allow_embedded_authentication is False + assert oidc.use_full_name is False + assert oidc.idp_configuration_name == "GoogleOIDC" + assert oidc.idp_configuration_id == "78c985b4-5494-4436-bcee-f595e287ba4a" + assert oidc.client_id == "ICcGeDt3XHwzZ1D0nCZt" + assert oidc.client_secret == "omit" + assert oidc.authorization_endpoint == "https://myidp.com/oauth2/v1/authorize" + assert oidc.token_endpoint == "https://myidp.com/oauth2/v1/token" + assert oidc.userinfo_endpoint == "https://myidp.com/oauth2/v1/userinfo" + assert oidc.jwks_uri == "https://myidp.com/oauth2/v1/keys" + assert oidc.end_session_endpoint == "https://myidp.com/oauth2/v1/logout" + assert oidc.custom_scope == "openid, email, profile" + assert oidc.prompt == "login,consent" + assert oidc.client_authentication == "client_secret_basic" + assert oidc.essential_acr_values == "phr" + assert oidc.email_mapping == "email" + assert oidc.first_name_mapping == "given_name" + assert oidc.last_name_mapping == "family_name" + assert oidc.full_name_mapping == "name" + + def test_oidc_create(self) -> None: + oidc = TSC.SiteOIDCConfiguration() + + # Only include the required fields for creation + oidc.enabled = True + oidc.idp_configuration_name = "GoogleOIDC" + oidc.client_id = "ICcGeDt3XHwzZ1D0nCZt" + oidc.client_secret = "omit" + oidc.authorization_endpoint = "https://myidp.com/oauth2/v1/authorize" + oidc.token_endpoint = "https://myidp.com/oauth2/v1/token" + oidc.userinfo_endpoint = "https://myidp.com/oauth2/v1/userinfo" + oidc.jwks_uri = "https://myidp.com/oauth2/v1/keys" + + with requests_mock.mock() as m: + m.put(self.baseurl, text=OIDC_CREATE.read_text()) + oidc = self.server.oidc.create(oidc) + + assert oidc.enabled is True + assert ( + oidc.test_login_url + == "https://sso.online.tableau.com/public/testLogin?alias=8a04d825-e5d4-408f-bbc2-1042b8bb4818&authSetting=OIDC&idpConfigurationId=78c985b4-5494-4436-bcee-f595e287ba4a" + ) + assert oidc.known_provider_alias == "Google" + assert oidc.allow_embedded_authentication is False + assert oidc.use_full_name is False + assert oidc.idp_configuration_name == "GoogleOIDC" + assert oidc.idp_configuration_id == "78c985b4-5494-4436-bcee-f595e287ba4a" + assert oidc.client_id == "ICcGeDt3XHwzZ1D0nCZt" + assert oidc.client_secret == "omit" + assert oidc.authorization_endpoint == "https://myidp.com/oauth2/v1/authorize" + assert oidc.token_endpoint == "https://myidp.com/oauth2/v1/token" + assert oidc.userinfo_endpoint == "https://myidp.com/oauth2/v1/userinfo" + assert oidc.jwks_uri == "https://myidp.com/oauth2/v1/keys" + assert oidc.end_session_endpoint == "https://myidp.com/oauth2/v1/logout" + assert oidc.custom_scope == "openid, email, profile" + assert oidc.prompt == "login,consent" + assert oidc.client_authentication == "client_secret_basic" + assert oidc.essential_acr_values == "phr" + assert oidc.email_mapping == "email" + assert oidc.first_name_mapping == "given_name" + assert oidc.last_name_mapping == "family_name" + assert oidc.full_name_mapping == "name" From e51369cef0aa4c1157e633df17e78d4faec18be3 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 1 Aug 2025 02:23:46 -0500 Subject: [PATCH 18/20] feat: SiteAuthConfiguration str and repr (#1641) * feat: SiteAuthConfiguration str and repr Gives SiteAuthConfiguration methods for str and repr calls to ensure consistent display of the object. * style: black --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Co-authored-by: Jac --- tableauserverclient/models/site_item.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index ab32ad09e..9cda5c898 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -1230,6 +1230,17 @@ def from_response(cls, resp: bytes, ns: dict) -> list["SiteAuthConfiguration"]: all_auth_configs.append(auth_config) return all_auth_configs + def __str__(self): + return ( + f"{self.__class__.__qualname__}(auth_setting={self.auth_setting}, " + f"enabled={self.enabled}, " + f"idp_configuration_id={self.idp_configuration_id}, " + f"idp_configuration_name={self.idp_configuration_name})" + ) + + def __repr__(self): + return f"<{str(self)}>" + # Used to convert string represented boolean to a boolean type def string_to_bool(s: str) -> bool: From a7af8b73af2339c417df1e6022fcd3355dee2647 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 1 Aug 2025 16:32:31 -0500 Subject: [PATCH 19/20] fix: virtual connections username (#1628) Closes #1626 VirtualConnections leverages the ConnectionItem object to parse the database connections server response. Most of other endpoints return "userName" and the VirtualConnections' "Get Database Connections" endpoint returns "username." Resolves the issue by allowing the ConnectionItem to read either. Update the test assets to reflect the actual returned value. Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/connection_item.py | 2 +- test/assets/virtual_connection_populate_connections.xml | 2 +- test/assets/virtual_connection_populate_connections2.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 3e8c6d290..e155a3e3a 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -120,7 +120,7 @@ def from_response(cls, resp, ns) -> list["ConnectionItem"]: connection_item.embed_password = string_to_bool(connection_xml.get("embedPassword", "")) connection_item.server_address = connection_xml.get("serverAddress", connection_xml.get("server", None)) connection_item.server_port = connection_xml.get("serverPort", connection_xml.get("port", None)) - connection_item.username = connection_xml.get("userName", None) + connection_item.username = connection_xml.get("userName", connection_xml.get("username", None)) connection_item._query_tagging = ( string_to_bool(s) if (s := connection_xml.get("queryTagging", None)) else None ) diff --git a/test/assets/virtual_connection_populate_connections.xml b/test/assets/virtual_connection_populate_connections.xml index 77d899520..0835e478f 100644 --- a/test/assets/virtual_connection_populate_connections.xml +++ b/test/assets/virtual_connection_populate_connections.xml @@ -1,6 +1,6 @@ - + diff --git a/test/assets/virtual_connection_populate_connections2.xml b/test/assets/virtual_connection_populate_connections2.xml index f0ad2646d..78ff90f65 100644 --- a/test/assets/virtual_connection_populate_connections2.xml +++ b/test/assets/virtual_connection_populate_connections2.xml @@ -1,6 +1,6 @@ - + From 9b84601420949a367d6c3eeb10b61e14c95c2c9f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 9 Aug 2025 00:18:11 -0500 Subject: [PATCH 20/20] fix: add contentType to tags batch actions (#1643) According to the .xsd schema file, the tags:batchCreate and tags:batchDelete need a "contentType" attribute on the "content" elements. This PR adds the missing attribute and checks in the test that the string is carried through in the request body. Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/server/request_factory.py | 1 + test/test_tagging.py | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 1df47f670..877a18c39 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -924,6 +924,7 @@ def batch_create(self, element: ET.Element, tags: set[str], content: content_typ if item.id is None: raise ValueError(f"Item {item} must have an ID to be tagged.") content_element.attrib["id"] = item.id + content_element.attrib["contentType"] = item.__class__.__name__.replace("Item", "") return ET.tostring(element) diff --git a/test/test_tagging.py b/test/test_tagging.py index 23dffebfb..8bfc90386 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -1,6 +1,7 @@ from contextlib import ExitStack import re from collections.abc import Iterable +from typing import Optional, Protocol import uuid from xml.etree import ElementTree as ET @@ -198,9 +199,14 @@ def test_update_tags(get_server, endpoint_type, item, tags) -> None: endpoint.update_tags(item) +class HasID(Protocol): + @property + def id(self) -> Optional[str]: ... + + def test_tags_batch_add(get_server) -> None: server = get_server - content = [make_workbook(), make_view(), make_datasource(), make_table(), make_database()] + content: list[HasID] = [make_workbook(), make_view(), make_datasource(), make_table(), make_database()] tags = ["a", "b"] add_tags_xml = batch_add_tags_xml_response_factory(tags, content) with requests_mock.mock() as m: @@ -210,8 +216,16 @@ def test_tags_batch_add(get_server) -> None: text=add_tags_xml, ) tag_result = server.tags.batch_add(tags, content) + history = m.request_history assert set(tag_result) == set(tags) + assert len(history) == 1 + body = ET.fromstring(history[0].body) + id_types = {c.id: c.__class__.__name__.replace("Item", "") for c in content} + for tag in body.findall(".//content"): + content_type = tag.attrib.get("contentType", "") + content_id = tag.attrib.get("id", "") + assert content_type == id_types.get(content_id, ""), f"Content type mismatch for {content_id}" def test_tags_batch_delete(get_server) -> None: