From 8abda4bafdd5526aefa19bc2113afed150658309 Mon Sep 17 00:00:00 2001 From: Vitor Honna Date: Thu, 15 May 2025 16:07:41 -0300 Subject: [PATCH 01/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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/44] 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: From d065506f0a77031fa2060b43aa8413b8ed8ffa1c Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 9 Oct 2025 13:05:30 -0500 Subject: [PATCH 21/44] fix: add missing closing tags (#1644) --- test/assets/favorites_add_view.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/assets/favorites_add_view.xml b/test/assets/favorites_add_view.xml index f6fc15c9a..0f5c6d166 100644 --- a/test/assets/favorites_add_view.xml +++ b/test/assets/favorites_add_view.xml @@ -11,4 +11,6 @@ - \ No newline at end of file + + + From e8aed2445287c7795768cc52011191a90b032374 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 13:41:42 -0500 Subject: [PATCH 22/44] ci: run tests against python 3.14 (#1660) --- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/run-tests.yml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index cae0f409c..3bbecd3c2 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -19,7 +19,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v5 with: - python-version: 3.9 + python-version: 3.13 - name: Build dist files run: | python -m pip install --upgrade pip diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 2e197cf20..4af48d064 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,7 +13,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14', '3.14t'] runs-on: ${{ matrix.os }} @@ -38,6 +38,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: ${{ matrix.allow-prereleases || false }} - name: Install dependencies run: | From a1962810f4c4740b724cc6bd4a9365e83cd63167 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 13:43:10 -0500 Subject: [PATCH 23/44] chore: pytestify test_datasource_model (#1656) --- test/test_datasource_model.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/test/test_datasource_model.py b/test/test_datasource_model.py index 655284194..c74805fa6 100644 --- a/test/test_datasource_model.py +++ b/test/test_datasource_model.py @@ -1,18 +1,20 @@ -import unittest +import pytest + import tableauserverclient as TSC -class DatasourceModelTests(unittest.TestCase): - def test_nullable_project_id(self): - datasource = TSC.DatasourceItem(name="10") - self.assertEqual(datasource.project_id, None) +def test_nullable_project_id(): + datasource = TSC.DatasourceItem(name="10") + assert datasource.project_id is None + + +def test_require_boolean_flag_bridge_fail(): + datasource = TSC.DatasourceItem("10") + with pytest.raises(ValueError): + datasource.use_remote_query_agent = "yes" - def test_require_boolean_flag_bridge_fail(self): - datasource = TSC.DatasourceItem("10") - with self.assertRaises(ValueError): - datasource.use_remote_query_agent = "yes" - def test_require_boolean_flag_bridge_ok(self): - datasource = TSC.DatasourceItem("10") - datasource.use_remote_query_agent = True - self.assertEqual(datasource.use_remote_query_agent, True) +def test_require_boolean_flag_bridge_ok(): + datasource = TSC.DatasourceItem("10") + datasource.use_remote_query_agent = True + assert datasource.use_remote_query_agent From 915f1af86b339e8fb30fe8d841af74d9c4d77070 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 18:35:18 -0500 Subject: [PATCH 24/44] chore: convert workbook tests to pytest (#1645) * chore: workook tests converted to pytest * chore: convert all assets to Paths * chore: convert tests to pytest * style: black * chore: remove asset and read_assets references * chore: narrow download_revision return type * chore: add type hints to fixture --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../server/endpoint/workbooks_endpoint.py | 75 +- test/test_workbook.py | 2027 +++++++++-------- 2 files changed, 1088 insertions(+), 1014 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 907d2d99e..5f9695829 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -30,9 +30,12 @@ from tableauserverclient.server import RequestFactory from typing import ( + Literal, Optional, TYPE_CHECKING, + TypeVar, Union, + overload, ) from collections.abc import Iterable, Sequence @@ -383,16 +386,34 @@ def update_connections( logger.info(f"Updated connections for workbook {workbook_item.id}: {', '.join(updated_ids)}") return connection_items + T = TypeVar("T", bound=FileObjectW) + + @overload + def download( + self, + workbook_id: str, + filepath: T, + include_extract: bool = True, + ) -> T: ... + + @overload + def download( + self, + workbook_id: str, + filepath: Optional[FilePath] = None, + include_extract: bool = True, + ) -> str: ... + # Download workbook contents with option of passing in filepath @api(version="2.0") @parameter_added_in(no_extract="2.5") @parameter_added_in(include_extract="2.5") def download( self, - workbook_id: str, - filepath: Optional[PathOrFileW] = None, - include_extract: bool = True, - ) -> PathOrFileW: + workbook_id, + filepath=None, + include_extract=True, + ): """ Downloads a workbook to the specified directory (optional). @@ -741,6 +762,30 @@ def delete_permission(self, item: WorkbookItem, capability_item: PermissionsRule """ return self._permissions.delete(item, capability_item) + @overload + def publish( + self, + workbook_item: WorkbookItem, + file: PathOrFileR, + mode: str, + connections: Optional[Sequence[ConnectionItem]], + as_job: Literal[False], + skip_connection_check: bool, + parameters=None, + ) -> WorkbookItem: ... + + @overload + def publish( + self, + workbook_item: WorkbookItem, + file: PathOrFileR, + mode: str, + connections: Optional[Sequence[ConnectionItem]], + as_job: Literal[True], + skip_connection_check: bool, + parameters=None, + ) -> JobItem: ... + @api(version="2.0") @parameter_added_in(as_job="3.0") @parameter_added_in(connections="2.8") @@ -977,15 +1022,27 @@ def _get_workbook_revisions( revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, workbook_item) return revisions + T = TypeVar("T", bound=FileObjectW) + + @overload + def download_revision( + self, workbook_id: str, revision_number: Optional[str], filepath: T, include_extract: bool + ) -> T: ... + + @overload + def download_revision( + self, workbook_id: str, revision_number: Optional[str], filepath: Optional[FilePath], include_extract: bool + ) -> str: ... + # Download 1 workbook revision by revision number @api(version="2.3") def download_revision( self, - workbook_id: str, - revision_number: Optional[str], - filepath: Optional[PathOrFileW] = None, - include_extract: bool = True, - ) -> PathOrFileW: + workbook_id, + revision_number, + filepath, + include_extract=True, + ): """ Downloads a workbook revision to the specified directory (optional). diff --git a/test/test_workbook.py b/test/test_workbook.py index a4242c210..e6e807f89 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -2,7 +2,6 @@ import re import requests_mock import tempfile -import unittest from defusedxml.ElementTree import fromstring from io import BytesIO from pathlib import Path @@ -14,1105 +13,1123 @@ 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 read_xml_asset, read_xml_assets, asset - -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") - -ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "workbook_add_tags.xml") -GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id.xml") -GET_BY_ID_XML_PERSONAL = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id_personal.xml") -GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_empty.xml") -GET_INVALID_DATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_invalid_date.xml") -GET_XML = os.path.join(TEST_ASSET_DIR, "workbook_get.xml") -GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "workbook_get_all_fields.xml") -ODATA_XML = os.path.join(TEST_ASSET_DIR, "odata_connection.xml") -POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_connections.xml") -POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") -POPULATE_POWERPOINT = os.path.join(TEST_ASSET_DIR, "populate_powerpoint.pptx") -POPULATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_permissions.xml") -POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "RESTAPISample Image.png") -POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_views.xml") -POPULATE_VIEWS_USAGE_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_views_usage.xml") -PUBLISH_XML = os.path.join(TEST_ASSET_DIR, "workbook_publish.xml") -PUBLISH_ASYNC_XML = os.path.join(TEST_ASSET_DIR, "workbook_publish_async.xml") -REFRESH_XML = os.path.join(TEST_ASSET_DIR, "workbook_refresh.xml") -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): - def setUp(self) -> None: - self.server = TSC.Server("http://test", False) - - # Fake sign in - self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - - self.baseurl = self.server.workbooks.baseurl - - def test_get(self) -> None: - with open(GET_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl, text=response_xml) - all_workbooks, pagination_item = self.server.workbooks.get() - - self.assertEqual(2, pagination_item.total_available) - self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", all_workbooks[0].id) - self.assertEqual("Superstore", all_workbooks[0].name) - self.assertEqual("Superstore", all_workbooks[0].content_url) - self.assertEqual(False, all_workbooks[0].show_tabs) - self.assertEqual("http://tableauserver/#/workbooks/1/views", all_workbooks[0].webpage_url) - self.assertEqual(1, all_workbooks[0].size) - self.assertEqual("2016-08-03T20:34:04Z", format_datetime(all_workbooks[0].created_at)) - self.assertEqual("description for Superstore", all_workbooks[0].description) - self.assertEqual("2016-08-04T17:56:41Z", format_datetime(all_workbooks[0].updated_at)) - self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_workbooks[0].project_id) - self.assertEqual("default", all_workbooks[0].project_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_workbooks[0].owner_id) - - self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_workbooks[1].id) - self.assertEqual("SafariSample", all_workbooks[1].name) - self.assertEqual("SafariSample", all_workbooks[1].content_url) - self.assertEqual("http://tableauserver/#/workbooks/2/views", all_workbooks[1].webpage_url) - self.assertEqual(False, all_workbooks[1].show_tabs) - self.assertEqual(26, all_workbooks[1].size) - self.assertEqual("2016-07-26T20:34:56Z", format_datetime(all_workbooks[1].created_at)) - self.assertEqual("description for SafariSample", all_workbooks[1].description) - self.assertEqual("2016-07-26T20:35:05Z", format_datetime(all_workbooks[1].updated_at)) - self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_workbooks[1].project_id) - self.assertEqual("default", all_workbooks[1].project_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_workbooks[1].owner_id) - self.assertEqual({"Safari", "Sample"}, all_workbooks[1].tags) - - def test_get_ignore_invalid_date(self) -> None: - with open(GET_INVALID_DATE_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl, text=response_xml) - all_workbooks, pagination_item = self.server.workbooks.get() - self.assertEqual(None, format_datetime(all_workbooks[0].created_at)) - self.assertEqual("2016-08-04T17:56:41Z", format_datetime(all_workbooks[0].updated_at)) - - def test_get_before_signin(self) -> None: - self.server._auth_token = None - self.assertRaises(TSC.NotSignedInError, self.server.workbooks.get) - - def test_get_empty(self) -> None: - with open(GET_EMPTY_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl, text=response_xml) - all_workbooks, pagination_item = self.server.workbooks.get() - self.assertEqual(0, pagination_item.total_available) - self.assertEqual([], all_workbooks) +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +ADD_TAGS_XML = TEST_ASSET_DIR / "workbook_add_tags.xml" +GET_BY_ID_XML = TEST_ASSET_DIR / "workbook_get_by_id.xml" +GET_BY_ID_XML_PERSONAL = TEST_ASSET_DIR / "workbook_get_by_id_personal.xml" +GET_EMPTY_XML = TEST_ASSET_DIR / "workbook_get_empty.xml" +GET_INVALID_DATE_XML = TEST_ASSET_DIR / "workbook_get_invalid_date.xml" +GET_XML = TEST_ASSET_DIR / "workbook_get.xml" +GET_XML_ALL_FIELDS = TEST_ASSET_DIR / "workbook_get_all_fields.xml" +ODATA_XML = TEST_ASSET_DIR / "odata_connection.xml" +POPULATE_CONNECTIONS_XML = TEST_ASSET_DIR / "workbook_populate_connections.xml" +POPULATE_PDF = TEST_ASSET_DIR / "populate_pdf.pdf" +POPULATE_POWERPOINT = TEST_ASSET_DIR / "populate_powerpoint.pptx" +POPULATE_PERMISSIONS_XML = TEST_ASSET_DIR / "workbook_populate_permissions.xml" +POPULATE_PREVIEW_IMAGE = TEST_ASSET_DIR / "RESTAPISample Image.png" +POPULATE_VIEWS_XML = TEST_ASSET_DIR / "workbook_populate_views.xml" +POPULATE_VIEWS_USAGE_XML = TEST_ASSET_DIR / "workbook_populate_views_usage.xml" +PUBLISH_XML = TEST_ASSET_DIR / "workbook_publish.xml" +PUBLISH_ASYNC_XML = TEST_ASSET_DIR / "workbook_publish_async.xml" +REFRESH_XML = TEST_ASSET_DIR / "workbook_refresh.xml" +REVISION_XML = TEST_ASSET_DIR / "workbook_revision.xml" +UPDATE_XML = TEST_ASSET_DIR / "workbook_update.xml" +UPDATE_PERMISSIONS = TEST_ASSET_DIR / "workbook_update_permissions.xml" +UPDATE_CONNECTIONS_XML = TEST_ASSET_DIR / "workbook_update_connections.xml" + + +@pytest.fixture(scope="function") +def server() -> TSC.Server: + server = TSC.Server("http://test", False) + + # Fake sign in + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + return server + + +def test_get(server: TSC.Server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl, text=response_xml) + all_workbooks, pagination_item = server.workbooks.get() + + assert 2 == pagination_item.total_available + assert "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" == all_workbooks[0].id + assert "Superstore" == all_workbooks[0].name + assert "Superstore" == all_workbooks[0].content_url + assert not all_workbooks[0].show_tabs + assert "http://tableauserver/#/workbooks/1/views" == all_workbooks[0].webpage_url + assert 1 == all_workbooks[0].size + assert "2016-08-03T20:34:04Z" == format_datetime(all_workbooks[0].created_at) + assert "description for Superstore" == all_workbooks[0].description + assert "2016-08-04T17:56:41Z" == format_datetime(all_workbooks[0].updated_at) + assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == all_workbooks[0].project_id + assert "default" == all_workbooks[0].project_name + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == all_workbooks[0].owner_id + + assert "3cc6cd06-89ce-4fdc-b935-5294135d6d42" == all_workbooks[1].id + assert "SafariSample" == all_workbooks[1].name + assert "SafariSample" == all_workbooks[1].content_url + assert "http://tableauserver/#/workbooks/2/views" == all_workbooks[1].webpage_url + assert not all_workbooks[1].show_tabs + assert 26 == all_workbooks[1].size + assert "2016-07-26T20:34:56Z" == format_datetime(all_workbooks[1].created_at) + assert "description for SafariSample" == all_workbooks[1].description + assert "2016-07-26T20:35:05Z" == format_datetime(all_workbooks[1].updated_at) + assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == all_workbooks[1].project_id + assert "default" == all_workbooks[1].project_name + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == all_workbooks[1].owner_id + assert {"Safari", "Sample"} == all_workbooks[1].tags + + +def test_get_ignore_invalid_date(server: TSC.Server) -> None: + response_xml = GET_INVALID_DATE_XML.read_text() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl, text=response_xml) + all_workbooks, pagination_item = server.workbooks.get() + assert format_datetime(all_workbooks[0].created_at) is None + assert "2016-08-04T17:56:41Z" == format_datetime(all_workbooks[0].updated_at) + + +def test_get_before_signin(server: TSC.Server) -> None: + server._auth_token = None + with pytest.raises(TSC.NotSignedInError): + server.workbooks.get() + + +def test_get_empty(server: TSC.Server) -> None: + response_xml = GET_EMPTY_XML.read_text() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl, text=response_xml) + all_workbooks, pagination_item = server.workbooks.get() + + assert 0 == pagination_item.total_available + assert [] == all_workbooks + + +def test_get_by_id(server: TSC.Server) -> None: + response_xml = GET_BY_ID_XML.read_text() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42", text=response_xml) + single_workbook = server.workbooks.get_by_id("3cc6cd06-89ce-4fdc-b935-5294135d6d42") + + assert "3cc6cd06-89ce-4fdc-b935-5294135d6d42" == single_workbook.id + assert "SafariSample" == single_workbook.name + assert "SafariSample" == single_workbook.content_url + assert "http://tableauserver/#/workbooks/2/views" == single_workbook.webpage_url + assert not single_workbook.show_tabs + assert 26 == single_workbook.size + assert "2016-07-26T20:34:56Z" == format_datetime(single_workbook.created_at) + assert "description for SafariSample" == single_workbook.description + assert "2016-07-26T20:35:05Z" == format_datetime(single_workbook.updated_at) + assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == single_workbook.project_id + assert "default" == single_workbook.project_name + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == single_workbook.owner_id + assert {"Safari", "Sample"} == single_workbook.tags + assert "d79634e1-6063-4ec9-95ff-50acbf609ff5" == single_workbook.views[0].id + assert "ENDANGERED SAFARI" == single_workbook.views[0].name + assert "SafariSample/sheets/ENDANGEREDSAFARI" == single_workbook.views[0].content_url + + +def test_get_by_id_personal(server: TSC.Server) -> None: + # workbooks in personal space don't have project_id or project_name + response_xml = GET_BY_ID_XML_PERSONAL.read_text() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d43", text=response_xml) + single_workbook = server.workbooks.get_by_id("3cc6cd06-89ce-4fdc-b935-5294135d6d43") + + assert "3cc6cd06-89ce-4fdc-b935-5294135d6d43" == single_workbook.id + assert "SafariSample" == single_workbook.name + assert "SafariSample" == single_workbook.content_url + assert "http://tableauserver/#/workbooks/2/views" == single_workbook.webpage_url + assert not single_workbook.show_tabs + assert 26 == single_workbook.size + assert "2016-07-26T20:34:56Z" == format_datetime(single_workbook.created_at) + assert "description for SafariSample" == single_workbook.description + assert "2016-07-26T20:35:05Z" == format_datetime(single_workbook.updated_at) + assert single_workbook.project_id + assert single_workbook.project_name is None + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == single_workbook.owner_id + assert {"Safari", "Sample"} == single_workbook.tags + assert "d79634e1-6063-4ec9-95ff-50acbf609ff5" == single_workbook.views[0].id + assert "ENDANGERED SAFARI" == single_workbook.views[0].name + assert "SafariSample/sheets/ENDANGEREDSAFARI" == single_workbook.views[0].content_url + + +def test_get_by_id_missing_id(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.workbooks.get_by_id("") + + +def test_refresh_id(server: TSC.Server) -> None: + server.version = "2.8" + server.workbooks.baseurl + response_xml = REFRESH_XML.read_text() + with requests_mock.mock() as m: + m.post( + server.workbooks.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/refresh", + status_code=202, + text=response_xml, + ) + server.workbooks.refresh("3cc6cd06-89ce-4fdc-b935-5294135d6d42") + + +def test_refresh_object(server: TSC.Server) -> None: + server.version = "2.8" + server.workbooks.baseurl + workbook = TSC.WorkbookItem("") + workbook._id = "3cc6cd06-89ce-4fdc-b935-5294135d6d42" + response_xml = REFRESH_XML.read_text() + with requests_mock.mock() as m: + m.post( + server.workbooks.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/refresh", + status_code=202, + text=response_xml, + ) + server.workbooks.refresh(workbook) + + +def test_delete(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.delete(server.workbooks.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42", status_code=204) + server.workbooks.delete("3cc6cd06-89ce-4fdc-b935-5294135d6d42") + + +def test_delete_missing_id(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.workbooks.delete("") + + +def test_update(server: TSC.Server) -> None: + response_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.put(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_workbook.name = "renamedWorkbook" + single_workbook.data_acceleration_config = { + "acceleration_enabled": True, + "accelerate_now": False, + "last_updated_at": None, + "acceleration_status": None, + } + single_workbook = server.workbooks.update(single_workbook) + + assert "1f951daf-4061-451a-9df1-69a8062664f2" == single_workbook.id + assert single_workbook.show_tabs + assert "1d0304cd-3796-429f-b815-7258370b9b74" == single_workbook.project_id + assert "dd2239f6-ddf1-4107-981a-4cf94e415794" == single_workbook.owner_id + assert "renamedWorkbook" == single_workbook.name + assert single_workbook.data_acceleration_config["acceleration_enabled"] + assert not single_workbook.data_acceleration_config["accelerate_now"] + + +def test_update_missing_id(server: TSC.Server) -> None: + single_workbook = TSC.WorkbookItem("test") + with pytest.raises(TSC.MissingRequiredFieldError): + server.workbooks.update(single_workbook) + + +def test_update_copy_fields(server: TSC.Server) -> None: + connection_xml = POPULATE_CONNECTIONS_XML.read_text() + update_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/connections", text=connection_xml) + m.put(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=update_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + server.workbooks.populate_connections(single_workbook) + updated_workbook = server.workbooks.update(single_workbook) + + assert single_workbook._connections == updated_workbook._connections + assert single_workbook._views == updated_workbook._views + assert single_workbook.tags == updated_workbook.tags + assert single_workbook._initial_tags == updated_workbook._initial_tags + assert single_workbook._preview_image == updated_workbook._preview_image + + +def test_update_tags(server: TSC.Server) -> None: + add_tags_xml = ADD_TAGS_XML.read_text() + update_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.put(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/tags", text=add_tags_xml) + m.delete(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/tags/b", status_code=204) + m.delete(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/tags/d", status_code=204) + m.put(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=update_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook._initial_tags.update(["a", "b", "c", "d"]) + single_workbook.tags.update(["a", "c", "e"]) + updated_workbook = server.workbooks.update(single_workbook) + + assert single_workbook.tags == updated_workbook.tags + assert single_workbook._initial_tags == updated_workbook._initial_tags + + +def test_download(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get( + server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", + headers={"Content-Disposition": 'name="tableau_workbook"; filename="RESTAPISample.twbx"'}, + ) + file_path = server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2") + assert os.path.exists(file_path) + os.remove(file_path) - def test_get_by_id(self) -> None: - with open(GET_BY_ID_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42", text=response_xml) - single_workbook = self.server.workbooks.get_by_id("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - - self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", single_workbook.id) - self.assertEqual("SafariSample", single_workbook.name) - self.assertEqual("SafariSample", single_workbook.content_url) - self.assertEqual("http://tableauserver/#/workbooks/2/views", single_workbook.webpage_url) - self.assertEqual(False, single_workbook.show_tabs) - self.assertEqual(26, single_workbook.size) - self.assertEqual("2016-07-26T20:34:56Z", format_datetime(single_workbook.created_at)) - self.assertEqual("description for SafariSample", single_workbook.description) - self.assertEqual("2016-07-26T20:35:05Z", format_datetime(single_workbook.updated_at)) - self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_workbook.project_id) - self.assertEqual("default", single_workbook.project_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual({"Safari", "Sample"}, single_workbook.tags) - self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) - self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) - self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) - - def test_get_by_id_personal(self) -> None: - # workbooks in personal space don't have project_id or project_name - with open(GET_BY_ID_XML_PERSONAL, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d43", text=response_xml) - single_workbook = self.server.workbooks.get_by_id("3cc6cd06-89ce-4fdc-b935-5294135d6d43") - - self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d43", single_workbook.id) - self.assertEqual("SafariSample", single_workbook.name) - self.assertEqual("SafariSample", single_workbook.content_url) - self.assertEqual("http://tableauserver/#/workbooks/2/views", single_workbook.webpage_url) - self.assertEqual(False, single_workbook.show_tabs) - self.assertEqual(26, single_workbook.size) - self.assertEqual("2016-07-26T20:34:56Z", format_datetime(single_workbook.created_at)) - self.assertEqual("description for SafariSample", single_workbook.description) - self.assertEqual("2016-07-26T20:35:05Z", format_datetime(single_workbook.updated_at)) - self.assertTrue(single_workbook.project_id) - self.assertIsNone(single_workbook.project_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) - self.assertEqual({"Safari", "Sample"}, single_workbook.tags) - self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) - self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) - self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) - - def test_get_by_id_missing_id(self) -> None: - self.assertRaises(ValueError, self.server.workbooks.get_by_id, "") - - def test_refresh_id(self) -> None: - self.server.version = "2.8" - self.baseurl = self.server.workbooks.baseurl - with open(REFRESH_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/refresh", status_code=202, text=response_xml) - self.server.workbooks.refresh("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - - def test_refresh_object(self) -> None: - self.server.version = "2.8" - self.baseurl = self.server.workbooks.baseurl - workbook = TSC.WorkbookItem("") - workbook._id = "3cc6cd06-89ce-4fdc-b935-5294135d6d42" - with open(REFRESH_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/refresh", status_code=202, text=response_xml) - self.server.workbooks.refresh(workbook) - def test_delete(self) -> None: +def test_download_object(server: TSC.Server) -> None: + with BytesIO() as file_object: with requests_mock.mock() as m: - m.delete(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42", status_code=204) - self.server.workbooks.delete("3cc6cd06-89ce-4fdc-b935-5294135d6d42") + m.get( + server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", + headers={"Content-Disposition": 'name="tableau_workbook"; filename="RESTAPISample.twbx"'}, + ) + file_path = server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2", filepath=file_object) + assert isinstance(file_path, BytesIO) - def test_delete_missing_id(self) -> None: - self.assertRaises(ValueError, self.server.workbooks.delete, "") - def test_update(self) -> None: - with open(UPDATE_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) - single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - single_workbook.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - single_workbook.name = "renamedWorkbook" - single_workbook.data_acceleration_config = { - "acceleration_enabled": True, - "accelerate_now": False, - "last_updated_at": None, - "acceleration_status": None, - } - single_workbook = self.server.workbooks.update(single_workbook) - - self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) - self.assertEqual(True, single_workbook.show_tabs) - self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", single_workbook.project_id) - self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", single_workbook.owner_id) - self.assertEqual("renamedWorkbook", single_workbook.name) - self.assertEqual(True, single_workbook.data_acceleration_config["acceleration_enabled"]) - self.assertEqual(False, single_workbook.data_acceleration_config["accelerate_now"]) - - def test_update_missing_id(self) -> None: +def test_download_sanitizes_name(server: TSC.Server) -> None: + filename = "Name,With,Commas.twbx" + disposition = f'name="tableau_workbook"; filename="{filename}"' + with requests_mock.mock() as m: + m.get( + server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", + headers={"Content-Disposition": disposition}, + ) + file_path = server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2") + assert os.path.basename(file_path) == "NameWithCommas.twbx" + assert os.path.exists(file_path) + os.remove(file_path) + + +def test_download_extract_only(server: TSC.Server) -> None: + # Pretend we're 2.5 for 'extract_only' + server.version = "2.5" + server.workbooks.baseurl + + with requests_mock.mock() as m: + m.get( + server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content?includeExtract=False", + headers={"Content-Disposition": 'name="tableau_workbook"; filename="RESTAPISample.twbx"'}, + complete_qs=True, + ) + # Technically this shouldn't download a twbx, but we are interested in the qs, not the file + file_path = server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2", include_extract=False) + assert os.path.exists(file_path) + os.remove(file_path) + + +def test_download_missing_id(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.workbooks.download("") + + +def test_populate_views(server: TSC.Server) -> None: + response_xml = POPULATE_VIEWS_XML.read_text() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/views", text=response_xml) single_workbook = TSC.WorkbookItem("test") - self.assertRaises(TSC.MissingRequiredFieldError, self.server.workbooks.update, single_workbook) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + server.workbooks.populate_views(single_workbook) - def test_update_copy_fields(self) -> None: - with open(POPULATE_CONNECTIONS_XML, "rb") as f: - connection_xml = f.read().decode("utf-8") - with open(UPDATE_XML, "rb") as f: - update_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/connections", text=connection_xml) - m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=update_xml) - single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74") - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - self.server.workbooks.populate_connections(single_workbook) - updated_workbook = self.server.workbooks.update(single_workbook) - - self.assertEqual(single_workbook._connections, updated_workbook._connections) - self.assertEqual(single_workbook._views, updated_workbook._views) - self.assertEqual(single_workbook.tags, updated_workbook.tags) - self.assertEqual(single_workbook._initial_tags, updated_workbook._initial_tags) - self.assertEqual(single_workbook._preview_image, updated_workbook._preview_image) - - def test_update_tags(self) -> None: - with open(ADD_TAGS_XML, "rb") as f: - add_tags_xml = f.read().decode("utf-8") - with open(UPDATE_XML, "rb") as f: - update_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/tags", text=add_tags_xml) - m.delete(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/tags/b", status_code=204) - m.delete(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/tags/d", status_code=204) - m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=update_xml) - single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74") - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - single_workbook._initial_tags.update(["a", "b", "c", "d"]) - single_workbook.tags.update(["a", "c", "e"]) - updated_workbook = self.server.workbooks.update(single_workbook) - - self.assertEqual(single_workbook.tags, updated_workbook.tags) - self.assertEqual(single_workbook._initial_tags, updated_workbook._initial_tags) - - def test_download(self) -> None: - with requests_mock.mock() as m: - m.get( - self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", - headers={"Content-Disposition": 'name="tableau_workbook"; filename="RESTAPISample.twbx"'}, - ) - file_path = self.server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2") - self.assertTrue(os.path.exists(file_path)) - os.remove(file_path) - - def test_download_object(self) -> None: - with BytesIO() as file_object: - with requests_mock.mock() as m: - m.get( - self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", - headers={"Content-Disposition": 'name="tableau_workbook"; filename="RESTAPISample.twbx"'}, - ) - file_path = self.server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2", filepath=file_object) - self.assertTrue(isinstance(file_path, BytesIO)) - - def test_download_sanitizes_name(self) -> None: - filename = "Name,With,Commas.twbx" - disposition = f'name="tableau_workbook"; filename="{filename}"' - with requests_mock.mock() as m: - m.get( - self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", - headers={"Content-Disposition": disposition}, - ) - file_path = self.server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2") - self.assertEqual(os.path.basename(file_path), "NameWithCommas.twbx") - self.assertTrue(os.path.exists(file_path)) - os.remove(file_path) + views_list = single_workbook.views + assert "097dbe13-de89-445f-b2c3-02f28bd010c1" == views_list[0].id + assert "GDP per capita" == views_list[0].name + assert "RESTAPISample/sheets/GDPpercapita" == views_list[0].content_url - def test_download_extract_only(self) -> None: - # Pretend we're 2.5 for 'extract_only' - self.server.version = "2.5" - self.baseurl = self.server.workbooks.baseurl + assert "2c1ab9d7-8d64-4cc6-b495-52e40c60c330" == views_list[1].id + assert "Country ranks" == views_list[1].name + assert "RESTAPISample/sheets/Countryranks" == views_list[1].content_url - with requests_mock.mock() as m: - m.get( - self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content?includeExtract=False", - headers={"Content-Disposition": 'name="tableau_workbook"; filename="RESTAPISample.twbx"'}, - complete_qs=True, - ) - # Technically this shouldn't download a twbx, but we are interested in the qs, not the file - file_path = self.server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2", include_extract=False) - self.assertTrue(os.path.exists(file_path)) - os.remove(file_path) + assert "0599c28c-6d82-457e-a453-e52c1bdb00f5" == views_list[2].id + assert "Interest rates" == views_list[2].name + assert "RESTAPISample/sheets/Interestrates" == views_list[2].content_url - def test_download_missing_id(self) -> None: - self.assertRaises(ValueError, self.server.workbooks.download, "") - def test_populate_views(self) -> None: - with open(POPULATE_VIEWS_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/views", text=response_xml) - single_workbook = TSC.WorkbookItem("test") - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - self.server.workbooks.populate_views(single_workbook) - - views_list = single_workbook.views - self.assertEqual("097dbe13-de89-445f-b2c3-02f28bd010c1", views_list[0].id) - self.assertEqual("GDP per capita", views_list[0].name) - self.assertEqual("RESTAPISample/sheets/GDPpercapita", views_list[0].content_url) - - self.assertEqual("2c1ab9d7-8d64-4cc6-b495-52e40c60c330", views_list[1].id) - self.assertEqual("Country ranks", views_list[1].name) - self.assertEqual("RESTAPISample/sheets/Countryranks", views_list[1].content_url) - - self.assertEqual("0599c28c-6d82-457e-a453-e52c1bdb00f5", views_list[2].id) - self.assertEqual("Interest rates", views_list[2].name) - self.assertEqual("RESTAPISample/sheets/Interestrates", views_list[2].content_url) - - def test_populate_views_with_usage(self) -> None: - with open(POPULATE_VIEWS_USAGE_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get( - self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/views?includeUsageStatistics=true", - text=response_xml, - ) - single_workbook = TSC.WorkbookItem("test") - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - self.server.workbooks.populate_views(single_workbook, usage=True) - - views_list = single_workbook.views - self.assertEqual("097dbe13-de89-445f-b2c3-02f28bd010c1", views_list[0].id) - self.assertEqual(2, views_list[0].total_views) - self.assertEqual("2c1ab9d7-8d64-4cc6-b495-52e40c60c330", views_list[1].id) - self.assertEqual(37, views_list[1].total_views) - self.assertEqual("0599c28c-6d82-457e-a453-e52c1bdb00f5", views_list[2].id) - self.assertEqual(0, views_list[2].total_views) - - def test_populate_views_missing_id(self) -> None: +def test_populate_views_with_usage(server: TSC.Server) -> None: + response_xml = POPULATE_VIEWS_USAGE_XML.read_text() + with requests_mock.mock() as m: + m.get( + server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/views?includeUsageStatistics=true", + text=response_xml, + ) single_workbook = TSC.WorkbookItem("test") - self.assertRaises(TSC.MissingRequiredFieldError, self.server.workbooks.populate_views, single_workbook) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + server.workbooks.populate_views(single_workbook, usage=True) - def test_populate_connections(self) -> None: - with open(POPULATE_CONNECTIONS_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/connections", text=response_xml) - single_workbook = TSC.WorkbookItem("test") - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - self.server.workbooks.populate_connections(single_workbook) - - self.assertEqual("37ca6ced-58d7-4dcf-99dc-f0a85223cbef", single_workbook.connections[0].id) - self.assertEqual("dataengine", single_workbook.connections[0].connection_type) - self.assertEqual("4506225a-0d32-4ab1-82d3-c24e85f7afba", single_workbook.connections[0].datasource_id) - self.assertEqual("World Indicators", single_workbook.connections[0].datasource_name) - - def test_populate_permissions(self) -> None: - with open(POPULATE_PERMISSIONS_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) - single_workbook = TSC.WorkbookItem("test") - single_workbook._id = "21778de4-b7b9-44bc-a599-1506a2639ace" - - self.server.workbooks.populate_permissions(single_workbook) - permissions = single_workbook.permissions - - self.assertEqual(permissions[0].grantee.tag_name, "group") - self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af") - self.assertDictEqual( - permissions[0].capabilities, - { - TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, - }, - ) + views_list = single_workbook.views + assert "097dbe13-de89-445f-b2c3-02f28bd010c1" == views_list[0].id + assert 2 == views_list[0].total_views + assert "2c1ab9d7-8d64-4cc6-b495-52e40c60c330" == views_list[1].id + assert 37 == views_list[1].total_views + assert "0599c28c-6d82-457e-a453-e52c1bdb00f5" == views_list[2].id + assert 0 == views_list[2].total_views - self.assertEqual(permissions[1].grantee.tag_name, "user") - self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a") - self.assertDictEqual( - permissions[1].capabilities, - { - TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Deny, - }, - ) - def test_add_permissions(self) -> None: - with open(UPDATE_PERMISSIONS, "rb") as f: - response_xml = f.read().decode("utf-8") +def test_populate_views_missing_id(server: TSC.Server) -> None: + single_workbook = TSC.WorkbookItem("test") + with pytest.raises(TSC.MissingRequiredFieldError): + server.workbooks.populate_views(single_workbook) + +def test_populate_connections(server: TSC.Server) -> None: + response_xml = POPULATE_CONNECTIONS_XML.read_text() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/connections", text=response_xml) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + server.workbooks.populate_connections(single_workbook) + + assert "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" == single_workbook.connections[0].id + assert "dataengine" == single_workbook.connections[0].connection_type + assert "4506225a-0d32-4ab1-82d3-c24e85f7afba" == single_workbook.connections[0].datasource_id + assert "World Indicators" == single_workbook.connections[0].datasource_name + + +def test_populate_permissions(server: TSC.Server) -> None: + response_xml = POPULATE_PERMISSIONS_XML.read_text() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) single_workbook = TSC.WorkbookItem("test") single_workbook._id = "21778de4-b7b9-44bc-a599-1506a2639ace" - bob = UserItem.as_reference("7c37ee24-c4b1-42b6-a154-eaeab7ee330a") - group_of_people = GroupItem.as_reference("5e5e1978-71fa-11e4-87dd-7382f5c437af") + server.workbooks.populate_permissions(single_workbook) + permissions = single_workbook.permissions + + assert permissions[0].grantee.tag_name == "group" + assert permissions[0].grantee.id == "5e5e1978-71fa-11e4-87dd-7382f5c437af" + assert permissions[0].capabilities == { + TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, + } + + assert permissions[1].grantee.tag_name == "user" + assert permissions[1].grantee.id == "7c37ee24-c4b1-42b6-a154-eaeab7ee330a" + assert permissions[1].capabilities == { + TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Deny, + } + + +def test_add_permissions(server: TSC.Server) -> None: + response_xml = UPDATE_PERMISSIONS.read_text() + + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "21778de4-b7b9-44bc-a599-1506a2639ace" + + bob = UserItem.as_reference("7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + group_of_people = GroupItem.as_reference("5e5e1978-71fa-11e4-87dd-7382f5c437af") + + new_permissions = [PermissionsRule(bob, {"Write": "Allow"}), PermissionsRule(group_of_people, {"Read": "Deny"})] + + with requests_mock.mock() as m: + m.put(server.workbooks.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) + permissions = server.workbooks.update_permissions(single_workbook, new_permissions) + + assert permissions[0].grantee.tag_name == "group" + assert permissions[0].grantee.id == "5e5e1978-71fa-11e4-87dd-7382f5c437af" + assert permissions[0].capabilities == {TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny} + assert permissions[1].grantee.tag_name == "user" + assert permissions[1].grantee.id == "7c37ee24-c4b1-42b6-a154-eaeab7ee330a" + assert permissions[1].capabilities == {TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow} + + +def test_populate_connections_missing_id(server: TSC.Server) -> None: + single_workbook = TSC.WorkbookItem("test") + with pytest.raises(TSC.MissingRequiredFieldError): + server.workbooks.populate_connections(single_workbook) + + +def test_populate_pdf(server: TSC.Server) -> None: + server.version = "3.4" + server.workbooks.baseurl + response = POPULATE_PDF.read_bytes() + with requests_mock.mock() as m: + m.get( + server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape", + content=response, + ) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + + type = TSC.PDFRequestOptions.PageType.A5 + orientation = TSC.PDFRequestOptions.Orientation.Landscape + req_option = TSC.PDFRequestOptions(type, orientation) - new_permissions = [PermissionsRule(bob, {"Write": "Allow"}), PermissionsRule(group_of_people, {"Read": "Deny"})] + server.workbooks.populate_pdf(single_workbook, req_option) + assert response == single_workbook.pdf - with requests_mock.mock() as m: - m.put(self.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) - permissions = self.server.workbooks.update_permissions(single_workbook, new_permissions) - self.assertEqual(permissions[0].grantee.tag_name, "group") - self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af") - self.assertDictEqual(permissions[0].capabilities, {TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny}) +def test_populate_pdf_unsupported(server: TSC.Server) -> None: + server.version = "3.4" + server.workbooks.baseurl + with requests_mock.mock() as m: + m.get( + server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape", + content=b"", + ) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + + type = TSC.PDFRequestOptions.PageType.A5 + orientation = TSC.PDFRequestOptions.Orientation.Landscape + req_option = TSC.PDFRequestOptions(type, orientation) + req_option.vf("Region", "West") + + with pytest.raises(UnsupportedAttributeError): + server.workbooks.populate_pdf(single_workbook, req_option) + + +def test_populate_pdf_vf_dims(server: TSC.Server) -> None: + server.version = "3.23" + server.workbooks.baseurl + response = POPULATE_PDF.read_bytes() + with requests_mock.mock() as m: + m.get( + server.workbooks.baseurl + + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape&vf_Region=West&vizWidth=1920&vizHeight=1080", + content=response, + ) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + + type = TSC.PDFRequestOptions.PageType.A5 + orientation = TSC.PDFRequestOptions.Orientation.Landscape + req_option = TSC.PDFRequestOptions(type, orientation) + req_option.vf("Region", "West") + req_option.viz_width = 1920 + req_option.viz_height = 1080 - self.assertEqual(permissions[1].grantee.tag_name, "user") - self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a") - self.assertDictEqual(permissions[1].capabilities, {TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow}) + server.workbooks.populate_pdf(single_workbook, req_option) + assert response == single_workbook.pdf - def test_populate_connections_missing_id(self) -> None: + +def test_populate_powerpoint(server: TSC.Server) -> None: + server.version = "3.8" + server.workbooks.baseurl + response = POPULATE_POWERPOINT.read_bytes() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/powerpoint?maxAge=1", content=response) single_workbook = TSC.WorkbookItem("test") - self.assertRaises(TSC.MissingRequiredFieldError, self.server.workbooks.populate_connections, single_workbook) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - def test_populate_pdf(self) -> None: - self.server.version = "3.4" - self.baseurl = self.server.workbooks.baseurl - with open(POPULATE_PDF, "rb") as f: - response = f.read() - with requests_mock.mock() as m: - m.get( - self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape", - content=response, - ) - single_workbook = TSC.WorkbookItem("test") - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + ro = TSC.PPTXRequestOptions(maxage=1) - type = TSC.PDFRequestOptions.PageType.A5 - orientation = TSC.PDFRequestOptions.Orientation.Landscape - req_option = TSC.PDFRequestOptions(type, orientation) + server.workbooks.populate_powerpoint(single_workbook, ro) + assert response == single_workbook.powerpoint - self.server.workbooks.populate_pdf(single_workbook, req_option) - self.assertEqual(response, single_workbook.pdf) - def test_populate_pdf_unsupported(self) -> None: - self.server.version = "3.4" - self.baseurl = self.server.workbooks.baseurl - with requests_mock.mock() as m: - m.get( - self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape", - content=b"", - ) - single_workbook = TSC.WorkbookItem("test") - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - - type = TSC.PDFRequestOptions.PageType.A5 - orientation = TSC.PDFRequestOptions.Orientation.Landscape - req_option = TSC.PDFRequestOptions(type, orientation) - req_option.vf("Region", "West") - - with self.assertRaises(UnsupportedAttributeError): - self.server.workbooks.populate_pdf(single_workbook, req_option) - - def test_populate_pdf_vf_dims(self) -> None: - self.server.version = "3.23" - self.baseurl = self.server.workbooks.baseurl - with open(POPULATE_PDF, "rb") as f: - response = f.read() - with requests_mock.mock() as m: - m.get( - self.baseurl - + "/1f951daf-4061-451a-9df1-69a8062664f2/pdf?type=a5&orientation=landscape&vf_Region=West&vizWidth=1920&vizHeight=1080", - content=response, - ) - single_workbook = TSC.WorkbookItem("test") - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - - type = TSC.PDFRequestOptions.PageType.A5 - orientation = TSC.PDFRequestOptions.Orientation.Landscape - req_option = TSC.PDFRequestOptions(type, orientation) - req_option.vf("Region", "West") - req_option.viz_width = 1920 - req_option.viz_height = 1080 - - self.server.workbooks.populate_pdf(single_workbook, req_option) - self.assertEqual(response, single_workbook.pdf) - - def test_populate_powerpoint(self) -> None: - self.server.version = "3.8" - self.baseurl = self.server.workbooks.baseurl - with open(POPULATE_POWERPOINT, "rb") as f: - response = f.read() - with requests_mock.mock() as m: - m.get( - self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/powerpoint?maxAge=1", - content=response, - ) - single_workbook = TSC.WorkbookItem("test") - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" +def test_populate_preview_image(server: TSC.Server) -> None: + response = POPULATE_PREVIEW_IMAGE.read_bytes() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/previewImage", content=response) + single_workbook = TSC.WorkbookItem("test") + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + server.workbooks.populate_preview_image(single_workbook) - ro = TSC.PPTXRequestOptions(maxage=1) + assert response == single_workbook.preview_image - self.server.workbooks.populate_powerpoint(single_workbook, ro) - self.assertEqual(response, single_workbook.powerpoint) - def test_populate_preview_image(self) -> None: - with open(POPULATE_PREVIEW_IMAGE, "rb") as f: - response = f.read() - with requests_mock.mock() as m: - m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/previewImage", content=response) - single_workbook = TSC.WorkbookItem("test") - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - self.server.workbooks.populate_preview_image(single_workbook) +def test_populate_preview_image_missing_id(server: TSC.Server) -> None: + single_workbook = TSC.WorkbookItem("test") + with pytest.raises(TSC.MissingRequiredFieldError): + server.workbooks.populate_preview_image(single_workbook) - self.assertEqual(response, single_workbook.preview_image) - def test_populate_preview_image_missing_id(self) -> None: - single_workbook = TSC.WorkbookItem("test") - self.assertRaises(TSC.MissingRequiredFieldError, self.server.workbooks.populate_preview_image, single_workbook) +def test_publish(server: TSC.Server) -> None: + response_xml = PUBLISH_XML.read_text() + with requests_mock.mock() as m: + m.post(server.workbooks.baseurl, text=response_xml) - def test_publish(self) -> None: - with open(PUBLISH_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) + new_workbook.description = "REST API Testing" + + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") + publish_mode = server.PublishMode.CreateNew + + new_workbook = server.workbooks.publish(new_workbook, sample_workbook, publish_mode) + assert "a8076ca1-e9d8-495e-bae6-c684dbb55836" == new_workbook.id + assert "RESTAPISample" == new_workbook.name + assert "RESTAPISample_0" == new_workbook.content_url + assert not new_workbook.show_tabs + assert 1 == new_workbook.size + assert "2016-08-18T18:33:24Z" == format_datetime(new_workbook.created_at) + assert "2016-08-18T20:31:34Z" == format_datetime(new_workbook.updated_at) + assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == new_workbook.project_id + assert "default" == new_workbook.project_name + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == new_workbook.owner_id + assert "fe0b4e89-73f4-435e-952d-3a263fbfa56c" == new_workbook.views[0].id + assert "GDP per capita" == new_workbook.views[0].name + assert "RESTAPISample_0/sheets/GDPpercapita" == new_workbook.views[0].content_url + assert "REST API Testing" == new_workbook.description + + +def test_publish_a_packaged_file_object(server: TSC.Server) -> None: + response_xml = PUBLISH_XML.read_text() + with requests_mock.mock() as m: + m.post(server.workbooks.baseurl, text=response_xml) - new_workbook.description = "REST API Testing" - - sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") - publish_mode = self.server.PublishMode.CreateNew - - new_workbook = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode) - - self.assertEqual("a8076ca1-e9d8-495e-bae6-c684dbb55836", new_workbook.id) - self.assertEqual("RESTAPISample", new_workbook.name) - self.assertEqual("RESTAPISample_0", new_workbook.content_url) - self.assertEqual(False, new_workbook.show_tabs) - self.assertEqual(1, new_workbook.size) - self.assertEqual("2016-08-18T18:33:24Z", format_datetime(new_workbook.created_at)) - self.assertEqual("2016-08-18T20:31:34Z", format_datetime(new_workbook.updated_at)) - self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_workbook.project_id) - self.assertEqual("default", new_workbook.project_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_workbook.owner_id) - self.assertEqual("fe0b4e89-73f4-435e-952d-3a263fbfa56c", new_workbook.views[0].id) - self.assertEqual("GDP per capita", new_workbook.views[0].name) - self.assertEqual("RESTAPISample_0/sheets/GDPpercapita", new_workbook.views[0].content_url) - self.assertEqual("REST API Testing", new_workbook.description) - - def test_publish_a_packaged_file_object(self) -> None: - with open(PUBLISH_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") - sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") - - with open(sample_workbook, "rb") as fp: - publish_mode = self.server.PublishMode.CreateNew - - new_workbook = self.server.workbooks.publish(new_workbook, fp, publish_mode) - - self.assertEqual("a8076ca1-e9d8-495e-bae6-c684dbb55836", new_workbook.id) - self.assertEqual("RESTAPISample", new_workbook.name) - self.assertEqual("RESTAPISample_0", new_workbook.content_url) - self.assertEqual(False, new_workbook.show_tabs) - self.assertEqual(1, new_workbook.size) - self.assertEqual("2016-08-18T18:33:24Z", format_datetime(new_workbook.created_at)) - self.assertEqual("2016-08-18T20:31:34Z", format_datetime(new_workbook.updated_at)) - self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_workbook.project_id) - self.assertEqual("default", new_workbook.project_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_workbook.owner_id) - self.assertEqual("fe0b4e89-73f4-435e-952d-3a263fbfa56c", new_workbook.views[0].id) - self.assertEqual("GDP per capita", new_workbook.views[0].name) - self.assertEqual("RESTAPISample_0/sheets/GDPpercapita", new_workbook.views[0].content_url) - - def test_publish_non_packeged_file_object(self) -> None: - with open(PUBLISH_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) + with open(sample_workbook, "rb") as fp: + publish_mode = server.PublishMode.CreateNew - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) + new_workbook = server.workbooks.publish(new_workbook, fp, publish_mode) - sample_workbook = os.path.join(TEST_ASSET_DIR, "RESTAPISample.twb") - - with open(sample_workbook, "rb") as fp: - publish_mode = self.server.PublishMode.CreateNew - - new_workbook = self.server.workbooks.publish(new_workbook, fp, publish_mode) - - self.assertEqual("a8076ca1-e9d8-495e-bae6-c684dbb55836", new_workbook.id) - self.assertEqual("RESTAPISample", new_workbook.name) - self.assertEqual("RESTAPISample_0", new_workbook.content_url) - self.assertEqual(False, new_workbook.show_tabs) - self.assertEqual(1, new_workbook.size) - self.assertEqual("2016-08-18T18:33:24Z", format_datetime(new_workbook.created_at)) - self.assertEqual("2016-08-18T20:31:34Z", format_datetime(new_workbook.updated_at)) - self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_workbook.project_id) - self.assertEqual("default", new_workbook.project_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_workbook.owner_id) - self.assertEqual("fe0b4e89-73f4-435e-952d-3a263fbfa56c", new_workbook.views[0].id) - self.assertEqual("GDP per capita", new_workbook.views[0].name) - self.assertEqual("RESTAPISample_0/sheets/GDPpercapita", new_workbook.views[0].content_url) - - def test_publish_path_object(self) -> None: - with open(PUBLISH_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) + assert "a8076ca1-e9d8-495e-bae6-c684dbb55836" == new_workbook.id + assert "RESTAPISample" == new_workbook.name + assert "RESTAPISample_0" == new_workbook.content_url + assert not new_workbook.show_tabs + assert 1 == new_workbook.size + assert "2016-08-18T18:33:24Z" == format_datetime(new_workbook.created_at) + assert "2016-08-18T20:31:34Z" == format_datetime(new_workbook.updated_at) + assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == new_workbook.project_id + assert "default" == new_workbook.project_name + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == new_workbook.owner_id + assert "fe0b4e89-73f4-435e-952d-3a263fbfa56c" == new_workbook.views[0].id + assert "GDP per capita" == new_workbook.views[0].name + assert "RESTAPISample_0/sheets/GDPpercapita" == new_workbook.views[0].content_url - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) - sample_workbook = Path(TEST_ASSET_DIR) / "SampleWB.twbx" - publish_mode = self.server.PublishMode.CreateNew - - new_workbook = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode) - - self.assertEqual("a8076ca1-e9d8-495e-bae6-c684dbb55836", new_workbook.id) - self.assertEqual("RESTAPISample", new_workbook.name) - self.assertEqual("RESTAPISample_0", new_workbook.content_url) - self.assertEqual(False, new_workbook.show_tabs) - self.assertEqual(1, new_workbook.size) - self.assertEqual("2016-08-18T18:33:24Z", format_datetime(new_workbook.created_at)) - self.assertEqual("2016-08-18T20:31:34Z", format_datetime(new_workbook.updated_at)) - self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_workbook.project_id) - self.assertEqual("default", new_workbook.project_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_workbook.owner_id) - self.assertEqual("fe0b4e89-73f4-435e-952d-3a263fbfa56c", new_workbook.views[0].id) - self.assertEqual("GDP per capita", new_workbook.views[0].name) - self.assertEqual("RESTAPISample_0/sheets/GDPpercapita", new_workbook.views[0].content_url) - - def test_publish_with_hidden_views_on_workbook(self) -> None: - with open(PUBLISH_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) +def test_publish_non_packeged_file_object(server: TSC.Server) -> None: + response_xml = PUBLISH_XML.read_text() + with requests_mock.mock() as m: + m.post(server.workbooks.baseurl, text=response_xml) - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) - sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") - publish_mode = self.server.PublishMode.CreateNew + sample_workbook = os.path.join(TEST_ASSET_DIR, "RESTAPISample.twb") - new_workbook.hidden_views = ["GDP per capita"] - new_workbook = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode) - request_body = m._adapter.request_history[0]._request.body - # order of attributes in xml is unspecified - self.assertTrue(re.search(rb"<\/views>", request_body)) - self.assertTrue(re.search(rb"<\/views>", request_body)) + with open(sample_workbook, "rb") as fp: + publish_mode = server.PublishMode.CreateNew - def test_publish_with_thumbnails_user_id(self) -> None: - with open(PUBLISH_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) + new_workbook = server.workbooks.publish(new_workbook, fp, publish_mode) - new_workbook = TSC.WorkbookItem( - name="Sample", - show_tabs=False, - project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", - thumbnails_user_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20761", - ) + assert "a8076ca1-e9d8-495e-bae6-c684dbb55836" == new_workbook.id + assert "RESTAPISample" == new_workbook.name + assert "RESTAPISample_0" == new_workbook.content_url + assert not new_workbook.show_tabs + assert 1 == new_workbook.size + assert "2016-08-18T18:33:24Z" == format_datetime(new_workbook.created_at) + assert "2016-08-18T20:31:34Z" == format_datetime(new_workbook.updated_at) + assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == new_workbook.project_id + assert "default" == new_workbook.project_name + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == new_workbook.owner_id + assert "fe0b4e89-73f4-435e-952d-3a263fbfa56c" == new_workbook.views[0].id + assert "GDP per capita" == new_workbook.views[0].name + assert "RESTAPISample_0/sheets/GDPpercapita" == new_workbook.views[0].content_url - sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") - publish_mode = self.server.PublishMode.CreateNew - new_workbook = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode) - request_body = m._adapter.request_history[0]._request.body - # order of attributes in xml is unspecified - self.assertTrue(re.search(rb"thumbnailsUserId=\"ee8c6e70-43b6-11e6-af4f-f7b0d8e20761\"", request_body)) - def test_publish_with_thumbnails_group_id(self) -> None: - with open(PUBLISH_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) +def test_publish_path_object(server: TSC.Server) -> None: + response_xml = PUBLISH_XML.read_text() + with requests_mock.mock() as m: + m.post(server.workbooks.baseurl, text=response_xml) - new_workbook = TSC.WorkbookItem( - name="Sample", - show_tabs=False, - project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", - thumbnails_group_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20762", - ) + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) - sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") - publish_mode = self.server.PublishMode.CreateNew - new_workbook = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode) - request_body = m._adapter.request_history[0]._request.body - self.assertTrue(re.search(rb"thumbnailsGroupId=\"ee8c6e70-43b6-11e6-af4f-f7b0d8e20762\"", request_body)) + sample_workbook = Path(TEST_ASSET_DIR) / "SampleWB.twbx" + publish_mode = server.PublishMode.CreateNew - @pytest.mark.filterwarnings("ignore:'as_job' not available") - def test_publish_with_query_params(self) -> None: - with open(PUBLISH_ASYNC_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) + new_workbook = server.workbooks.publish(new_workbook, sample_workbook, publish_mode) - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) + assert "a8076ca1-e9d8-495e-bae6-c684dbb55836" == new_workbook.id + assert "RESTAPISample" == new_workbook.name + assert "RESTAPISample_0" == new_workbook.content_url + assert not new_workbook.show_tabs + assert 1 == new_workbook.size + assert "2016-08-18T18:33:24Z" == format_datetime(new_workbook.created_at) + assert "2016-08-18T20:31:34Z" == format_datetime(new_workbook.updated_at) + assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == new_workbook.project_id + assert "default" == new_workbook.project_name + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == new_workbook.owner_id + assert "fe0b4e89-73f4-435e-952d-3a263fbfa56c" == new_workbook.views[0].id + assert "GDP per capita" == new_workbook.views[0].name + assert "RESTAPISample_0/sheets/GDPpercapita" == new_workbook.views[0].content_url - sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") - publish_mode = self.server.PublishMode.CreateNew - self.server.workbooks.publish( - new_workbook, sample_workbook, publish_mode, as_job=True, skip_connection_check=True - ) +def test_publish_with_hidden_views_on_workbook(server: TSC.Server) -> None: + response_xml = PUBLISH_XML.read_text() + with requests_mock.mock() as m: + m.post(server.workbooks.baseurl, text=response_xml) - request_query_params = m._adapter.request_history[0].qs - self.assertTrue("asjob" in request_query_params) - self.assertTrue(request_query_params["asjob"]) - self.assertTrue("skipconnectioncheck" in request_query_params) - self.assertTrue(request_query_params["skipconnectioncheck"]) - - def test_publish_async(self) -> None: - self.server.version = "3.0" - baseurl = self.server.workbooks.baseurl - with open(PUBLISH_ASYNC_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(baseurl, text=response_xml) + new_workbook = TSC.WorkbookItem( + name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + ) - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") + publish_mode = server.PublishMode.CreateNew + + new_workbook.hidden_views = ["GDP per capita"] + new_workbook = server.workbooks.publish(new_workbook, sample_workbook, publish_mode) + request_body = m._adapter.request_history[0]._request.body + # order of attributes in xml is unspecified + assert re.search(b'<\\/views>', request_body) + assert re.search(b'<\\/views>', request_body) - sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") - publish_mode = self.server.PublishMode.CreateNew - - new_job = self.server.workbooks.publish(new_workbook, sample_workbook, publish_mode, as_job=True) - - self.assertEqual("7c3d599e-949f-44c3-94a1-f30ba85757e4", new_job.id) - self.assertEqual("PublishWorkbook", new_job.type) - self.assertEqual("0", new_job.progress) - self.assertEqual("2018-06-29T23:22:32Z", format_datetime(new_job.created_at)) - self.assertEqual(1, new_job.finish_code) - - def test_publish_invalid_file(self) -> None: - new_workbook = TSC.WorkbookItem("test", "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - self.assertRaises(IOError, self.server.workbooks.publish, new_workbook, ".", self.server.PublishMode.CreateNew) - - def test_publish_invalid_file_type(self) -> None: - new_workbook = TSC.WorkbookItem("test", "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - self.assertRaises( - ValueError, - self.server.workbooks.publish, - new_workbook, - os.path.join(TEST_ASSET_DIR, "SampleDS.tds"), - self.server.PublishMode.CreateNew, + +def test_publish_with_thumbnails_user_id(server: TSC.Server) -> None: + response_xml = PUBLISH_XML.read_text() + with requests_mock.mock() as m: + m.post(server.workbooks.baseurl, text=response_xml) + + new_workbook = TSC.WorkbookItem( + name="Sample", + show_tabs=False, + project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", + thumbnails_user_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20761", ) - def test_publish_unnamed_file_object(self) -> None: - new_workbook = TSC.WorkbookItem("test") + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") + publish_mode = server.PublishMode.CreateNew + new_workbook = server.workbooks.publish(new_workbook, sample_workbook, publish_mode) + request_body = m._adapter.request_history[0]._request.body + # order of attributes in xml is unspecified + assert re.search(b'thumbnailsUserId=\\"ee8c6e70-43b6-11e6-af4f-f7b0d8e20761\\"', request_body) - with open(os.path.join(TEST_ASSET_DIR, "SampleWB.twbx"), "rb") as f: - self.assertRaises( - ValueError, self.server.workbooks.publish, new_workbook, f, self.server.PublishMode.CreateNew - ) - def test_publish_non_bytes_file_object(self) -> None: - new_workbook = TSC.WorkbookItem("test") +def test_publish_with_thumbnails_group_id(server: TSC.Server) -> None: + response_xml = PUBLISH_XML.read_text() + with requests_mock.mock() as m: + m.post(server.workbooks.baseurl, text=response_xml) - with open(os.path.join(TEST_ASSET_DIR, "SampleWB.twbx")) as f: - self.assertRaises( - TypeError, self.server.workbooks.publish, new_workbook, f, self.server.PublishMode.CreateNew - ) + new_workbook = TSC.WorkbookItem( + name="Sample", + show_tabs=False, + project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", + thumbnails_group_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20762", + ) - def test_publish_file_object_of_unknown_type_raises_exception(self) -> None: - new_workbook = TSC.WorkbookItem("test") - with BytesIO() as file_object: - file_object.write(bytes.fromhex("89504E470D0A1A0A")) - file_object.seek(0) - self.assertRaises( - ValueError, self.server.workbooks.publish, new_workbook, file_object, self.server.PublishMode.CreateNew - ) + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") + publish_mode = server.PublishMode.CreateNew + new_workbook = server.workbooks.publish(new_workbook, sample_workbook, publish_mode) + request_body = m._adapter.request_history[0]._request.body + assert re.search(b'thumbnailsGroupId=\\"ee8c6e70-43b6-11e6-af4f-f7b0d8e20762\\"', request_body) + + +@pytest.mark.filterwarnings("ignore:'as_job' not available") +def test_publish_with_query_params(server: TSC.Server) -> None: + response_xml = PUBLISH_ASYNC_XML.read_text() + with requests_mock.mock() as m: + m.post(server.workbooks.baseurl, text=response_xml) - def test_publish_multi_connection(self) -> None: new_workbook = TSC.WorkbookItem( name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" ) - connection1 = TSC.ConnectionItem() - connection1.server_address = "mysql.test.com" - connection1.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) - connection2 = TSC.ConnectionItem() - connection2.server_address = "pgsql.test.com" - connection2.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) - - response = RequestFactory.Workbook._generate_xml(new_workbook, connections=[connection1, connection2]) - # Can't use ConnectionItem parser due to xml namespace problems - connection_results = fromstring(response).findall(".//connection") - - self.assertEqual(connection_results[0].get("serverAddress", None), "mysql.test.com") - self.assertEqual(connection_results[0].find("connectionCredentials").get("name", None), "test") # type: ignore[union-attr] - self.assertEqual(connection_results[1].get("serverAddress", None), "pgsql.test.com") - self.assertEqual(connection_results[1].find("connectionCredentials").get("password", None), "secret") # type: ignore[union-attr] - - def test_publish_multi_connection_flat(self) -> None: + + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") + publish_mode = server.PublishMode.CreateNew + + server.workbooks.publish(new_workbook, sample_workbook, publish_mode, as_job=True, skip_connection_check=True) + + request_query_params = m._adapter.request_history[0].qs + assert "asjob" in request_query_params + assert request_query_params["asjob"] + assert "skipconnectioncheck" in request_query_params + assert request_query_params["skipconnectioncheck"] + + +def test_publish_async(server: TSC.Server) -> None: + server.version = "3.0" + baseurl = server.workbooks.baseurl + response_xml = PUBLISH_ASYNC_XML.read_text() + with requests_mock.mock() as m: + m.post(baseurl, text=response_xml) + new_workbook = TSC.WorkbookItem( name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" ) - connection1 = TSC.ConnectionItem() - connection1.server_address = "mysql.test.com" - connection1.username = "test" - connection1.password = "secret" - connection1.embed_password = True - connection2 = TSC.ConnectionItem() - connection2.server_address = "pgsql.test.com" - connection2.username = "test" - connection2.password = "secret" - connection2.embed_password = True - - response = RequestFactory.Workbook._generate_xml(new_workbook, connections=[connection1, connection2]) - # Can't use ConnectionItem parser due to xml namespace problems - connection_results = fromstring(response).findall(".//connection") - - self.assertEqual(connection_results[0].get("serverAddress", None), "mysql.test.com") - self.assertEqual(connection_results[0].find("connectionCredentials").get("name", None), "test") # type: ignore[union-attr] - self.assertEqual(connection_results[1].get("serverAddress", None), "pgsql.test.com") - self.assertEqual(connection_results[1].find("connectionCredentials").get("password", None), "secret") # type: ignore[union-attr] - - def test_synchronous_publish_timeout_error(self) -> None: - with requests_mock.mock() as m: - m.register_uri("POST", self.baseurl, status_code=504) - - new_workbook = TSC.WorkbookItem(project_id="") - publish_mode = self.server.PublishMode.CreateNew - - self.assertRaisesRegex( - InternalServerError, - "Please use asynchronous publishing to avoid timeouts", - self.server.workbooks.publish, - new_workbook, - asset("SampleWB.twbx"), - publish_mode, - ) - def test_delete_extracts_all(self) -> None: - self.server.version = "3.10" - self.baseurl = self.server.workbooks.baseurl + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") + publish_mode = server.PublishMode.CreateNew - with open(PUBLISH_ASYNC_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post( - self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/deleteExtract", status_code=200, text=response_xml - ) - self.server.workbooks.delete_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42") + new_job = server.workbooks.publish(new_workbook, sample_workbook, publish_mode, as_job=True) - def test_create_extracts_all(self) -> None: - self.server.version = "3.10" - self.baseurl = self.server.workbooks.baseurl + assert "7c3d599e-949f-44c3-94a1-f30ba85757e4" == new_job.id + assert "PublishWorkbook" == new_job.type + assert "0" == new_job.progress + assert "2018-06-29T23:22:32Z" == format_datetime(new_job.created_at) + assert 1 == new_job.finish_code - with open(PUBLISH_ASYNC_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post( - self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract", status_code=200, text=response_xml - ) - self.server.workbooks.create_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - def test_create_extracts_one(self) -> None: - self.server.version = "3.10" - self.baseurl = self.server.workbooks.baseurl +def test_publish_invalid_file(server: TSC.Server) -> None: + new_workbook = TSC.WorkbookItem("test", "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + with pytest.raises(IOError): + server.workbooks.publish(new_workbook, ".", server.PublishMode.CreateNew) - datasource = TSC.DatasourceItem("test") - datasource._id = "1f951daf-4061-451a-9df1-69a8062664f2" - with open(PUBLISH_ASYNC_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post( - self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract", status_code=200, text=response_xml - ) - self.server.workbooks.create_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42", False, datasource) +def test_publish_invalid_file_type(server: TSC.Server) -> None: + new_workbook = TSC.WorkbookItem("test", "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + with pytest.raises(ValueError): + server.workbooks.publish( + new_workbook, os.path.join(TEST_ASSET_DIR, "SampleDS.tds"), server.PublishMode.CreateNew + ) - def test_revisions(self) -> None: - self.baseurl = self.server.workbooks.baseurl - workbook = TSC.WorkbookItem("project", "test") - workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" - with open(REVISION_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{workbook.id}/revisions", text=response_xml) - self.server.workbooks.populate_revisions(workbook) - revisions = workbook.revisions - - self.assertEqual(len(revisions), 3) - self.assertEqual("2016-07-26T20:34:56Z", format_datetime(revisions[0].created_at)) - self.assertEqual("2016-07-27T20:34:56Z", format_datetime(revisions[1].created_at)) - self.assertEqual("2016-07-28T20:34:56Z", format_datetime(revisions[2].created_at)) - - self.assertEqual(False, revisions[0].deleted) - self.assertEqual(False, revisions[0].current) - self.assertEqual(False, revisions[1].deleted) - self.assertEqual(False, revisions[1].current) - self.assertEqual(False, revisions[2].deleted) - self.assertEqual(True, revisions[2].current) - - self.assertEqual("Cassie", revisions[0].user_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", revisions[0].user_id) - self.assertIsNone(revisions[1].user_name) - self.assertIsNone(revisions[1].user_id) - self.assertEqual("Cassie", revisions[2].user_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", revisions[2].user_id) - - def test_delete_revision(self) -> None: - self.baseurl = self.server.workbooks.baseurl - workbook = TSC.WorkbookItem("project", "test") - workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" +def test_publish_unnamed_file_object(server: TSC.Server) -> None: + new_workbook = TSC.WorkbookItem("test") - with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{workbook.id}/revisions/3") - self.server.workbooks.delete_revision(workbook.id, "3") + with open(os.path.join(TEST_ASSET_DIR, "SampleWB.twbx"), "rb") as f: + with pytest.raises(ValueError): + server.workbooks.publish(new_workbook, f, server.PublishMode.CreateNew) - def test_download_revision(self) -> None: - with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: - m.get( - self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/revisions/3/content", - headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, - ) - file_path = self.server.workbooks.download_revision("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", "3", td) - self.assertTrue(os.path.exists(file_path)) - def test_bad_download_response(self) -> None: - with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: - m.get( - self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", - headers={"Content-Disposition": '''name="tableau_workbook"; filename*=UTF-8''"Sample workbook.twb"'''}, - ) - file_path = self.server.workbooks.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) - self.assertTrue(os.path.exists(file_path)) - - def test_odata_connection(self) -> None: - self.baseurl = self.server.workbooks.baseurl - workbook = TSC.WorkbookItem("project", "test") - workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" - connection = TSC.ConnectionItem() - url = "https://odata.website.com/TestODataEndpoint" - connection.server_address = url - connection._connection_type = "odata" - connection._id = "17376070-64d1-4d17-acb4-a56e4b5b1768" - - creds = TSC.ConnectionCredentials("", "", True) - connection.connection_credentials = creds - with open(ODATA_XML, "rb") as f: - response_xml = f.read().decode("utf-8") +def test_publish_non_bytes_file_object(server: TSC.Server) -> None: + new_workbook = TSC.WorkbookItem("test") - with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{workbook.id}/connections/{connection.id}", text=response_xml) - self.server.workbooks.update_connection(workbook, connection) + with open(os.path.join(TEST_ASSET_DIR, "SampleWB.twbx")) as f: + with pytest.raises(TypeError): + server.workbooks.publish(new_workbook, f, server.PublishMode.CreateNew) - history = m.request_history - request = history[0] - xml = fromstring(request.body) - xml_connection = xml.find(".//connection") +def test_publish_file_object_of_unknown_type_raises_exception(server: TSC.Server) -> None: + new_workbook = TSC.WorkbookItem("test") + with BytesIO() as file_object: + file_object.write(bytes.fromhex("89504E470D0A1A0A")) + file_object.seek(0) + with pytest.raises(ValueError): + server.workbooks.publish(new_workbook, file_object, server.PublishMode.CreateNew) - 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) +def test_publish_multi_connection(server: TSC.Server) -> None: + new_workbook = TSC.WorkbookItem(name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + connection1 = TSC.ConnectionItem() + connection1.server_address = "mysql.test.com" + connection1.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) + connection2 = TSC.ConnectionItem() + connection2.server_address = "pgsql.test.com" + connection2.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) - 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"] + response = RequestFactory.Workbook._generate_xml(new_workbook, connections=[connection1, connection2]) + # Can't use ConnectionItem parser due to xml namespace problems + connection_results = fromstring(response).findall(".//connection") - 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, - ) + assert connection_results[0].get("serverAddress", None) == "mysql.test.com" + assert connection_results[0].find("connectionCredentials").get("name", None) == "test" + assert connection_results[1].get("serverAddress", None) == "pgsql.test.com" + assert connection_results[1].find("connectionCredentials").get("password", None) == "secret" - 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) - self.assertEqual("AD Service Principal", connection_items[0].auth_type) +def test_publish_multi_connection_flat(server: TSC.Server) -> None: + new_workbook = TSC.WorkbookItem(name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + connection1 = TSC.ConnectionItem() + connection1.server_address = "mysql.test.com" + connection1.username = "test" + connection1.password = "secret" + connection1.embed_password = True + connection2 = TSC.ConnectionItem() + connection2.server_address = "pgsql.test.com" + connection2.username = "test" + connection2.password = "secret" + connection2.embed_password = True - def test_get_workbook_all_fields(self) -> None: - self.server.version = "3.21" - baseurl = self.server.workbooks.baseurl + response = RequestFactory.Workbook._generate_xml(new_workbook, connections=[connection1, connection2]) + # Can't use ConnectionItem parser due to xml namespace problems + connection_results = fromstring(response).findall(".//connection") - with open(GET_XML_ALL_FIELDS) as f: - response = f.read() + assert connection_results[0].get("serverAddress", None) == "mysql.test.com" + assert connection_results[0].find("connectionCredentials").get("name", None) == "test" + assert connection_results[1].get("serverAddress", None) == "pgsql.test.com" + assert connection_results[1].find("connectionCredentials").get("password", None) == "secret" - ro = TSC.RequestOptions() - ro.all_fields = True - with requests_mock.mock() as m: - m.get(f"{baseurl}?fields=_all_", text=response) - workbooks, _ = self.server.workbooks.get(req_options=ro) - - assert workbooks[0].id == "9df3e2d1-070e-497a-9578-8cc557ced9df" - assert workbooks[0].name == "Superstore" - assert workbooks[0].content_url == "Superstore" - assert workbooks[0].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265605" - assert workbooks[0].show_tabs - assert workbooks[0].size == 2 - assert workbooks[0].created_at == parse_datetime("2024-02-14T04:42:09Z") - assert workbooks[0].updated_at == parse_datetime("2024-02-14T04:42:10Z") - assert workbooks[0].sheet_count == 9 - assert not workbooks[0].has_extracts - assert not workbooks[0].encrypt_extracts - assert workbooks[0].default_view_id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534" - assert workbooks[0].share_description == "Superstore" - assert workbooks[0].last_published_at == parse_datetime("2024-02-14T04:42:09Z") - assert isinstance(workbooks[0].project, TSC.ProjectItem) - assert workbooks[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" - assert workbooks[0].project.name == "Samples" - assert workbooks[0].project.description == "This project includes automatically uploaded samples." - assert isinstance(workbooks[0].location, TSC.LocationItem) - assert workbooks[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" - assert workbooks[0].location.type == "Project" - assert workbooks[0].location.name == "Samples" - assert isinstance(workbooks[0].owner, TSC.UserItem) - assert workbooks[0].owner.email == "bob@example.com" - assert workbooks[0].owner.fullname == "Bob Smith" - assert workbooks[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" - assert workbooks[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") - assert workbooks[0].owner.name == "bob@example.com" - assert workbooks[0].owner.site_role == "SiteAdministratorCreator" - assert workbooks[1].id == "6693cb26-9507-4174-ad3e-9de81a18c971" - assert workbooks[1].name == "World Indicators" - assert workbooks[1].content_url == "WorldIndicators" - assert workbooks[1].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265606" - assert workbooks[1].show_tabs - assert workbooks[1].size == 1 - assert workbooks[1].created_at == parse_datetime("2024-02-14T04:42:11Z") - assert workbooks[1].updated_at == parse_datetime("2024-02-14T04:42:12Z") - assert workbooks[1].sheet_count == 8 - assert not workbooks[1].has_extracts - assert not workbooks[1].encrypt_extracts - assert workbooks[1].default_view_id == "3d10dbcf-a206-47c7-91ba-ebab3ab33d7c" - assert workbooks[1].share_description == "World Indicators" - assert workbooks[1].last_published_at == parse_datetime("2024-02-14T04:42:11Z") - assert isinstance(workbooks[1].project, TSC.ProjectItem) - assert workbooks[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" - assert workbooks[1].project.name == "Samples" - assert workbooks[1].project.description == "This project includes automatically uploaded samples." - assert isinstance(workbooks[1].location, TSC.LocationItem) - assert workbooks[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" - assert workbooks[1].location.type == "Project" - assert workbooks[1].location.name == "Samples" - assert isinstance(workbooks[1].owner, TSC.UserItem) - assert workbooks[1].owner.email == "bob@example.com" - assert workbooks[1].owner.fullname == "Bob Smith" - assert workbooks[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" - assert workbooks[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") - assert workbooks[1].owner.name == "bob@example.com" - assert workbooks[1].owner.site_role == "SiteAdministratorCreator" - assert workbooks[2].id == "dbc0f162-909f-4edf-8392-0d12a80af955" - assert workbooks[2].name == "Superstore" - assert workbooks[2].description == "This is a superstore workbook" - assert workbooks[2].content_url == "Superstore_17078880698360" - assert workbooks[2].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265621" - assert not workbooks[2].show_tabs - assert workbooks[2].size == 1 - assert workbooks[2].created_at == parse_datetime("2024-02-14T05:21:09Z") - assert workbooks[2].updated_at == parse_datetime("2024-07-02T02:19:59Z") - assert workbooks[2].sheet_count == 7 - assert workbooks[2].has_extracts - assert not workbooks[2].encrypt_extracts - assert workbooks[2].default_view_id == "8c4b1d3e-3f31-4d2a-8b9f-492b92f27987" - assert workbooks[2].share_description == "Superstore" - assert workbooks[2].last_published_at == parse_datetime("2024-07-02T02:19:58Z") - assert isinstance(workbooks[2].project, TSC.ProjectItem) - assert workbooks[2].project.id == "9836791c-9468-40f0-b7f3-d10b9562a046" - assert workbooks[2].project.name == "default" - assert workbooks[2].project.description == "The default project that was automatically created by Tableau." - assert isinstance(workbooks[2].location, TSC.LocationItem) - assert workbooks[2].location.id == "9836791c-9468-40f0-b7f3-d10b9562a046" - assert workbooks[2].location.type == "Project" - assert workbooks[2].location.name == "default" - assert isinstance(workbooks[2].owner, TSC.UserItem) - assert workbooks[2].owner.email == "bob@example.com" - assert workbooks[2].owner.fullname == "Bob Smith" - assert workbooks[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" - assert workbooks[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") - assert workbooks[2].owner.name == "bob@example.com" - assert workbooks[2].owner.site_role == "SiteAdministratorCreator" +def test_synchronous_publish_timeout_error(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.register_uri("POST", server.workbooks.baseurl, status_code=504) + + new_workbook = TSC.WorkbookItem(project_id="") + publish_mode = server.PublishMode.CreateNew + + with pytest.raises(InternalServerError, match="Please use asynchronous publishing to avoid timeouts"): + server.workbooks.publish(new_workbook, TEST_ASSET_DIR / "SampleWB.twbx", publish_mode) + + +def test_delete_extracts_all(server: TSC.Server) -> None: + server.version = "3.10" + server.workbooks.baseurl + + response_xml = PUBLISH_ASYNC_XML.read_text() + with requests_mock.mock() as m: + m.post( + server.workbooks.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/deleteExtract", + status_code=200, + text=response_xml, + ) + server.workbooks.delete_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42") + + +def test_create_extracts_all(server: TSC.Server) -> None: + server.version = "3.10" + server.workbooks.baseurl + + response_xml = PUBLISH_ASYNC_XML.read_text() + with requests_mock.mock() as m: + m.post( + server.workbooks.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract", + status_code=200, + text=response_xml, + ) + server.workbooks.create_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42") + + +def test_create_extracts_one(server: TSC.Server) -> None: + server.version = "3.10" + server.workbooks.baseurl + + datasource = TSC.DatasourceItem("test") + datasource._id = "1f951daf-4061-451a-9df1-69a8062664f2" + + response_xml = PUBLISH_ASYNC_XML.read_text() + with requests_mock.mock() as m: + m.post( + server.workbooks.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract", + status_code=200, + text=response_xml, + ) + server.workbooks.create_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42", False, datasource) + + +def test_revisions(server: TSC.Server) -> None: + server.workbooks.baseurl + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + + response_xml = REVISION_XML.read_text() + with requests_mock.mock() as m: + m.get(f"{server.workbooks.baseurl}/{workbook.id}/revisions", text=response_xml) + server.workbooks.populate_revisions(workbook) + revisions = workbook.revisions + + assert len(revisions) == 3 + assert "2016-07-26T20:34:56Z" == format_datetime(revisions[0].created_at) + assert "2016-07-27T20:34:56Z" == format_datetime(revisions[1].created_at) + assert "2016-07-28T20:34:56Z" == format_datetime(revisions[2].created_at) + + assert not revisions[0].deleted + assert not revisions[0].current + assert not revisions[1].deleted + assert not revisions[1].current + assert not revisions[2].deleted + assert revisions[2].current + + assert "Cassie" == revisions[0].user_name + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == revisions[0].user_id + assert revisions[1].user_name is None + assert revisions[1].user_id is None + assert "Cassie" == revisions[2].user_name + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == revisions[2].user_id + + +def test_delete_revision(server: TSC.Server) -> None: + server.workbooks.baseurl + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + + with requests_mock.mock() as m: + m.delete(f"{server.workbooks.baseurl}/{workbook.id}/revisions/3") + server.workbooks.delete_revision(workbook.id, "3") + + +def test_download_revision(server: TSC.Server) -> None: + with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: + m.get( + server.workbooks.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/revisions/3/content", + headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, + ) + file_path = server.workbooks.download_revision("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", "3", td) + assert os.path.exists(file_path) + + +def test_bad_download_response(server: TSC.Server) -> None: + with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: + m.get( + server.workbooks.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", + headers={"Content-Disposition": '''name="tableau_workbook"; filename*=UTF-8''"Sample workbook.twb"'''}, + ) + file_path = server.workbooks.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) + assert os.path.exists(file_path) + + +def test_odata_connection(server: TSC.Server) -> None: + server.workbooks.baseurl + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + connection = TSC.ConnectionItem() + url = "https://odata.website.com/TestODataEndpoint" + connection.server_address = url + connection._connection_type = "odata" + connection._id = "17376070-64d1-4d17-acb4-a56e4b5b1768" + + creds = TSC.ConnectionCredentials("", "", True) + connection.connection_credentials = creds + response_xml = ODATA_XML.read_text() + + with requests_mock.mock() as m: + m.put(f"{server.workbooks.baseurl}/{workbook.id}/connections/{connection.id}", text=response_xml) + server.workbooks.update_connection(workbook, connection) + + history = m.request_history + + request = history[0] + xml = fromstring(request.body) + xml_connection = xml.find(".//connection") + + assert xml_connection is not None + assert xml_connection.get("serverAddress") == url + + +def test_update_workbook_connections(server: TSC.Server) -> None: + populate_xml = POPULATE_CONNECTIONS_XML.read_text() + response_xml = UPDATE_CONNECTIONS_XML.read_text() + + 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 + server.version = "3.26" + url = f"{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 = 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] + + assert updated_ids == connection_luids + assert "AD Service Principal" == connection_items[0].auth_type + + +def test_get_workbook_all_fields(server: TSC.Server) -> None: + server.version = "3.21" + baseurl = server.workbooks.baseurl + + response = GET_XML_ALL_FIELDS.read_text() + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{baseurl}?fields=_all_", text=response) + workbooks, _ = server.workbooks.get(req_options=ro) + + assert workbooks[0].id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert workbooks[0].name == "Superstore" + assert workbooks[0].content_url == "Superstore" + assert workbooks[0].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265605" + assert workbooks[0].show_tabs + assert workbooks[0].size == 2 + assert workbooks[0].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert workbooks[0].updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert workbooks[0].sheet_count == 9 + assert not workbooks[0].has_extracts + assert not workbooks[0].encrypt_extracts + assert workbooks[0].default_view_id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534" + assert workbooks[0].share_description == "Superstore" + assert workbooks[0].last_published_at == parse_datetime("2024-02-14T04:42:09Z") + assert isinstance(workbooks[0].project, TSC.ProjectItem) + assert workbooks[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[0].project.name == "Samples" + assert workbooks[0].project.description == "This project includes automatically uploaded samples." + assert isinstance(workbooks[0].location, TSC.LocationItem) + assert workbooks[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[0].location.type == "Project" + assert workbooks[0].location.name == "Samples" + assert isinstance(workbooks[0].owner, TSC.UserItem) + assert workbooks[0].owner.email == "bob@example.com" + assert workbooks[0].owner.fullname == "Bob Smith" + assert workbooks[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[0].owner.name == "bob@example.com" + assert workbooks[0].owner.site_role == "SiteAdministratorCreator" + assert workbooks[1].id == "6693cb26-9507-4174-ad3e-9de81a18c971" + assert workbooks[1].name == "World Indicators" + assert workbooks[1].content_url == "WorldIndicators" + assert workbooks[1].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265606" + assert workbooks[1].show_tabs + assert workbooks[1].size == 1 + assert workbooks[1].created_at == parse_datetime("2024-02-14T04:42:11Z") + assert workbooks[1].updated_at == parse_datetime("2024-02-14T04:42:12Z") + assert workbooks[1].sheet_count == 8 + assert not workbooks[1].has_extracts + assert not workbooks[1].encrypt_extracts + assert workbooks[1].default_view_id == "3d10dbcf-a206-47c7-91ba-ebab3ab33d7c" + assert workbooks[1].share_description == "World Indicators" + assert workbooks[1].last_published_at == parse_datetime("2024-02-14T04:42:11Z") + assert isinstance(workbooks[1].project, TSC.ProjectItem) + assert workbooks[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[1].project.name == "Samples" + assert workbooks[1].project.description == "This project includes automatically uploaded samples." + assert isinstance(workbooks[1].location, TSC.LocationItem) + assert workbooks[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert workbooks[1].location.type == "Project" + assert workbooks[1].location.name == "Samples" + assert isinstance(workbooks[1].owner, TSC.UserItem) + assert workbooks[1].owner.email == "bob@example.com" + assert workbooks[1].owner.fullname == "Bob Smith" + assert workbooks[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[1].owner.name == "bob@example.com" + assert workbooks[1].owner.site_role == "SiteAdministratorCreator" + assert workbooks[2].id == "dbc0f162-909f-4edf-8392-0d12a80af955" + assert workbooks[2].name == "Superstore" + assert workbooks[2].description == "This is a superstore workbook" + assert workbooks[2].content_url == "Superstore_17078880698360" + assert workbooks[2].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265621" + assert not workbooks[2].show_tabs + assert workbooks[2].size == 1 + assert workbooks[2].created_at == parse_datetime("2024-02-14T05:21:09Z") + assert workbooks[2].updated_at == parse_datetime("2024-07-02T02:19:59Z") + assert workbooks[2].sheet_count == 7 + assert workbooks[2].has_extracts + assert not workbooks[2].encrypt_extracts + assert workbooks[2].default_view_id == "8c4b1d3e-3f31-4d2a-8b9f-492b92f27987" + assert workbooks[2].share_description == "Superstore" + assert workbooks[2].last_published_at == parse_datetime("2024-07-02T02:19:58Z") + assert isinstance(workbooks[2].project, TSC.ProjectItem) + assert workbooks[2].project.id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert workbooks[2].project.name == "default" + assert workbooks[2].project.description == "The default project that was automatically created by Tableau." + assert isinstance(workbooks[2].location, TSC.LocationItem) + assert workbooks[2].location.id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert workbooks[2].location.type == "Project" + assert workbooks[2].location.name == "default" + assert isinstance(workbooks[2].owner, TSC.UserItem) + assert workbooks[2].owner.email == "bob@example.com" + assert workbooks[2].owner.fullname == "Bob Smith" + assert workbooks[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert workbooks[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert workbooks[2].owner.name == "bob@example.com" + assert workbooks[2].owner.site_role == "SiteAdministratorCreator" From 59eaebbeec53f6e2f35c11aa2a11d58203133a47 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 19:05:59 -0500 Subject: [PATCH 25/44] chore: convert test_auth to pytest (#1646) * chore: convert test_auth to pytest * chore: type hint tests --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_auth.py | 269 ++++++++++++++++++++++++---------------------- 1 file changed, 141 insertions(+), 128 deletions(-) diff --git a/test/test_auth.py b/test/test_auth.py index 09e3e251d..c50f4d29b 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -1,133 +1,146 @@ -import os.path -import unittest +from pathlib import Path +import pytest import requests_mock import tableauserverclient as TSC -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") - -SIGN_IN_XML = os.path.join(TEST_ASSET_DIR, "auth_sign_in.xml") -SIGN_IN_IMPERSONATE_XML = os.path.join(TEST_ASSET_DIR, "auth_sign_in_impersonate.xml") -SIGN_IN_ERROR_XML = os.path.join(TEST_ASSET_DIR, "auth_sign_in_error.xml") - - -class AuthTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server("http://test", False) - self.baseurl = self.server.auth.baseurl - - def test_sign_in(self): - with open(SIGN_IN_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl + "/signin", text=response_xml) - tableau_auth = TSC.TableauAuth("testuser", "password", site_id="Samples") - self.server.auth.sign_in(tableau_auth) - - self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) - self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) - self.assertEqual("Samples", self.server.site_url) - self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) - - def test_sign_in_with_personal_access_tokens(self): - with open(SIGN_IN_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl + "/signin", text=response_xml) - tableau_auth = TSC.PersonalAccessTokenAuth( - token_name="mytoken", personal_access_token="Random123Generated", site_id="Samples" - ) - self.server.auth.sign_in(tableau_auth) - - self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) - self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) - self.assertEqual("Samples", self.server.site_url) - self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) - - def test_sign_in_impersonate(self): - with open(SIGN_IN_IMPERSONATE_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl + "/signin", text=response_xml) - tableau_auth = TSC.TableauAuth( - "testuser", "password", user_id_to_impersonate="dd2239f6-ddf1-4107-981a-4cf94e415794" - ) - self.server.auth.sign_in(tableau_auth) - - self.assertEqual("MJonFA6HDyy2C3oqR13fRGqE6cmgzwq3", self.server.auth_token) - self.assertEqual("dad65087-b08b-4603-af4e-2887b8aafc67", self.server.site_id) - self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", self.server.user_id) - - def test_sign_in_error(self): - with open(SIGN_IN_ERROR_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl + "/signin", text=response_xml, status_code=401) - tableau_auth = TSC.TableauAuth("testuser", "wrongpassword") - self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) - - def test_sign_in_invalid_token(self): - with open(SIGN_IN_ERROR_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl + "/signin", text=response_xml, status_code=401) - tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid") - self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) - - def test_sign_in_without_auth(self): - with open(SIGN_IN_ERROR_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl + "/signin", text=response_xml, status_code=401) - tableau_auth = TSC.TableauAuth("", "") - self.assertRaises(TSC.FailedSignInError, self.server.auth.sign_in, tableau_auth) - - def test_sign_out(self): - with open(SIGN_IN_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl + "/signin", text=response_xml) - m.post(self.baseurl + "/signout", text="") - tableau_auth = TSC.TableauAuth("testuser", "password") - self.server.auth.sign_in(tableau_auth) - self.server.auth.sign_out() - - self.assertIsNone(self.server._auth_token) - self.assertIsNone(self.server._site_id) - self.assertIsNone(self.server._site_url) - self.assertIsNone(self.server._user_id) - - def test_switch_site(self): - self.server.version = "2.6" - baseurl = self.server.auth.baseurl - site_id, user_id, auth_token = list("123") - self.server._set_auth(site_id, user_id, auth_token) - with open(SIGN_IN_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(baseurl + "/switchSite", text=response_xml) - site = TSC.SiteItem("Samples", "Samples") - self.server.auth.switch_site(site) - - self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) - self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) - self.assertEqual("Samples", self.server.site_url) - self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) - - def test_revoke_all_server_admin_tokens(self): - self.server.version = "3.10" - baseurl = self.server.auth.baseurl - with open(SIGN_IN_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(baseurl + "/signin", text=response_xml) - m.post(baseurl + "/revokeAllServerAdminTokens", text="") - tableau_auth = TSC.TableauAuth("testuser", "password") - self.server.auth.sign_in(tableau_auth) - self.server.auth.revoke_all_server_admin_tokens() - - self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token) - self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id) - self.assertEqual("Samples", self.server.site_url) - self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id) +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +SIGN_IN_XML = TEST_ASSET_DIR / "auth_sign_in.xml" +SIGN_IN_IMPERSONATE_XML = TEST_ASSET_DIR / "auth_sign_in_impersonate.xml" +SIGN_IN_ERROR_XML = TEST_ASSET_DIR / "auth_sign_in_error.xml" + + +@pytest.fixture(scope="function") +def server() -> TSC.Server: + """Fixture to create a Tableau Server instance for testing.""" + server_instance = TSC.Server("http://test", False) + return server_instance + + +def test_sign_in(server: TSC.Server) -> None: + with open(SIGN_IN_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(server.auth.baseurl + "/signin", text=response_xml) + tableau_auth = TSC.TableauAuth("testuser", "password", site_id="Samples") + server.auth.sign_in(tableau_auth) + + assert "eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l" == server.auth_token + assert "6b7179ba-b82b-4f0f-91ed-812074ac5da6" == server.site_id + assert "Samples" == server.site_url + assert "1a96d216-e9b8-497b-a82a-0b899a965e01" == server.user_id + + +def test_sign_in_with_personal_access_tokens(server: TSC.Server) -> None: + with open(SIGN_IN_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(server.auth.baseurl + "/signin", text=response_xml) + tableau_auth = TSC.PersonalAccessTokenAuth( + token_name="mytoken", personal_access_token="Random123Generated", site_id="Samples" + ) + server.auth.sign_in(tableau_auth) + + assert "eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l" == server.auth_token + assert "6b7179ba-b82b-4f0f-91ed-812074ac5da6" == server.site_id + assert "Samples" == server.site_url + assert "1a96d216-e9b8-497b-a82a-0b899a965e01" == server.user_id + + +def test_sign_in_impersonate(server: TSC.Server) -> None: + with open(SIGN_IN_IMPERSONATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(server.auth.baseurl + "/signin", text=response_xml) + tableau_auth = TSC.TableauAuth( + "testuser", "password", user_id_to_impersonate="dd2239f6-ddf1-4107-981a-4cf94e415794" + ) + server.auth.sign_in(tableau_auth) + + assert "MJonFA6HDyy2C3oqR13fRGqE6cmgzwq3" == server.auth_token + assert "dad65087-b08b-4603-af4e-2887b8aafc67" == server.site_id + assert "dd2239f6-ddf1-4107-981a-4cf94e415794" == server.user_id + + +def test_sign_in_error(server: TSC.Server) -> None: + with open(SIGN_IN_ERROR_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(server.auth.baseurl + "/signin", text=response_xml, status_code=401) + tableau_auth = TSC.TableauAuth("testuser", "wrongpassword") + with pytest.raises(TSC.FailedSignInError): + server.auth.sign_in(tableau_auth) + + +def test_sign_in_invalid_token(server: TSC.Server) -> None: + with open(SIGN_IN_ERROR_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(server.auth.baseurl + "/signin", text=response_xml, status_code=401) + tableau_auth = TSC.PersonalAccessTokenAuth(token_name="mytoken", personal_access_token="invalid") + with pytest.raises(TSC.FailedSignInError): + server.auth.sign_in(tableau_auth) + + +def test_sign_in_without_auth(server: TSC.Server) -> None: + with open(SIGN_IN_ERROR_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(server.auth.baseurl + "/signin", text=response_xml, status_code=401) + tableau_auth = TSC.TableauAuth("", "") + with pytest.raises(TSC.FailedSignInError): + server.auth.sign_in(tableau_auth) + + +def test_sign_out(server: TSC.Server) -> None: + with open(SIGN_IN_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(server.auth.baseurl + "/signin", text=response_xml) + m.post(server.auth.baseurl + "/signout", text="") + tableau_auth = TSC.TableauAuth("testuser", "password") + server.auth.sign_in(tableau_auth) + server.auth.sign_out() + + assert server._auth_token is None + assert server._site_id is None + assert server._site_url is None + assert server._user_id is None + + +def test_switch_site(server: TSC.Server) -> None: + server.version = "2.6" + baseurl = server.auth.baseurl + site_id, user_id, auth_token = list("123") + server._set_auth(site_id, user_id, auth_token) + with open(SIGN_IN_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(baseurl + "/switchSite", text=response_xml) + site = TSC.SiteItem("Samples", "Samples") + server.auth.switch_site(site) + + assert "eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l" == server.auth_token + assert "6b7179ba-b82b-4f0f-91ed-812074ac5da6" == server.site_id + assert "Samples" == server.site_url + assert "1a96d216-e9b8-497b-a82a-0b899a965e01" == server.user_id + + +def test_revoke_all_server_admin_tokens(server: TSC.Server) -> None: + server.version = "3.10" + baseurl = server.auth.baseurl + with open(SIGN_IN_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(baseurl + "/signin", text=response_xml) + m.post(baseurl + "/revokeAllServerAdminTokens", text="") + tableau_auth = TSC.TableauAuth("testuser", "password") + server.auth.sign_in(tableau_auth) + server.auth.revoke_all_server_admin_tokens() + + assert "eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l" == server.auth_token + assert "6b7179ba-b82b-4f0f-91ed-812074ac5da6" == server.site_id + assert "Samples" == server.site_url + assert "1a96d216-e9b8-497b-a82a-0b899a965e01" == server.user_id From 575a1aed9cd67a9e3dbde47ec3a4d6c9c73a25fd Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 21:54:27 -0500 Subject: [PATCH 26/44] chore: make datasource typing more specific (#1649) * chore: make datasource typing more specific Use overloads to narrow return types of DatasourceEndpoint to reflect what users actually pass in. * chore: make update_hyper_data actions more specific --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/datasource_item.py | 2 +- .../server/endpoint/datasources_endpoint.py | 113 +++++++++++++++--- 2 files changed, 95 insertions(+), 20 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 5501ee332..2813c370c 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -490,7 +490,7 @@ def _set_values( self._owner = owner @classmethod - def from_response(cls, resp: str, ns: dict) -> list["DatasourceItem"]: + def from_response(cls, resp: bytes, ns: dict) -> list["DatasourceItem"]: all_datasource_items = list() parsed_response = fromstring(resp) all_datasource_xml = parsed_response.findall(".//t:datasource", namespaces=ns) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index ba242c8ec..f528b3732 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,8 +6,8 @@ from contextlib import closing from pathlib import Path -from typing import Literal, Optional, TYPE_CHECKING, Union, overload -from collections.abc import Iterable, Mapping, Sequence +from typing import Literal, Optional, TYPE_CHECKING, TypedDict, TypeVar, Union, overload +from collections.abc import Iterable, Sequence from tableauserverclient.helpers.headers import fix_filename from tableauserverclient.models.dqw_item import DQWItem @@ -50,13 +50,50 @@ FileObject = Union[io.BufferedReader, io.BytesIO] PathOrFile = Union[FilePath, FileObject] -FilePath = Union[str, os.PathLike] FileObjectR = Union[io.BufferedReader, io.BytesIO] FileObjectW = Union[io.BufferedWriter, io.BytesIO] PathOrFileR = Union[FilePath, FileObjectR] PathOrFileW = Union[FilePath, FileObjectW] +HyperActionCondition = TypedDict( + "HyperActionCondition", + { + "op": str, + "target-col": str, + "source-col": str, + }, +) + +HyperActionRow = TypedDict( + "HyperActionRow", + { + "action": Literal[ + "update", + "upsert", + "delete", + ], + "source-table": str, + "target-table": str, + "condition": HyperActionCondition, + }, +) + +HyperActionTable = TypedDict( + "HyperActionTable", + { + "action": Literal[ + "insert", + "replace", + ], + "source-table": str, + "target-table": str, + }, +) + +HyperAction = Union[HyperActionTable, HyperActionRow] + + class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]): def __init__(self, parent_srv: "Server") -> None: super().__init__(parent_srv) @@ -191,16 +228,34 @@ def delete(self, datasource_id: str) -> None: self.delete_request(url) logger.info(f"Deleted single datasource (ID: {datasource_id})") + T = TypeVar("T", bound=FileObjectW) + + @overload + def download( + self, + datasource_id: str, + filepath: T, + include_extract: bool = True, + ) -> T: ... + + @overload + def download( + self, + datasource_id: str, + filepath: Optional[FilePath] = None, + include_extract: bool = True, + ) -> str: ... + # Download 1 datasource by id @api(version="2.0") @parameter_added_in(no_extract="2.5") @parameter_added_in(include_extract="2.5") def download( self, - datasource_id: str, - filepath: Optional[PathOrFileW] = None, - include_extract: bool = True, - ) -> PathOrFileW: + datasource_id, + filepath=None, + include_extract=True, + ): """ Downloads the specified data source from a site. The data source is downloaded as a .tdsx file. @@ -479,13 +534,13 @@ def publish( @parameter_added_in(as_job="3.0") def publish( self, - datasource_item: DatasourceItem, - file: PathOrFileR, - mode: str, - connection_credentials: Optional[ConnectionCredentials] = None, - connections: Optional[Sequence[ConnectionItem]] = None, - as_job: bool = False, - ) -> Union[DatasourceItem, JobItem]: + datasource_item, + file, + mode, + connection_credentials=None, + connections=None, + as_job=False, + ): """ Publishes a data source to a server, or appends data to an existing data source. @@ -631,7 +686,7 @@ def update_hyper_data( datasource_or_connection_item: Union[DatasourceItem, ConnectionItem, str], *, request_id: str, - actions: Sequence[Mapping], + actions: Sequence[HyperAction], payload: Optional[FilePath] = None, ) -> JobItem: """ @@ -898,15 +953,35 @@ def _get_datasource_revisions( revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, datasource_item) return revisions - # Download 1 datasource revision by revision number - @api(version="2.3") + T = TypeVar("T", bound=FileObjectW) + + @overload + def download_revision( + self, + datasource_id: str, + revision_number: Optional[str], + filepath: T, + include_extract: bool = True, + ) -> T: ... + + @overload def download_revision( self, datasource_id: str, revision_number: Optional[str], - filepath: Optional[PathOrFileW] = None, + filepath: Optional[FilePath] = None, include_extract: bool = True, - ) -> PathOrFileW: + ) -> str: ... + + # Download 1 datasource revision by revision number + @api(version="2.3") + def download_revision( + self, + datasource_id, + revision_number, + filepath=None, + include_extract=True, + ): """ Downloads a specific version of a data source prior to the current one in .tdsx format. To download the current version of a data source set From 7bd23f47a333d160fc89ae70368f8bc9db945175 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 22:51:56 -0500 Subject: [PATCH 27/44] chore: embrace pytest in test_datasource (#1648) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_datasource.py | 1518 ++++++++++++++++++++------------------- 1 file changed, 779 insertions(+), 739 deletions(-) diff --git a/test/test_datasource.py b/test/test_datasource.py index d36ddab75..e9635874d 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -1,12 +1,14 @@ +from io import BytesIO import os +from pathlib import Path import tempfile -import unittest -from io import BytesIO from typing import Optional +import unittest from zipfile import ZipFile -import requests_mock from defusedxml.ElementTree import fromstring +import pytest +import requests_mock import tableauserverclient as TSC from tableauserverclient import ConnectionItem @@ -14,799 +16,837 @@ from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads from tableauserverclient.server.request_factory import RequestFactory -from ._utils import read_xml_asset, read_xml_assets, asset - -ADD_TAGS_XML = "datasource_add_tags.xml" -GET_XML = "datasource_get.xml" -GET_EMPTY_XML = "datasource_get_empty.xml" -GET_BY_ID_XML = "datasource_get_by_id.xml" -GET_XML_ALL_FIELDS = "datasource_get_all_fields.xml" -POPULATE_CONNECTIONS_XML = "datasource_populate_connections.xml" -POPULATE_PERMISSIONS_XML = "datasource_populate_permissions.xml" -PUBLISH_XML = "datasource_publish.xml" -PUBLISH_XML_ASYNC = "datasource_publish_async.xml" -REFRESH_XML = "datasource_refresh.xml" -REVISION_XML = "datasource_revision.xml" -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): - 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.baseurl = self.server.datasources.baseurl - - def test_get(self) -> None: - response_xml = read_xml_asset(GET_XML) - with requests_mock.mock() as m: - m.get(self.baseurl, text=response_xml) - all_datasources, pagination_item = self.server.datasources.get() - - self.assertEqual(2, pagination_item.total_available) - self.assertEqual("e76a1461-3b1d-4588-bf1b-17551a879ad9", all_datasources[0].id) - self.assertEqual("dataengine", all_datasources[0].datasource_type) - self.assertEqual("SampleDsDescription", all_datasources[0].description) - self.assertEqual("SampleDS", all_datasources[0].content_url) - self.assertEqual(4096, all_datasources[0].size) - self.assertEqual("2016-08-11T21:22:40Z", format_datetime(all_datasources[0].created_at)) - self.assertEqual("2016-08-11T21:34:17Z", format_datetime(all_datasources[0].updated_at)) - self.assertEqual("default", all_datasources[0].project_name) - self.assertEqual("SampleDS", all_datasources[0].name) - self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_datasources[0].project_id) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_datasources[0].owner_id) - self.assertEqual("https://web.com", all_datasources[0].webpage_url) - self.assertFalse(all_datasources[0].encrypt_extracts) - self.assertTrue(all_datasources[0].has_extracts) - self.assertFalse(all_datasources[0].use_remote_query_agent) - - self.assertEqual("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", all_datasources[1].id) - self.assertEqual("dataengine", all_datasources[1].datasource_type) - self.assertEqual("description Sample", all_datasources[1].description) - self.assertEqual("Sampledatasource", all_datasources[1].content_url) - self.assertEqual(10240, all_datasources[1].size) - self.assertEqual("2016-08-04T21:31:55Z", format_datetime(all_datasources[1].created_at)) - self.assertEqual("2016-08-04T21:31:55Z", format_datetime(all_datasources[1].updated_at)) - self.assertEqual("default", all_datasources[1].project_name) - self.assertEqual("Sample datasource", all_datasources[1].name) - self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_datasources[1].project_id) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_datasources[1].owner_id) - self.assertEqual({"world", "indicators", "sample"}, all_datasources[1].tags) - self.assertEqual("https://page.com", all_datasources[1].webpage_url) - self.assertTrue(all_datasources[1].encrypt_extracts) - self.assertFalse(all_datasources[1].has_extracts) - self.assertTrue(all_datasources[1].use_remote_query_agent) - - def test_get_before_signin(self) -> None: - self.server._auth_token = None - self.assertRaises(TSC.NotSignedInError, self.server.datasources.get) - - def test_get_empty(self) -> None: - response_xml = read_xml_asset(GET_EMPTY_XML) - with requests_mock.mock() as m: - m.get(self.baseurl, text=response_xml) - all_datasources, pagination_item = self.server.datasources.get() - self.assertEqual(0, pagination_item.total_available) - self.assertEqual([], all_datasources) +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +ADD_TAGS_XML = TEST_ASSET_DIR / "datasource_add_tags.xml" +GET_XML = TEST_ASSET_DIR / "datasource_get.xml" +GET_EMPTY_XML = TEST_ASSET_DIR / "datasource_get_empty.xml" +GET_BY_ID_XML = TEST_ASSET_DIR / "datasource_get_by_id.xml" +GET_XML_ALL_FIELDS = TEST_ASSET_DIR / "datasource_get_all_fields.xml" +POPULATE_CONNECTIONS_XML = TEST_ASSET_DIR / "datasource_populate_connections.xml" +POPULATE_PERMISSIONS_XML = TEST_ASSET_DIR / "datasource_populate_permissions.xml" +PUBLISH_XML = TEST_ASSET_DIR / "datasource_publish.xml" +PUBLISH_XML_ASYNC = TEST_ASSET_DIR / "datasource_publish_async.xml" +REFRESH_XML = TEST_ASSET_DIR / "datasource_refresh.xml" +REVISION_XML = TEST_ASSET_DIR / "datasource_revision.xml" +UPDATE_XML = TEST_ASSET_DIR / "datasource_update.xml" +UPDATE_HYPER_DATA_XML = TEST_ASSET_DIR / "datasource_data_update.xml" +UPDATE_CONNECTION_XML = TEST_ASSET_DIR / "datasource_connection_update.xml" +UPDATE_CONNECTIONS_XML = TEST_ASSET_DIR / "datasource_connections_update.xml" + + +@pytest.fixture(scope="function") +def server(): + """Fixture to create a TSC.Server instance for testing.""" + server = TSC.Server("http://test", False) + + # Fake signin + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + return server + + +def test_get(server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.datasources.baseurl, text=response_xml) + all_datasources, pagination_item = server.datasources.get() + + assert 2 == pagination_item.total_available + assert "e76a1461-3b1d-4588-bf1b-17551a879ad9" == all_datasources[0].id + assert "dataengine" == all_datasources[0].datasource_type + assert "SampleDsDescription" == all_datasources[0].description + assert "SampleDS" == all_datasources[0].content_url + assert 4096 == all_datasources[0].size + assert "2016-08-11T21:22:40Z" == format_datetime(all_datasources[0].created_at) + assert "2016-08-11T21:34:17Z" == format_datetime(all_datasources[0].updated_at) + assert "default" == all_datasources[0].project_name + assert "SampleDS" == all_datasources[0].name + assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == all_datasources[0].project_id + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == all_datasources[0].owner_id + assert "https://web.com" == all_datasources[0].webpage_url + assert not all_datasources[0].encrypt_extracts + assert all_datasources[0].has_extracts + assert not all_datasources[0].use_remote_query_agent + + assert "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" == all_datasources[1].id + assert "dataengine" == all_datasources[1].datasource_type + assert "description Sample" == all_datasources[1].description + assert "Sampledatasource" == all_datasources[1].content_url + assert 10240 == all_datasources[1].size + assert "2016-08-04T21:31:55Z" == format_datetime(all_datasources[1].created_at) + assert "2016-08-04T21:31:55Z" == format_datetime(all_datasources[1].updated_at) + assert "default" == all_datasources[1].project_name + assert "Sample datasource" == all_datasources[1].name + assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == all_datasources[1].project_id + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == all_datasources[1].owner_id + assert {"world", "indicators", "sample"} == all_datasources[1].tags + assert "https://page.com" == all_datasources[1].webpage_url + assert all_datasources[1].encrypt_extracts + assert not all_datasources[1].has_extracts + assert all_datasources[1].use_remote_query_agent + + +def test_get_before_signin(server) -> None: + server._auth_token = None + with pytest.raises(TSC.NotSignedInError): + server.datasources.get() + + +def test_get_empty(server) -> None: + response_xml = GET_EMPTY_XML.read_text() + with requests_mock.mock() as m: + m.get(server.datasources.baseurl, text=response_xml) + all_datasources, pagination_item = server.datasources.get() + + assert 0 == pagination_item.total_available + assert [] == all_datasources + + +def test_get_by_id(server) -> None: + response_xml = GET_BY_ID_XML.read_text() + with requests_mock.mock() as m: + m.get(server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=response_xml) + single_datasource = server.datasources.get_by_id("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") + + assert "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" == single_datasource.id + assert "dataengine" == single_datasource.datasource_type + assert "abc description xyz" == single_datasource.description + assert "Sampledatasource" == single_datasource.content_url + assert "2016-08-04T21:31:55Z" == format_datetime(single_datasource.created_at) + assert "2016-08-04T21:31:55Z" == format_datetime(single_datasource.updated_at) + assert "default" == single_datasource.project_name + assert "Sample datasource" == single_datasource.name + assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == single_datasource.project_id + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == single_datasource.owner_id + assert {"world", "indicators", "sample"} == single_datasource.tags + assert TSC.DatasourceItem.AskDataEnablement.SiteDefault == single_datasource.ask_data_enablement + + +def test_update(server) -> None: + response_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.put(server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=response_xml) + single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74", "Sample datasource") + single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_datasource._content_url = "Sampledatasource" + single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + single_datasource.certified = True + single_datasource.certification_note = "Warning, here be dragons." + updated_datasource = server.datasources.update(single_datasource) + + assert updated_datasource.id == single_datasource.id + assert updated_datasource.name == single_datasource.name + assert updated_datasource.content_url == single_datasource.content_url + assert updated_datasource.project_id == single_datasource.project_id + assert updated_datasource.owner_id == single_datasource.owner_id + assert updated_datasource.certified == single_datasource.certified + assert updated_datasource.certification_note == single_datasource.certification_note + + +def test_update_copy_fields(server) -> None: + response_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.put(server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=response_xml) + single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74", "test") + single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + single_datasource._project_name = "Tester" + updated_datasource = server.datasources.update(single_datasource) + + assert single_datasource.tags == updated_datasource.tags + assert single_datasource._project_name == updated_datasource._project_name + + +def test_update_tags(server) -> None: + add_tags_xml = ADD_TAGS_XML.read_text() + update_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.delete(server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/b", status_code=204) + m.delete(server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/d", status_code=204) + m.put(server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags", text=add_tags_xml) + m.put(server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=update_xml) + single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74") + single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + single_datasource._initial_tags.update(["a", "b", "c", "d"]) + single_datasource.tags.update(["a", "c", "e"]) + updated_datasource = server.datasources.update(single_datasource) + + assert single_datasource.tags == updated_datasource.tags + assert single_datasource._initial_tags == updated_datasource._initial_tags + + +def test_populate_connections(server) -> None: + response_xml = POPULATE_CONNECTIONS_XML.read_text() + with requests_mock.mock() as m: + m.get(server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", text=response_xml) + single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74", "test") + single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + server.datasources.populate_connections(single_datasource) + assert "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" == single_datasource.id + connections: Optional[list[ConnectionItem]] = single_datasource.connections + + assert connections is not None + ds1, ds2 = connections + assert "be786ae0-d2bf-4a4b-9b34-e2de8d2d4488" == ds1.id + assert "textscan" == ds1.connection_type + assert "forty-two.net" == ds1.server_address + assert "duo" == ds1.username + assert True == ds1.embed_password + assert ds1.datasource_id == single_datasource.id + assert single_datasource.name == ds1.datasource_name + assert "970e24bc-e200-4841-a3e9-66e7d122d77e" == ds2.id + assert "sqlserver" == ds2.connection_type + assert "database.com" == ds2.server_address + assert "heero" == ds2.username + assert False == ds2.embed_password + assert ds2.datasource_id == single_datasource.id + assert single_datasource.name == ds2.datasource_name + + +def test_update_connection(server) -> None: + populate_xml = POPULATE_CONNECTIONS_XML.read_text() + response_xml = UPDATE_CONNECTION_XML.read_text() + + with requests_mock.mock() as m: + m.get(server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", text=populate_xml) + m.put( + server.datasources.baseurl + + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", + text=response_xml, + ) + single_datasource = TSC.DatasourceItem("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488") + single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + server.datasources.populate_connections(single_datasource) - def test_get_by_id(self) -> None: - response_xml = read_xml_asset(GET_BY_ID_XML) - with requests_mock.mock() as m: - m.get(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=response_xml) - single_datasource = self.server.datasources.get_by_id("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") - - self.assertEqual("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", single_datasource.id) - self.assertEqual("dataengine", single_datasource.datasource_type) - self.assertEqual("abc description xyz", single_datasource.description) - self.assertEqual("Sampledatasource", single_datasource.content_url) - self.assertEqual("2016-08-04T21:31:55Z", format_datetime(single_datasource.created_at)) - self.assertEqual("2016-08-04T21:31:55Z", format_datetime(single_datasource.updated_at)) - self.assertEqual("default", single_datasource.project_name) - self.assertEqual("Sample datasource", single_datasource.name) - self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_datasource.project_id) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_datasource.owner_id) - self.assertEqual({"world", "indicators", "sample"}, single_datasource.tags) - self.assertEqual(TSC.DatasourceItem.AskDataEnablement.SiteDefault, single_datasource.ask_data_enablement) - - def test_update(self) -> None: - response_xml = read_xml_asset(UPDATE_XML) - with requests_mock.mock() as m: - m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=response_xml) - single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74", "Sample datasource") - single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - single_datasource._content_url = "Sampledatasource" - single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - single_datasource.certified = True - single_datasource.certification_note = "Warning, here be dragons." - updated_datasource = self.server.datasources.update(single_datasource) - - self.assertEqual(updated_datasource.id, single_datasource.id) - self.assertEqual(updated_datasource.name, single_datasource.name) - self.assertEqual(updated_datasource.content_url, single_datasource.content_url) - self.assertEqual(updated_datasource.project_id, single_datasource.project_id) - self.assertEqual(updated_datasource.owner_id, single_datasource.owner_id) - self.assertEqual(updated_datasource.certified, single_datasource.certified) - self.assertEqual(updated_datasource.certification_note, single_datasource.certification_note) - - def test_update_copy_fields(self) -> None: - with open(asset(UPDATE_XML), "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=response_xml) - single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74", "test") - single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - single_datasource._project_name = "Tester" - updated_datasource = self.server.datasources.update(single_datasource) + connection = single_datasource.connections[0] # type: ignore[index] + connection.server_address = "bar" + connection.server_port = "9876" + connection.username = "foo" + new_connection = server.datasources.update_connection(single_datasource, connection) + assert connection.id == new_connection.id + assert connection.connection_type == new_connection.connection_type + assert "bar" == new_connection.server_address + assert "9876" == new_connection.server_port + assert "foo" == new_connection.username - self.assertEqual(single_datasource.tags, updated_datasource.tags) - self.assertEqual(single_datasource._project_name, updated_datasource._project_name) - def test_update_tags(self) -> None: - add_tags_xml, update_xml = read_xml_assets(ADD_TAGS_XML, UPDATE_XML) - with requests_mock.mock() as m: - m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/b", status_code=204) - m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags/d", status_code=204) - m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/tags", text=add_tags_xml) - m.put(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", text=update_xml) - single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74") - single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - single_datasource._initial_tags.update(["a", "b", "c", "d"]) - single_datasource.tags.update(["a", "c", "e"]) - updated_datasource = self.server.datasources.update(single_datasource) - - self.assertEqual(single_datasource.tags, updated_datasource.tags) - self.assertEqual(single_datasource._initial_tags, updated_datasource._initial_tags) - - def test_populate_connections(self) -> None: - response_xml = read_xml_asset(POPULATE_CONNECTIONS_XML) - with requests_mock.mock() as m: - m.get(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", text=response_xml) - single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74", "test") - single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - self.server.datasources.populate_connections(single_datasource) - self.assertEqual("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", single_datasource.id) - connections: Optional[list[ConnectionItem]] = single_datasource.connections - - self.assertIsNotNone(connections) - assert connections is not None - ds1, ds2 = connections - self.assertEqual("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", ds1.id) - self.assertEqual("textscan", ds1.connection_type) - self.assertEqual("forty-two.net", ds1.server_address) - self.assertEqual("duo", ds1.username) - self.assertEqual(True, ds1.embed_password) - self.assertEqual(ds1.datasource_id, single_datasource.id) - self.assertEqual(single_datasource.name, ds1.datasource_name) - self.assertEqual("970e24bc-e200-4841-a3e9-66e7d122d77e", ds2.id) - self.assertEqual("sqlserver", ds2.connection_type) - self.assertEqual("database.com", ds2.server_address) - self.assertEqual("heero", ds2.username) - self.assertEqual(False, ds2.embed_password) - self.assertEqual(ds2.datasource_id, single_datasource.id) - self.assertEqual(single_datasource.name, ds2.datasource_name) - - def test_update_connection(self) -> None: - populate_xml, response_xml = read_xml_assets(POPULATE_CONNECTIONS_XML, UPDATE_CONNECTION_XML) +def test_update_connections(server) -> None: + populate_xml = POPULATE_CONNECTIONS_XML.read_text() + response_xml = UPDATE_CONNECTIONS_XML.read_text() - with requests_mock.mock() as m: - m.get(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", text=populate_xml) - m.put( - self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", - text=response_xml, - ) - single_datasource = TSC.DatasourceItem("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488") - single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - self.server.datasources.populate_connections(single_datasource) - - connection = single_datasource.connections[0] # type: ignore[index] - connection.server_address = "bar" - connection.server_port = "9876" - connection.username = "foo" - new_connection = self.server.datasources.update_connection(single_datasource, connection) - self.assertEqual(connection.id, new_connection.id) - self.assertEqual(connection.connection_type, new_connection.connection_type) - self.assertEqual("bar", new_connection.server_address) - 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, - ) + with requests_mock.Mocker() as m: - print("BASEURL:", self.server.baseurl) - print("Calling PUT on:", f"{self.server.baseurl}/{datasource.id}/connections") + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + connection_luids = ["be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", "a1b2c3d4-e5f6-7a8b-9c0d-123456789abc"] - 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] + datasource = TSC.DatasourceItem(datasource_id) + datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + server.version = "3.26" - self.assertEqual(updated_ids, connection_luids) - self.assertEqual("auth-keypair", connection_items[0].auth_type) + url = f"{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, + ) - def test_populate_permissions(self) -> None: - with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions", text=response_xml) - single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74", "test") - single_datasource._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" - - self.server.datasources.populate_permissions(single_datasource) - permissions = single_datasource.permissions - - self.assertEqual(permissions[0].grantee.tag_name, "group") # type: ignore[index] - self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af") # type: ignore[index] - self.assertDictEqual( - permissions[0].capabilities, # type: ignore[index] - { - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - }, - ) + print("BASEURL:", server.baseurl) + print("Calling PUT on:", f"{server.baseurl}/{datasource.id}/connections") - self.assertEqual(permissions[1].grantee.tag_name, "user") # type: ignore[index] - self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a") # type: ignore[index] - self.assertDictEqual( - permissions[1].capabilities, # type: ignore[index] - { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, - }, - ) + connection_items = 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] + + assert updated_ids == connection_luids + assert "auth-keypair" == connection_items[0].auth_type + + +def test_populate_permissions(server) -> None: + response_xml = POPULATE_PERMISSIONS_XML.read_text() + with requests_mock.mock() as m: + m.get(server.datasources.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions", text=response_xml) + single_datasource = TSC.DatasourceItem("1d0304cd-3796-429f-b815-7258370b9b74", "test") + single_datasource._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" + + server.datasources.populate_permissions(single_datasource) + permissions = single_datasource.permissions + + assert permissions is not None + assert permissions[0].grantee.tag_name == "group" + assert permissions[0].grantee.id == "5e5e1978-71fa-11e4-87dd-7382f5c437af" + assert permissions[0].capabilities == { # type: ignore[index] + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + } + + assert permissions[1].grantee.tag_name == "user" + assert permissions[1].grantee.id == "7c37ee24-c4b1-42b6-a154-eaeab7ee330a" + assert permissions[1].capabilities == { # type: ignore[index] + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + } + + +def test_publish(server) -> None: + response_xml = PUBLISH_XML.read_text() + with requests_mock.mock() as m: + m.post(server.datasources.baseurl, text=response_xml) + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "SampleDS") + publish_mode = server.PublishMode.CreateNew + + new_datasource = server.datasources.publish(new_datasource, TEST_ASSET_DIR / "SampleDS.tds", mode=publish_mode) + + assert "e76a1461-3b1d-4588-bf1b-17551a879ad9" == new_datasource.id + assert "SampleDS" == new_datasource.name + assert "SampleDS" == new_datasource.content_url + assert "dataengine" == new_datasource.datasource_type + assert "2016-08-11T21:22:40Z" == format_datetime(new_datasource.created_at) + assert "2016-08-17T23:37:08Z" == format_datetime(new_datasource.updated_at) + assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == new_datasource.project_id + assert "default" == new_datasource.project_name + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == new_datasource.owner_id + + +def test_publish_a_non_packaged_file_object(server) -> None: + response_xml = PUBLISH_XML.read_text() + with requests_mock.mock() as m: + m.post(server.datasources.baseurl, text=response_xml) + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "SampleDS") + publish_mode = server.PublishMode.CreateNew + + with open(TEST_ASSET_DIR / "SampleDS.tds", "rb") as file_object: + new_datasource = server.datasources.publish(new_datasource, file_object, mode=publish_mode) + + assert "e76a1461-3b1d-4588-bf1b-17551a879ad9" == new_datasource.id + assert "SampleDS" == new_datasource.name + assert "SampleDS" == new_datasource.content_url + assert "dataengine" == new_datasource.datasource_type + assert "2016-08-11T21:22:40Z" == format_datetime(new_datasource.created_at) + assert "2016-08-17T23:37:08Z" == format_datetime(new_datasource.updated_at) + assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == new_datasource.project_id + assert "default" == new_datasource.project_name + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == new_datasource.owner_id + + +def test_publish_a_packaged_file_object(server) -> None: + response_xml = PUBLISH_XML.read_text() + with requests_mock.mock() as m: + m.post(server.datasources.baseurl, text=response_xml) + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "SampleDS") + publish_mode = server.PublishMode.CreateNew + + # Create a dummy tdsx file in memory + with BytesIO() as zip_archive: + with ZipFile(zip_archive, "w") as zf: + zf.write(str(TEST_ASSET_DIR / "SampleDS.tds"), arcname="SampleDS.tds") + + zip_archive.seek(0) + + new_datasource = server.datasources.publish(new_datasource, zip_archive, mode=publish_mode) + + assert "e76a1461-3b1d-4588-bf1b-17551a879ad9" == new_datasource.id + assert "SampleDS" == new_datasource.name + assert "SampleDS" == new_datasource.content_url + assert "dataengine" == new_datasource.datasource_type + assert "2016-08-11T21:22:40Z" == format_datetime(new_datasource.created_at) + assert "2016-08-17T23:37:08Z" == format_datetime(new_datasource.updated_at) + assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == new_datasource.project_id + assert "default" == new_datasource.project_name + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == new_datasource.owner_id + + +def test_publish_async(server) -> None: + server.version = "3.0" + baseurl = server.datasources.baseurl + response_xml = PUBLISH_XML_ASYNC.read_text() + with requests_mock.mock() as m: + m.post(baseurl, text=response_xml) + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "SampleDS") + publish_mode = server.PublishMode.CreateNew + + new_job = server.datasources.publish( + new_datasource, TEST_ASSET_DIR / "SampleDS.tds", mode=publish_mode, as_job=True + ) - def test_publish(self) -> None: - response_xml = read_xml_asset(PUBLISH_XML) - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) - new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "SampleDS") - publish_mode = self.server.PublishMode.CreateNew - - new_datasource = self.server.datasources.publish(new_datasource, asset("SampleDS.tds"), mode=publish_mode) - - self.assertEqual("e76a1461-3b1d-4588-bf1b-17551a879ad9", new_datasource.id) - self.assertEqual("SampleDS", new_datasource.name) - self.assertEqual("SampleDS", new_datasource.content_url) - self.assertEqual("dataengine", new_datasource.datasource_type) - self.assertEqual("2016-08-11T21:22:40Z", format_datetime(new_datasource.created_at)) - self.assertEqual("2016-08-17T23:37:08Z", format_datetime(new_datasource.updated_at)) - self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_datasource.project_id) - self.assertEqual("default", new_datasource.project_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_datasource.owner_id) - - def test_publish_a_non_packaged_file_object(self) -> None: - response_xml = read_xml_asset(PUBLISH_XML) - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) - new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "SampleDS") - publish_mode = self.server.PublishMode.CreateNew - - with open(asset("SampleDS.tds"), "rb") as file_object: - new_datasource = self.server.datasources.publish(new_datasource, file_object, mode=publish_mode) - - self.assertEqual("e76a1461-3b1d-4588-bf1b-17551a879ad9", new_datasource.id) - self.assertEqual("SampleDS", new_datasource.name) - self.assertEqual("SampleDS", new_datasource.content_url) - self.assertEqual("dataengine", new_datasource.datasource_type) - self.assertEqual("2016-08-11T21:22:40Z", format_datetime(new_datasource.created_at)) - self.assertEqual("2016-08-17T23:37:08Z", format_datetime(new_datasource.updated_at)) - self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_datasource.project_id) - self.assertEqual("default", new_datasource.project_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_datasource.owner_id) - - def test_publish_a_packaged_file_object(self) -> None: - response_xml = read_xml_asset(PUBLISH_XML) - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) - new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "SampleDS") - publish_mode = self.server.PublishMode.CreateNew - - # Create a dummy tdsx file in memory - with BytesIO() as zip_archive: - with ZipFile(zip_archive, "w") as zf: - zf.write(asset("SampleDS.tds")) - - zip_archive.seek(0) - - new_datasource = self.server.datasources.publish(new_datasource, zip_archive, mode=publish_mode) - - self.assertEqual("e76a1461-3b1d-4588-bf1b-17551a879ad9", new_datasource.id) - self.assertEqual("SampleDS", new_datasource.name) - self.assertEqual("SampleDS", new_datasource.content_url) - self.assertEqual("dataengine", new_datasource.datasource_type) - self.assertEqual("2016-08-11T21:22:40Z", format_datetime(new_datasource.created_at)) - self.assertEqual("2016-08-17T23:37:08Z", format_datetime(new_datasource.updated_at)) - self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_datasource.project_id) - self.assertEqual("default", new_datasource.project_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_datasource.owner_id) - - def test_publish_async(self) -> None: - self.server.version = "3.0" - baseurl = self.server.datasources.baseurl - response_xml = read_xml_asset(PUBLISH_XML_ASYNC) - with requests_mock.mock() as m: - m.post(baseurl, text=response_xml) - new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "SampleDS") - publish_mode = self.server.PublishMode.CreateNew + assert "9a373058-af5f-4f83-8662-98b3e0228a73" == new_job.id + assert "PublishDatasource" == new_job.type + assert "0" == new_job.progress + assert "2018-06-30T00:54:54Z" == format_datetime(new_job.created_at) + assert 1 == new_job.finish_code - new_job = self.server.datasources.publish( - new_datasource, asset("SampleDS.tds"), mode=publish_mode, as_job=True - ) - self.assertEqual("9a373058-af5f-4f83-8662-98b3e0228a73", new_job.id) - self.assertEqual("PublishDatasource", new_job.type) - self.assertEqual("0", new_job.progress) - self.assertEqual("2018-06-30T00:54:54Z", format_datetime(new_job.created_at)) - self.assertEqual(1, new_job.finish_code) +def test_publish_unnamed_file_object(server) -> None: + new_datasource = TSC.DatasourceItem("test") + publish_mode = server.PublishMode.CreateNew - def test_publish_unnamed_file_object(self) -> None: - new_datasource = TSC.DatasourceItem("test") - publish_mode = self.server.PublishMode.CreateNew + with open(TEST_ASSET_DIR / "SampleDS.tds", "rb") as file_object: + with pytest.raises(ValueError): + server.datasources.publish(new_datasource, file_object, publish_mode) - with open(asset("SampleDS.tds"), "rb") as file_object: - self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, file_object, publish_mode) - def test_refresh_id(self) -> None: - self.server.version = "2.8" - self.baseurl = self.server.datasources.baseurl - response_xml = read_xml_asset(REFRESH_XML) - with requests_mock.mock() as m: - m.post(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/refresh", status_code=202, text=response_xml) - new_job = self.server.datasources.refresh("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") - - self.assertEqual("7c3d599e-949f-44c3-94a1-f30ba85757e4", new_job.id) - self.assertEqual("RefreshExtract", new_job.type) - self.assertEqual(None, new_job.progress) - self.assertEqual("2020-03-05T22:05:32Z", format_datetime(new_job.created_at)) - self.assertEqual(-1, new_job.finish_code) - - def test_refresh_object(self) -> None: - self.server.version = "2.8" - self.baseurl = self.server.datasources.baseurl - datasource = TSC.DatasourceItem("") - datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - response_xml = read_xml_asset(REFRESH_XML) - with requests_mock.mock() as m: - m.post(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/refresh", status_code=202, text=response_xml) - new_job = self.server.datasources.refresh(datasource) - - # We only check the `id`; remaining fields are already tested in `test_refresh_id` - self.assertEqual("7c3d599e-949f-44c3-94a1-f30ba85757e4", new_job.id) - - def test_datasource_refresh_request_empty(self) -> None: - self.server.version = "2.8" - self.baseurl = self.server.datasources.baseurl - item = TSC.DatasourceItem("") - item._id = "1234" - text = read_xml_asset(REFRESH_XML) - - def match_request_body(request): - try: - root = fromstring(request.body) - assert root.tag == "tsRequest" - assert len(root) == 0 - return True - except Exception: - return False +def test_refresh_id(server) -> None: + server.version = "2.8" + response_xml = REFRESH_XML.read_text() + with requests_mock.mock() as m: + m.post( + server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/refresh", + status_code=202, + text=response_xml, + ) + new_job = server.datasources.refresh("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") + + assert "7c3d599e-949f-44c3-94a1-f30ba85757e4" == new_job.id + assert "RefreshExtract" == new_job.type + assert None == new_job.progress + assert "2020-03-05T22:05:32Z" == format_datetime(new_job.created_at) + assert -1 == new_job.finish_code + + +def test_refresh_object(server) -> None: + server.version = "2.8" + datasource = TSC.DatasourceItem("") + datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + response_xml = REFRESH_XML.read_text() + with requests_mock.mock() as m: + m.post( + server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/refresh", + status_code=202, + text=response_xml, + ) + new_job = server.datasources.refresh(datasource) + + # We only check the `id`; remaining fields are already tested in `test_refresh_id` + assert "7c3d599e-949f-44c3-94a1-f30ba85757e4" == new_job.id + + +def test_datasource_refresh_request_empty(server) -> None: + server.version = "2.8" + item = TSC.DatasourceItem("") + item._id = "1234" + text = REFRESH_XML.read_text() + + def match_request_body(request): + try: + root = fromstring(request.body) + assert root.tag == "tsRequest" + assert len(root) == 0 + return True + except Exception: + return False + + with requests_mock.mock() as m: + m.post(f"{server.datasources.baseurl}/1234/refresh", text=text, additional_matcher=match_request_body) + + +def test_update_hyper_data_datasource_object(server) -> None: + """Calling `update_hyper_data` with a `DatasourceItem` should update that datasource""" + server.version = "3.13" + + datasource = TSC.DatasourceItem("") + datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + response_xml = UPDATE_HYPER_DATA_XML.read_text() + with requests_mock.mock() as m: + m.patch( + server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data", + status_code=202, + headers={"requestid": "test_id"}, + text=response_xml, + ) + new_job = server.datasources.update_hyper_data(datasource, request_id="test_id", actions=[]) + + assert "5c0ba560-c959-424e-b08a-f32ef0bfb737" == new_job.id + assert "UpdateUploadedFile" == new_job.type + assert None == new_job.progress + assert "2021-09-18T09:40:12Z" == format_datetime(new_job.created_at) + assert -1 == new_job.finish_code + + +def test_update_hyper_data_connection_object(server) -> None: + """Calling `update_hyper_data` with a `ConnectionItem` should update that connection""" + server.version = "3.13" + + connection = TSC.ConnectionItem() + connection._datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + connection._id = "7ecaccd8-39b0-4875-a77d-094f6e930019" + response_xml = UPDATE_HYPER_DATA_XML.read_text() + with requests_mock.mock() as m: + m.patch( + server.datasources.baseurl + + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/7ecaccd8-39b0-4875-a77d-094f6e930019/data", + status_code=202, + headers={"requestid": "test_id"}, + text=response_xml, + ) + new_job = server.datasources.update_hyper_data(connection, request_id="test_id", actions=[]) - with requests_mock.mock() as m: - m.post(f"{self.baseurl}/1234/refresh", text=text, additional_matcher=match_request_body) + # We only check the `id`; remaining fields are already tested in `test_update_hyper_data_datasource_object` + assert "5c0ba560-c959-424e-b08a-f32ef0bfb737" == new_job.id - def test_update_hyper_data_datasource_object(self) -> None: - """Calling `update_hyper_data` with a `DatasourceItem` should update that datasource""" - self.server.version = "3.13" - self.baseurl = self.server.datasources.baseurl - datasource = TSC.DatasourceItem("") - datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - response_xml = read_xml_asset(UPDATE_HYPER_DATA_XML) - with requests_mock.mock() as m: - m.patch( - self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data", - status_code=202, - headers={"requestid": "test_id"}, - text=response_xml, - ) - new_job = self.server.datasources.update_hyper_data(datasource, request_id="test_id", actions=[]) - - self.assertEqual("5c0ba560-c959-424e-b08a-f32ef0bfb737", new_job.id) - self.assertEqual("UpdateUploadedFile", new_job.type) - self.assertEqual(None, new_job.progress) - self.assertEqual("2021-09-18T09:40:12Z", format_datetime(new_job.created_at)) - self.assertEqual(-1, new_job.finish_code) - - def test_update_hyper_data_connection_object(self) -> None: - """Calling `update_hyper_data` with a `ConnectionItem` should update that connection""" - self.server.version = "3.13" - self.baseurl = self.server.datasources.baseurl - - connection = TSC.ConnectionItem() - connection._datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - connection._id = "7ecaccd8-39b0-4875-a77d-094f6e930019" - response_xml = read_xml_asset(UPDATE_HYPER_DATA_XML) - with requests_mock.mock() as m: - m.patch( - self.baseurl - + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections/7ecaccd8-39b0-4875-a77d-094f6e930019/data", - status_code=202, - headers={"requestid": "test_id"}, - text=response_xml, - ) - new_job = self.server.datasources.update_hyper_data(connection, request_id="test_id", actions=[]) +def test_update_hyper_data_datasource_string(server) -> None: + """For convenience, calling `update_hyper_data` with a `str` should update the datasource with the corresponding UUID""" + server.version = "3.13" - # We only check the `id`; remaining fields are already tested in `test_update_hyper_data_datasource_object` - self.assertEqual("5c0ba560-c959-424e-b08a-f32ef0bfb737", new_job.id) + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + response_xml = UPDATE_HYPER_DATA_XML.read_text() + with requests_mock.mock() as m: + m.patch( + server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data", + status_code=202, + headers={"requestid": "test_id"}, + text=response_xml, + ) + new_job = server.datasources.update_hyper_data(datasource_id, request_id="test_id", actions=[]) - def test_update_hyper_data_datasource_string(self) -> None: - """For convenience, calling `update_hyper_data` with a `str` should update the datasource with the corresponding UUID""" - self.server.version = "3.13" - self.baseurl = self.server.datasources.baseurl + # We only check the `id`; remaining fields are already tested in `test_update_hyper_data_datasource_object` + assert "5c0ba560-c959-424e-b08a-f32ef0bfb737" == new_job.id - datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - response_xml = read_xml_asset(UPDATE_HYPER_DATA_XML) - with requests_mock.mock() as m: - m.patch( - self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data", - status_code=202, - headers={"requestid": "test_id"}, - text=response_xml, - ) - new_job = self.server.datasources.update_hyper_data(datasource_id, request_id="test_id", actions=[]) - # We only check the `id`; remaining fields are already tested in `test_update_hyper_data_datasource_object` - self.assertEqual("5c0ba560-c959-424e-b08a-f32ef0bfb737", new_job.id) +def test_update_hyper_data_datasource_payload_file(server) -> None: + """If `payload` is present, we upload it and associate the job with it""" + server.version = "3.13" - def test_update_hyper_data_datasource_payload_file(self) -> None: - """If `payload` is present, we upload it and associate the job with it""" - self.server.version = "3.13" - self.baseurl = self.server.datasources.baseurl + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + mock_upload_id = "10051:c3e56879876842d4b3600f20c1f79876-0:0" + response_xml = UPDATE_HYPER_DATA_XML.read_text() + with requests_mock.mock() as rm, unittest.mock.patch.object(Fileuploads, "upload", return_value=mock_upload_id): + rm.patch( + server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data?uploadSessionId=" + mock_upload_id, + status_code=202, + headers={"requestid": "test_id"}, + text=response_xml, + ) + new_job = server.datasources.update_hyper_data( + datasource_id, request_id="test_id", actions=[], payload=(TEST_ASSET_DIR / "World Indicators.hyper") + ) - datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - mock_upload_id = "10051:c3e56879876842d4b3600f20c1f79876-0:0" - response_xml = read_xml_asset(UPDATE_HYPER_DATA_XML) - with requests_mock.mock() as rm, unittest.mock.patch.object(Fileuploads, "upload", return_value=mock_upload_id): - rm.patch( - self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/data?uploadSessionId=" + mock_upload_id, - status_code=202, - headers={"requestid": "test_id"}, - text=response_xml, - ) - new_job = self.server.datasources.update_hyper_data( - datasource_id, request_id="test_id", actions=[], payload=asset("World Indicators.hyper") - ) + # We only check the `id`; remaining fields are already tested in `test_update_hyper_data_datasource_object` + assert "5c0ba560-c959-424e-b08a-f32ef0bfb737" == new_job.id - # We only check the `id`; remaining fields are already tested in `test_update_hyper_data_datasource_object` - self.assertEqual("5c0ba560-c959-424e-b08a-f32ef0bfb737", new_job.id) - def test_update_hyper_data_datasource_invalid_payload_file(self) -> None: - """If `payload` points to a non-existing file, we report an error""" - self.server.version = "3.13" - self.baseurl = self.server.datasources.baseurl - datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - with self.assertRaises(IOError) as cm: - self.server.datasources.update_hyper_data( - datasource_id, request_id="test_id", actions=[], payload="no/such/file.missing" - ) - exception = cm.exception - self.assertEqual(str(exception), "File path does not lead to an existing file.") +def test_update_hyper_data_datasource_invalid_payload_file(server) -> None: + """If `payload` points to a non-existing file, we report an error""" + server.version = "3.13" + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + with pytest.raises(IOError, match="File path does not lead to an existing file."): + server.datasources.update_hyper_data( + datasource_id, request_id="test_id", actions=[], payload="no/such/file.missing" + ) - def test_delete(self) -> None: - with requests_mock.mock() as m: - m.delete(self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", status_code=204) - self.server.datasources.delete("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") - def test_download(self) -> None: - with requests_mock.mock() as m: - m.get( - self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", - headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, - ) - file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") - self.assertTrue(os.path.exists(file_path)) - os.remove(file_path) - - def test_download_object(self) -> None: - with BytesIO() as file_object: - with requests_mock.mock() as m: - m.get( - self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", - headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, - ) - file_path = self.server.datasources.download( - "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", filepath=file_object - ) - self.assertTrue(isinstance(file_path, BytesIO)) - - def test_download_sanitizes_name(self) -> None: - filename = "Name,With,Commas.tds" - disposition = f'name="tableau_workbook"; filename="{filename}"' - with requests_mock.mock() as m: - m.get( - self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", - headers={"Content-Disposition": disposition}, - ) - file_path = self.server.datasources.download("1f951daf-4061-451a-9df1-69a8062664f2") - self.assertEqual(os.path.basename(file_path), "NameWithCommas.tds") - self.assertTrue(os.path.exists(file_path)) - os.remove(file_path) +def test_delete(server) -> None: + with requests_mock.mock() as m: + m.delete(server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", status_code=204) + server.datasources.delete("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") - def test_download_extract_only(self) -> None: - # Pretend we're 2.5 for 'extract_only' - self.server.version = "2.5" - self.baseurl = self.server.datasources.baseurl +def test_download(server) -> None: + with requests_mock.mock() as m: + m.get( + server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", + headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, + ) + file_path = server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") + assert os.path.exists(file_path) + os.remove(file_path) + + +def test_download_object(server) -> None: + with BytesIO() as file_object: with requests_mock.mock() as m: m.get( - self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content?includeExtract=False", + server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, - complete_qs=True, ) - file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", include_extract=False) - self.assertTrue(os.path.exists(file_path)) - os.remove(file_path) - - def test_update_missing_id(self) -> None: - single_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") - self.assertRaises(TSC.MissingRequiredFieldError, self.server.datasources.update, single_datasource) - - def test_publish_missing_path(self) -> None: - new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") - self.assertRaises( - IOError, self.server.datasources.publish, new_datasource, "", self.server.PublishMode.CreateNew + file_path = server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", filepath=file_object) + assert isinstance(file_path, BytesIO) + + +def test_download_sanitizes_name(server) -> None: + filename = "Name,With,Commas.tds" + disposition = f'name="tableau_workbook"; filename="{filename}"' + with requests_mock.mock() as m: + m.get( + server.datasources.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", + headers={"Content-Disposition": disposition}, + ) + file_path = server.datasources.download("1f951daf-4061-451a-9df1-69a8062664f2") + assert os.path.basename(file_path) == "NameWithCommas.tds" + assert os.path.exists(file_path) + os.remove(file_path) + + +def test_download_extract_only(server) -> None: + # Pretend we're 2.5 for 'extract_only' + server.version = "2.5" + + with requests_mock.mock() as m: + m.get( + server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content?includeExtract=False", + headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, + complete_qs=True, ) + file_path = server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", include_extract=False) + assert os.path.exists(file_path) + os.remove(file_path) - def test_publish_missing_mode(self) -> None: - new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") - self.assertRaises(ValueError, self.server.datasources.publish, new_datasource, asset("SampleDS.tds"), None) - def test_publish_invalid_file_type(self) -> None: - new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") - self.assertRaises( - ValueError, - self.server.datasources.publish, +def test_update_missing_id(server) -> None: + single_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") + with pytest.raises(TSC.MissingRequiredFieldError): + server.datasources.update(single_datasource) + + +def test_publish_missing_path(server) -> None: + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") + with pytest.raises(IOError): + server.datasources.publish(new_datasource, "", server.PublishMode.CreateNew) + + +def test_publish_missing_mode(server) -> None: + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") + with pytest.raises(ValueError): + server.datasources.publish(new_datasource, TEST_ASSET_DIR / "SampleDS.tds", None) + + +def test_publish_invalid_file_type(server) -> None: + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") + with pytest.raises(ValueError): + server.datasources.publish( new_datasource, - asset("SampleWB.twbx"), - self.server.PublishMode.Append, + TEST_ASSET_DIR / "SampleWB.twbx", + server.PublishMode.Append, ) - def test_publish_hyper_file_object_raises_exception(self) -> None: - new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") - with open(asset("World Indicators.hyper"), "rb") as file_object: - self.assertRaises( - ValueError, self.server.datasources.publish, new_datasource, file_object, self.server.PublishMode.Append - ) - def test_publish_tde_file_object_raises_exception(self) -> None: - new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") - tds_asset = asset(os.path.join("Data", "Tableau Samples", "World Indicators.tde")) - with open(tds_asset, "rb") as file_object: - self.assertRaises( - ValueError, self.server.datasources.publish, new_datasource, file_object, self.server.PublishMode.Append - ) +def test_publish_hyper_file_object_raises_exception(server) -> None: + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") + with open(TEST_ASSET_DIR / "World Indicators.hyper", "rb") as file_object: + with pytest.raises(ValueError): + server.datasources.publish(new_datasource, file_object, server.PublishMode.Append) - def test_publish_file_object_of_unknown_type_raises_exception(self) -> None: - new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") - with BytesIO() as file_object: - file_object.write(bytes.fromhex("89504E470D0A1A0A")) - file_object.seek(0) - self.assertRaises( - ValueError, self.server.datasources.publish, new_datasource, file_object, self.server.PublishMode.Append - ) +def test_publish_tde_file_object_raises_exception(server) -> None: + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") + tds_asset = TEST_ASSET_DIR / "Data" / "Tableau Samples" / "World Indicators.tde" + with open(tds_asset, "rb") as file_object: + with pytest.raises(ValueError): + server.datasources.publish(new_datasource, file_object, server.PublishMode.Append) - def test_publish_multi_connection(self) -> None: - new_datasource = TSC.DatasourceItem(name="Sample", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - connection1 = TSC.ConnectionItem() - connection1.server_address = "mysql.test.com" - connection1.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) - connection2 = TSC.ConnectionItem() - connection2.server_address = "pgsql.test.com" - connection2.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) - - response = RequestFactory.Datasource._generate_xml(new_datasource, connections=[connection1, connection2]) - # Can't use ConnectionItem parser due to xml namespace problems - connection_results = fromstring(response).findall(".//connection") - - self.assertEqual(connection_results[0].get("serverAddress", None), "mysql.test.com") - self.assertEqual(connection_results[0].find("connectionCredentials").get("name", None), "test") # type: ignore[union-attr] - self.assertEqual(connection_results[1].get("serverAddress", None), "pgsql.test.com") - self.assertEqual(connection_results[1].find("connectionCredentials").get("password", None), "secret") # type: ignore[union-attr] - - def test_publish_single_connection(self) -> None: - new_datasource = TSC.DatasourceItem(name="Sample", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - connection_creds = TSC.ConnectionCredentials("test", "secret", True) - - response = RequestFactory.Datasource._generate_xml(new_datasource, connection_credentials=connection_creds) - # Can't use ConnectionItem parser due to xml namespace problems - credentials = fromstring(response).findall(".//connectionCredentials") - - self.assertEqual(len(credentials), 1) - self.assertEqual(credentials[0].get("name", None), "test") - self.assertEqual(credentials[0].get("password", None), "secret") - self.assertEqual(credentials[0].get("embed", None), "true") - - def test_credentials_and_multi_connect_raises_exception(self) -> None: - new_datasource = TSC.DatasourceItem(name="Sample", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - - connection_creds = TSC.ConnectionCredentials("test", "secret", True) - - connection1 = TSC.ConnectionItem() - connection1.server_address = "mysql.test.com" - connection1.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) - - with self.assertRaises(RuntimeError): - response = RequestFactory.Datasource._generate_xml( - new_datasource, connection_credentials=connection_creds, connections=[connection1] - ) - def test_synchronous_publish_timeout_error(self) -> None: - with requests_mock.mock() as m: - m.register_uri("POST", self.baseurl, status_code=504) - - new_datasource = TSC.DatasourceItem(project_id="") - publish_mode = self.server.PublishMode.CreateNew - # http://test/api/2.4/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources?datasourceType=tds - self.assertRaisesRegex( - InternalServerError, - "Please use asynchronous publishing to avoid timeouts.", - self.server.datasources.publish, +def test_publish_file_object_of_unknown_type_raises_exception(server) -> None: + new_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") + + with BytesIO() as file_object: + file_object.write(bytes.fromhex("89504E470D0A1A0A")) + file_object.seek(0) + with pytest.raises(ValueError): + server.datasources.publish(new_datasource, file_object, server.PublishMode.Append) + + +def test_publish_multi_connection(server) -> None: + new_datasource = TSC.DatasourceItem(name="Sample", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + connection1 = TSC.ConnectionItem() + connection1.server_address = "mysql.test.com" + connection1.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) + connection2 = TSC.ConnectionItem() + connection2.server_address = "pgsql.test.com" + connection2.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) + + response = RequestFactory.Datasource._generate_xml(new_datasource, connections=[connection1, connection2]) + # Can't use ConnectionItem parser due to xml namespace problems + connection_results = fromstring(response).findall(".//connection") + + assert connection_results[0].get("serverAddress", None) == "mysql.test.com" + assert connection_results[0].find("connectionCredentials").get("name", None) == "test" + assert connection_results[1].get("serverAddress", None) == "pgsql.test.com" + assert connection_results[1].find("connectionCredentials").get("password", None) == "secret" + + +def test_publish_single_connection(server) -> None: + new_datasource = TSC.DatasourceItem(name="Sample", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + connection_creds = TSC.ConnectionCredentials("test", "secret", True) + + response = RequestFactory.Datasource._generate_xml(new_datasource, connection_credentials=connection_creds) + # Can't use ConnectionItem parser due to xml namespace problems + credentials = fromstring(response).findall(".//connectionCredentials") + + assert len(credentials) == 1 + assert credentials[0].get("name", None) == "test" + assert credentials[0].get("password", None) == "secret" + assert credentials[0].get("embed", None) == "true" + + +def test_credentials_and_multi_connect_raises_exception(server) -> None: + new_datasource = TSC.DatasourceItem(name="Sample", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + + connection_creds = TSC.ConnectionCredentials("test", "secret", True) + + connection1 = TSC.ConnectionItem() + connection1.server_address = "mysql.test.com" + connection1.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) + + with pytest.raises(RuntimeError): + response = RequestFactory.Datasource._generate_xml( + new_datasource, connection_credentials=connection_creds, connections=[connection1] + ) + + +def test_synchronous_publish_timeout_error(server) -> None: + with requests_mock.mock() as m: + m.register_uri("POST", server.datasources.baseurl, status_code=504) + + new_datasource = TSC.DatasourceItem(project_id="") + publish_mode = server.PublishMode.CreateNew + # http://test/api/2.4/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources?datasourceType=tds + + with pytest.raises(InternalServerError, match="Please use asynchronous publishing to avoid timeouts."): + server.datasources.publish( new_datasource, - asset("SampleDS.tds"), + TEST_ASSET_DIR / "SampleDS.tds", publish_mode, ) - def test_delete_extracts(self) -> None: - self.server.version = "3.10" - self.baseurl = self.server.datasources.baseurl - with requests_mock.mock() as m: - m.post(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/deleteExtract", status_code=200) - self.server.datasources.delete_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - def test_create_extracts(self) -> None: - self.server.version = "3.10" - self.baseurl = self.server.datasources.baseurl +def test_delete_extracts(server) -> None: + server.version = "3.10" + with requests_mock.mock() as m: + m.post(server.datasources.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/deleteExtract", status_code=200) + server.datasources.delete_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - response_xml = read_xml_asset(PUBLISH_XML_ASYNC) - with requests_mock.mock() as m: - m.post( - self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract", status_code=200, text=response_xml - ) - self.server.datasources.create_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - def test_create_extracts_encrypted(self) -> None: - self.server.version = "3.10" - self.baseurl = self.server.datasources.baseurl +def test_create_extracts(server) -> None: + server.version = "3.10" - response_xml = read_xml_asset(PUBLISH_XML_ASYNC) - with requests_mock.mock() as m: - m.post( - self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract", status_code=200, text=response_xml - ) - self.server.datasources.create_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42", True) + response_xml = PUBLISH_XML_ASYNC.read_text() + with requests_mock.mock() as m: + m.post( + server.datasources.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract", + status_code=200, + text=response_xml, + ) + server.datasources.create_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - def test_revisions(self) -> None: - datasource = TSC.DatasourceItem("project", "test") - datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" - response_xml = read_xml_asset(REVISION_XML) - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{datasource.id}/revisions", text=response_xml) - self.server.datasources.populate_revisions(datasource) - revisions = datasource.revisions - - self.assertEqual(len(revisions), 3) - self.assertEqual("2016-07-26T20:34:56Z", format_datetime(revisions[0].created_at)) - self.assertEqual("2016-07-27T20:34:56Z", format_datetime(revisions[1].created_at)) - self.assertEqual("2016-07-28T20:34:56Z", format_datetime(revisions[2].created_at)) - - self.assertEqual(False, revisions[0].deleted) - self.assertEqual(False, revisions[0].current) - self.assertEqual(False, revisions[1].deleted) - self.assertEqual(False, revisions[1].current) - self.assertEqual(False, revisions[2].deleted) - self.assertEqual(True, revisions[2].current) - - self.assertEqual("Cassie", revisions[0].user_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", revisions[0].user_id) - self.assertIsNone(revisions[1].user_name) - self.assertIsNone(revisions[1].user_id) - self.assertEqual("Cassie", revisions[2].user_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", revisions[2].user_id) - - def test_delete_revision(self) -> None: - datasource = TSC.DatasourceItem("project", "test") - datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" +def test_create_extracts_encrypted(server) -> None: + server.version = "3.10" - with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{datasource.id}/revisions/3") - self.server.datasources.delete_revision(datasource.id, "3") + response_xml = PUBLISH_XML_ASYNC.read_text() + with requests_mock.mock() as m: + m.post( + server.datasources.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/createExtract", + status_code=200, + text=response_xml, + ) + server.datasources.create_extract("3cc6cd06-89ce-4fdc-b935-5294135d6d42", True) - def test_download_revision(self) -> None: - with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: - m.get( - self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/revisions/3/content", - headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, - ) - file_path = self.server.datasources.download_revision("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", "3", td) - self.assertTrue(os.path.exists(file_path)) - def test_bad_download_response(self) -> None: - with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: - m.get( - self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", - headers={ - "Content-Disposition": '''name="tableau_datasource"; filename*=UTF-8''"Sample datasource.tds"''' - }, - ) - file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) - self.assertTrue(os.path.exists(file_path)) +def test_revisions(server) -> None: + datasource = TSC.DatasourceItem("project", "test") + datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" - def test_get_datasource_all_fields(self) -> None: - ro = TSC.RequestOptions() - ro.all_fields = True - with requests_mock.mock() as m: - m.get(f"{self.baseurl}?fields=_all_", text=read_xml_asset(GET_XML_ALL_FIELDS)) - datasources, _ = self.server.datasources.get(req_options=ro) - - assert datasources[0].connected_workbooks_count == 0 - assert datasources[0].content_url == "SuperstoreDatasource" - assert datasources[0].created_at == parse_datetime("2024-02-14T04:42:13Z") - assert not datasources[0].encrypt_extracts - assert datasources[0].favorites_total == 0 - assert not datasources[0].has_alert - assert not datasources[0].has_extracts - assert datasources[0].id == "a71cdd15-3a23-4ec1-b3ce-9956f5e00bb7" - assert not datasources[0].certified - assert datasources[0].is_published - assert datasources[0].name == "Superstore Datasource" - assert datasources[0].size == 1 - assert datasources[0].datasource_type == "excel-direct" - assert datasources[0].updated_at == parse_datetime("2024-02-14T04:42:14Z") - assert not datasources[0].use_remote_query_agent - assert datasources[0].server_name == "localhost" - assert datasources[0].webpage_url == "https://10ax.online.tableau.com/#/site/example/datasources/3566752" - assert isinstance(datasources[0].project, TSC.ProjectItem) - assert datasources[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" - assert datasources[0].project.name == "Samples" - assert datasources[0].project.description == "This project includes automatically uploaded samples." - assert datasources[0].owner.email == "bob@example.com" - assert isinstance(datasources[0].owner, TSC.UserItem) - assert datasources[0].owner.fullname == "Bob Smith" - assert datasources[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" - assert datasources[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") - assert datasources[0].owner.name == "bob@example.com" - assert datasources[0].owner.site_role == "SiteAdministratorCreator" + response_xml = REVISION_XML.read_text() + with requests_mock.mock() as m: + m.get(f"{server.datasources.baseurl}/{datasource.id}/revisions", text=response_xml) + server.datasources.populate_revisions(datasource) + revisions = datasource.revisions + + assert len(revisions) == 3 + assert "2016-07-26T20:34:56Z" == format_datetime(revisions[0].created_at) + assert "2016-07-27T20:34:56Z" == format_datetime(revisions[1].created_at) + assert "2016-07-28T20:34:56Z" == format_datetime(revisions[2].created_at) + + assert False == revisions[0].deleted + assert False == revisions[0].current + assert False == revisions[1].deleted + assert False == revisions[1].current + assert False == revisions[2].deleted + assert True == revisions[2].current + + assert "Cassie" == revisions[0].user_name + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == revisions[0].user_id + assert revisions[1].user_name is None + assert revisions[1].user_id is None + assert "Cassie" == revisions[2].user_name + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == revisions[2].user_id + + +def test_delete_revision(server) -> None: + datasource = TSC.DatasourceItem("project", "test") + datasource._id = "06b944d2-959d-4604-9305-12323c95e70e" + + with requests_mock.mock() as m: + m.delete(f"{server.datasources.baseurl}/{datasource.id}/revisions/3") + server.datasources.delete_revision(datasource.id, "3") + + +def test_download_revision(server) -> None: + with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: + m.get( + server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/revisions/3/content", + headers={"Content-Disposition": 'name="tableau_datasource"; filename="Sample datasource.tds"'}, + ) + file_path = server.datasources.download_revision("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", "3", td) + assert os.path.exists(file_path) + + +def test_bad_download_response(server) -> None: + with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: + m.get( + server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", + headers={"Content-Disposition": '''name="tableau_datasource"; filename*=UTF-8''"Sample datasource.tds"'''}, + ) + file_path = server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) + assert os.path.exists(file_path) + + +def test_get_datasource_all_fields(server) -> None: + ro = TSC.RequestOptions() + ro.all_fields = True + with requests_mock.mock() as m: + m.get(f"{server.datasources.baseurl}?fields=_all_", text=GET_XML_ALL_FIELDS.read_text()) + datasources, _ = server.datasources.get(req_options=ro) + + assert datasources[0].connected_workbooks_count == 0 + assert datasources[0].content_url == "SuperstoreDatasource" + assert datasources[0].created_at == parse_datetime("2024-02-14T04:42:13Z") + assert not datasources[0].encrypt_extracts + assert datasources[0].favorites_total == 0 + assert not datasources[0].has_alert + assert not datasources[0].has_extracts + assert datasources[0].id == "a71cdd15-3a23-4ec1-b3ce-9956f5e00bb7" + assert not datasources[0].certified + assert datasources[0].is_published + assert datasources[0].name == "Superstore Datasource" + assert datasources[0].size == 1 + assert datasources[0].datasource_type == "excel-direct" + assert datasources[0].updated_at == parse_datetime("2024-02-14T04:42:14Z") + assert not datasources[0].use_remote_query_agent + assert datasources[0].server_name == "localhost" + assert datasources[0].webpage_url == "https://10ax.online.tableau.com/#/site/example/datasources/3566752" + assert isinstance(datasources[0].project, TSC.ProjectItem) + assert datasources[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert datasources[0].project.name == "Samples" + assert datasources[0].project.description == "This project includes automatically uploaded samples." + assert datasources[0].owner.email == "bob@example.com" + assert isinstance(datasources[0].owner, TSC.UserItem) + assert datasources[0].owner.fullname == "Bob Smith" + assert datasources[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert datasources[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert datasources[0].owner.name == "bob@example.com" + assert datasources[0].owner.site_role == "SiteAdministratorCreator" From c31784af0a25506aeb9bec61ad2133762bdc8451 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 22:54:31 -0500 Subject: [PATCH 28/44] chore: pytestify test_connection_ (#1650) * chore: pytestify test_connection_ Embrace pytest's testing methodology in test_connection_ * chore: parameterize test * chore: add type hints to tests --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_connection_.py | 45 ++++++++++++++++------------------------ 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/test/test_connection_.py b/test/test_connection_.py index 47b796ebe..8bfed79c7 100644 --- a/test/test_connection_.py +++ b/test/test_connection_.py @@ -1,34 +1,25 @@ -import unittest import tableauserverclient as TSC +import pytest -class DatasourceModelTests(unittest.TestCase): - def test_require_boolean_query_tag_fails(self): - conn = TSC.ConnectionItem() - conn._connection_type = "postgres" - with self.assertRaises(ValueError): - conn.query_tagging = "no" - def test_set_query_tag_normal_conn(self): - conn = TSC.ConnectionItem() - conn._connection_type = "postgres" - conn.query_tagging = True - self.assertEqual(conn.query_tagging, True) +def test_require_boolean_query_tag_fails() -> None: + conn = TSC.ConnectionItem() + conn._connection_type = "postgres" + with pytest.raises(ValueError): + conn.query_tagging = "no" # type: ignore[assignment] - def test_ignore_query_tag_for_hyper(self): - conn = TSC.ConnectionItem() - conn._connection_type = "hyper" - conn.query_tagging = True - self.assertEqual(conn.query_tagging, None) - def test_ignore_query_tag_for_teradata(self): - conn = TSC.ConnectionItem() - conn._connection_type = "teradata" - conn.query_tagging = True - self.assertEqual(conn.query_tagging, None) +def test_set_query_tag_normal_conn() -> None: + conn = TSC.ConnectionItem() + conn._connection_type = "postgres" + conn.query_tagging = True + assert conn.query_tagging - def test_ignore_query_tag_for_snowflake(self): - conn = TSC.ConnectionItem() - conn._connection_type = "snowflake" - conn.query_tagging = True - self.assertEqual(conn.query_tagging, None) + +@pytest.mark.parametrize("conn_type", ["hyper", "teradata", "snowflake"]) +def test_ignore_query_tag(conn_type: str) -> None: + conn = TSC.ConnectionItem() + conn._connection_type = conn_type + conn.query_tagging = True + assert conn.query_tagging is None From 4ef5b99963496017d05e63458a03a925959f59ea Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 22:57:42 -0500 Subject: [PATCH 29/44] chore: pytestify test_custom_view (#1651) * chore: pytestify test_custom_view * chore: remove unused import * chore: add server type hints --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_custom_view.py | 615 ++++++++++++++++++++------------------- 1 file changed, 315 insertions(+), 300 deletions(-) diff --git a/test/test_custom_view.py b/test/test_custom_view.py index 6e863a863..0df3b849f 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -3,318 +3,333 @@ import os from pathlib import Path from tempfile import TemporaryDirectory -import unittest +import pytest import requests_mock import tableauserverclient as TSC from tableauserverclient.config import BYTES_PER_MB from tableauserverclient.datetime_helpers import format_datetime -from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError TEST_ASSET_DIR = Path(__file__).parent / "assets" -GET_XML = os.path.join(TEST_ASSET_DIR, "custom_view_get.xml") -GET_XML_ID = os.path.join(TEST_ASSET_DIR, "custom_view_get_id.xml") -POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png") -CUSTOM_VIEW_UPDATE_XML = os.path.join(TEST_ASSET_DIR, "custom_view_update.xml") -CUSTOM_VIEW_POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") -CUSTOM_VIEW_POPULATE_CSV = os.path.join(TEST_ASSET_DIR, "populate_csv.csv") +GET_XML = TEST_ASSET_DIR / "custom_view_get.xml" +GET_XML_ID = TEST_ASSET_DIR / "custom_view_get_id.xml" +POPULATE_PREVIEW_IMAGE = TEST_ASSET_DIR / "Sample View Image.png" +CUSTOM_VIEW_UPDATE_XML = TEST_ASSET_DIR / "custom_view_update.xml" +CUSTOM_VIEW_POPULATE_PDF = TEST_ASSET_DIR / "populate_pdf.pdf" +CUSTOM_VIEW_POPULATE_CSV = TEST_ASSET_DIR / "populate_csv.csv" CUSTOM_VIEW_DOWNLOAD = TEST_ASSET_DIR / "custom_view_download.json" FILE_UPLOAD_INIT = TEST_ASSET_DIR / "fileupload_initialize.xml" FILE_UPLOAD_APPEND = TEST_ASSET_DIR / "fileupload_append.xml" -class CustomViewTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server("http://test", False) - self.server.version = "3.21" # custom views only introduced in 3.19 - - # Fake sign in - self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - - self.baseurl = self.server.custom_views.baseurl - - def test_get(self) -> None: - with open(GET_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - print(response_xml) - with requests_mock.mock() as m: - m.get(self.baseurl, text=response_xml) - all_views, pagination_item = self.server.custom_views.get() - - self.assertEqual(2, pagination_item.total_available) - self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", all_views[0].id) - self.assertEqual("ENDANGERED SAFARI", all_views[0].name) - self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", all_views[0].content_url) - self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", all_views[0].workbook.id) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner.id) - self.assertIsNone(all_views[0].created_at) - self.assertIsNone(all_views[0].updated_at) - self.assertFalse(all_views[0].shared) - - self.assertEqual("fd252f73-593c-4c4e-8584-c032b8022adc", all_views[1].id) - self.assertEqual("Overview", all_views[1].name) - self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", all_views[1].workbook.id) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[1].owner.id) - self.assertEqual("2002-05-30T09:00:00Z", format_datetime(all_views[1].created_at)) - self.assertEqual("2002-06-05T08:00:59Z", format_datetime(all_views[1].updated_at)) - self.assertTrue(all_views[1].shared) - - def test_get_by_id(self) -> None: - with open(GET_XML_ID, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5", text=response_xml) - view: TSC.CustomViewItem = self.server.custom_views.get_by_id("d79634e1-6063-4ec9-95ff-50acbf609ff5") - - self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", view.id) - self.assertEqual("ENDANGERED SAFARI", view.name) - self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", view.content_url) - if view.workbook: - self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook.id) - if view.owner: - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner.id) - if view.view: - self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.view.id) - self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) - self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) - - def test_get_by_id_missing_id(self) -> None: - self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.get_by_id, None) - - def test_get_before_signin(self) -> None: - self.server._auth_token = None - self.assertRaises(TSC.NotSignedInError, self.server.custom_views.get) - - def test_populate_image(self) -> None: - with open(POPULATE_PREVIEW_IMAGE, "rb") as f: - response = f.read() - with requests_mock.mock() as m: - m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image", content=response) - single_view = TSC.CustomViewItem() - single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - self.server.custom_views.populate_image(single_view) - self.assertEqual(response, single_view.image) - - def test_populate_image_with_options(self) -> None: - with open(POPULATE_PREVIEW_IMAGE, "rb") as f: - response = f.read() - with requests_mock.mock() as m: - m.get( - self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high&maxAge=10", content=response - ) - single_view = TSC.CustomViewItem() - single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage=10) - self.server.custom_views.populate_image(single_view, req_option) - self.assertEqual(response, single_view.image) - - def test_populate_image_missing_id(self) -> None: +@pytest.fixture(scope="function") +def server() -> TSC.Server: + server = TSC.Server("http://test", False) + server.version = "3.21" # custom views only introduced in 3.19 + + # Fake sign in + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + return server + + +def test_get(server: TSC.Server) -> None: + response_xml = GET_XML.read_text() + print(response_xml) + with requests_mock.mock() as m: + m.get(server.custom_views.baseurl, text=response_xml) + all_views, pagination_item = server.custom_views.get() + + assert 2 == pagination_item.total_available + assert "d79634e1-6063-4ec9-95ff-50acbf609ff5" == all_views[0].id + assert "ENDANGERED SAFARI" == all_views[0].name + assert "SafariSample/sheets/ENDANGEREDSAFARI" == all_views[0].content_url + assert "3cc6cd06-89ce-4fdc-b935-5294135d6d42" == all_views[0].workbook.id + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == all_views[0].owner.id + assert all_views[0].created_at is None + assert all_views[0].updated_at is None + assert not all_views[0].shared + + assert "fd252f73-593c-4c4e-8584-c032b8022adc" == all_views[1].id + assert "Overview" == all_views[1].name + assert "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" == all_views[1].workbook.id + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == all_views[1].owner.id + assert "2002-05-30T09:00:00Z" == format_datetime(all_views[1].created_at) + assert "2002-06-05T08:00:59Z" == format_datetime(all_views[1].updated_at) + assert all_views[1].shared + + +def test_get_by_id(server: TSC.Server) -> None: + response_xml = GET_XML_ID.read_text() + with requests_mock.mock() as m: + m.get(server.custom_views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5", text=response_xml) + view: TSC.CustomViewItem = server.custom_views.get_by_id("d79634e1-6063-4ec9-95ff-50acbf609ff5") + + assert "d79634e1-6063-4ec9-95ff-50acbf609ff5" == view.id + assert "ENDANGERED SAFARI" == view.name + assert "SafariSample/sheets/ENDANGEREDSAFARI" == view.content_url + if view.workbook: + assert "3cc6cd06-89ce-4fdc-b935-5294135d6d42" == view.workbook.id + if view.owner: + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == view.owner.id + if view.view: + assert "5241e88d-d384-4fd7-9c2f-648b5247efc5" == view.view.id + assert "2002-05-30T09:00:00Z" == format_datetime(view.created_at) + assert "2002-06-05T08:00:59Z" == format_datetime(view.updated_at) + + +def test_get_by_id_missing_id(server: TSC.Server) -> None: + with pytest.raises(TSC.MissingRequiredFieldError): + server.custom_views.get_by_id(None) + + +def test_get_before_signin(server: TSC.Server) -> None: + server._auth_token = None + with pytest.raises(TSC.NotSignedInError): + server.custom_views.get() + + +def test_populate_image(server: TSC.Server) -> None: + response = POPULATE_PREVIEW_IMAGE.read_bytes() + with requests_mock.mock() as m: + m.get(server.custom_views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image", content=response) single_view = TSC.CustomViewItem() - single_view._id = None - self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.populate_image, single_view) - - def test_delete(self) -> None: - with requests_mock.mock() as m: - m.delete(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42", status_code=204) - self.server.custom_views.delete("3cc6cd06-89ce-4fdc-b935-5294135d6d42") - - def test_delete_missing_id(self) -> None: - self.assertRaises(ValueError, self.server.custom_views.delete, "") - - def test_update(self) -> None: - with open(CUSTOM_VIEW_UPDATE_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) - the_custom_view = TSC.CustomViewItem("1d0304cd-3796-429f-b815-7258370b9b74", name="Best test ever") - the_custom_view._id = "1f951daf-4061-451a-9df1-69a8062664f2" - the_custom_view.owner = TSC.UserItem() - the_custom_view.owner.id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - the_custom_view = self.server.custom_views.update(the_custom_view) - - self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", the_custom_view.id) - if the_custom_view.owner: - self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", the_custom_view.owner.id) - self.assertEqual("Best test ever", the_custom_view.name) - - def test_update_missing_id(self) -> None: - cv = TSC.CustomViewItem(name="test") - self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.update, cv) - - def test_download(self) -> None: - cv = TSC.CustomViewItem(name="test") - cv._id = "1f951daf-4061-451a-9df1-69a8062664f2" - content = CUSTOM_VIEW_DOWNLOAD.read_bytes() - data = io.BytesIO() - with requests_mock.mock() as m: - m.get(f"{self.server.custom_views.expurl}/1f951daf-4061-451a-9df1-69a8062664f2/content", content=content) - self.server.custom_views.download(cv, data) - - assert data.getvalue() == content - - def test_publish_filepath(self) -> None: - cv = TSC.CustomViewItem(name="test") - cv._owner = TSC.UserItem() - cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - cv._workbook = TSC.WorkbookItem() - cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - with requests_mock.mock() as m: - m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) - view = self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD) - - assert view is not None - assert isinstance(view, TSC.CustomViewItem) - assert view.id is not None - assert view.name is not None - - def test_publish_file_str(self) -> None: - cv = TSC.CustomViewItem(name="test") - cv._owner = TSC.UserItem() - cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - cv._workbook = TSC.WorkbookItem() - cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - with requests_mock.mock() as m: - m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) - view = self.server.custom_views.publish(cv, str(CUSTOM_VIEW_DOWNLOAD)) - - assert view is not None - assert isinstance(view, TSC.CustomViewItem) - assert view.id is not None - assert view.name is not None - - def test_publish_file_io(self) -> None: - cv = TSC.CustomViewItem(name="test") - cv._owner = TSC.UserItem() - cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - cv._workbook = TSC.WorkbookItem() - cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - data = io.BytesIO(CUSTOM_VIEW_DOWNLOAD.read_bytes()) - with requests_mock.mock() as m: - m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) - view = self.server.custom_views.publish(cv, data) - - assert view is not None - assert isinstance(view, TSC.CustomViewItem) - assert view.id is not None - assert view.name is not None - - def test_publish_missing_owner_id(self) -> None: - cv = TSC.CustomViewItem(name="test") - cv._owner = TSC.UserItem() - cv._workbook = TSC.WorkbookItem() - cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - with requests_mock.mock() as m: - m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) - with self.assertRaises(ValueError): - self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD) - - def test_publish_missing_wb_id(self) -> None: - cv = TSC.CustomViewItem(name="test") - cv._owner = TSC.UserItem() - cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - cv._workbook = TSC.WorkbookItem() - with requests_mock.mock() as m: - m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) - with self.assertRaises(ValueError): - self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD) - - def test_large_publish(self): - cv = TSC.CustomViewItem(name="test") - cv._owner = TSC.UserItem() - cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - cv._workbook = TSC.WorkbookItem() - cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - with ExitStack() as stack: - temp_dir = stack.enter_context(TemporaryDirectory()) - file_path = Path(temp_dir) / "test_file" - file_path.write_bytes(os.urandom(65 * BYTES_PER_MB)) - mock = stack.enter_context(requests_mock.mock()) - # Mock initializing upload - mock.post(self.server.fileuploads.baseurl, status_code=201, text=FILE_UPLOAD_INIT.read_text()) - # Mock the upload - mock.put( - f"{self.server.fileuploads.baseurl}/7720:170fe6b1c1c7422dadff20f944d58a52-1:0", - text=FILE_UPLOAD_APPEND.read_text(), - ) - # Mock the publish - mock.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) - - view = self.server.custom_views.publish(cv, file_path) - - assert view is not None - assert isinstance(view, TSC.CustomViewItem) - assert view.id is not None - assert view.name is not None - - def test_populate_pdf(self) -> None: - self.server.version = "3.23" - self.baseurl = self.server.custom_views.baseurl - with open(CUSTOM_VIEW_POPULATE_PDF, "rb") as f: - response = f.read() - with requests_mock.mock() as m: - m.get( - self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait&maxAge=5", - content=response, - ) - custom_view = TSC.CustomViewItem() - custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - - size = TSC.PDFRequestOptions.PageType.Letter - orientation = TSC.PDFRequestOptions.Orientation.Portrait - req_option = TSC.PDFRequestOptions(size, orientation, 5) - - self.server.custom_views.populate_pdf(custom_view, req_option) - self.assertEqual(response, custom_view.pdf) - - def test_populate_csv(self) -> None: - self.server.version = "3.23" - self.baseurl = self.server.custom_views.baseurl - with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f: - response = f.read() - with requests_mock.mock() as m: - m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data?maxAge=1", content=response) - custom_view = TSC.CustomViewItem() - custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - request_option = TSC.CSVRequestOptions(maxage=1) - self.server.custom_views.populate_csv(custom_view, request_option) - - csv_file = b"".join(custom_view.csv) - self.assertEqual(response, csv_file) - - def test_populate_csv_default_maxage(self) -> None: - self.server.version = "3.23" - self.baseurl = self.server.custom_views.baseurl - with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f: - response = f.read() - with requests_mock.mock() as m: - m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data", content=response) - custom_view = TSC.CustomViewItem() - custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - self.server.custom_views.populate_csv(custom_view) - - csv_file = b"".join(custom_view.csv) - self.assertEqual(response, csv_file) - - def test_pdf_height(self) -> None: - self.server.version = "3.23" - self.baseurl = self.server.custom_views.baseurl - with open(CUSTOM_VIEW_POPULATE_PDF, "rb") as f: - response = f.read() - with requests_mock.mock() as m: - m.get( - self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?vizHeight=1080&vizWidth=1920", - content=response, - ) - custom_view = TSC.CustomViewItem() - custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - - req_option = TSC.PDFRequestOptions( - viz_height=1080, - viz_width=1920, - ) - - self.server.custom_views.populate_pdf(custom_view, req_option) - self.assertEqual(response, custom_view.pdf) + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + server.custom_views.populate_image(single_view) + assert response == single_view.image + + +def test_populate_image_with_options(server: TSC.Server) -> None: + response = POPULATE_PREVIEW_IMAGE.read_bytes() + with requests_mock.mock() as m: + m.get( + server.custom_views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high&maxAge=10", + content=response, + ) + single_view = TSC.CustomViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage=10) + server.custom_views.populate_image(single_view, req_option) + assert response == single_view.image + + +def test_populate_image_missing_id(server: TSC.Server) -> None: + single_view = TSC.CustomViewItem() + single_view._id = None + with pytest.raises(TSC.MissingRequiredFieldError): + server.custom_views.populate_image(single_view) + + +def test_delete(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.delete(server.custom_views.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42", status_code=204) + server.custom_views.delete("3cc6cd06-89ce-4fdc-b935-5294135d6d42") + + +def test_delete_missing_id(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.custom_views.delete("") + + +def test_update(server: TSC.Server) -> None: + response_xml = CUSTOM_VIEW_UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.put(server.custom_views.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + the_custom_view = TSC.CustomViewItem("1d0304cd-3796-429f-b815-7258370b9b74", name="Best test ever") + the_custom_view._id = "1f951daf-4061-451a-9df1-69a8062664f2" + the_custom_view.owner = TSC.UserItem() + the_custom_view.owner.id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + the_custom_view = server.custom_views.update(the_custom_view) + + assert "1f951daf-4061-451a-9df1-69a8062664f2" == the_custom_view.id + if the_custom_view.owner: + assert "dd2239f6-ddf1-4107-981a-4cf94e415794" == the_custom_view.owner.id + assert "Best test ever" == the_custom_view.name + + +def test_update_missing_id(server: TSC.Server) -> None: + cv = TSC.CustomViewItem(name="test") + with pytest.raises(TSC.MissingRequiredFieldError): + server.custom_views.update(cv) + + +def test_download(server: TSC.Server) -> None: + cv = TSC.CustomViewItem(name="test") + cv._id = "1f951daf-4061-451a-9df1-69a8062664f2" + content = CUSTOM_VIEW_DOWNLOAD.read_bytes() + data = io.BytesIO() + with requests_mock.mock() as m: + m.get(f"{server.custom_views.expurl}/1f951daf-4061-451a-9df1-69a8062664f2/content", content=content) + server.custom_views.download(cv, data) + + assert data.getvalue() == content + + +def test_publish_filepath(server: TSC.Server) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + with requests_mock.mock() as m: + m.post(server.custom_views.expurl, status_code=201, text=GET_XML.read_text()) + view = server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD) + + assert view is not None + assert isinstance(view, TSC.CustomViewItem) + assert view.id is not None + assert view.name is not None + + +def test_publish_file_str(server: TSC.Server) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + with requests_mock.mock() as m: + m.post(server.custom_views.expurl, status_code=201, text=GET_XML.read_text()) + view = server.custom_views.publish(cv, str(CUSTOM_VIEW_DOWNLOAD)) + + assert view is not None + assert isinstance(view, TSC.CustomViewItem) + assert view.id is not None + assert view.name is not None + + +def test_publish_file_io(server: TSC.Server) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + data = io.BytesIO(CUSTOM_VIEW_DOWNLOAD.read_bytes()) + with requests_mock.mock() as m: + m.post(server.custom_views.expurl, status_code=201, text=GET_XML.read_text()) + view = server.custom_views.publish(cv, data) + + assert view is not None + assert isinstance(view, TSC.CustomViewItem) + assert view.id is not None + assert view.name is not None + + +def test_publish_missing_owner_id(server: TSC.Server) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + with requests_mock.mock() as m: + m.post(server.custom_views.expurl, status_code=201, text=GET_XML.read_text()) + with pytest.raises(ValueError): + server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD) + + +def test_publish_missing_wb_id(server: TSC.Server) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + with requests_mock.mock() as m: + m.post(server.custom_views.expurl, status_code=201, text=GET_XML.read_text()) + with pytest.raises(ValueError): + server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD) + + +def test_large_publish(server: TSC.Server): + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + with ExitStack() as stack: + temp_dir = stack.enter_context(TemporaryDirectory()) + file_path = Path(temp_dir) / "test_file" + file_path.write_bytes(os.urandom(65 * BYTES_PER_MB)) + mock = stack.enter_context(requests_mock.mock()) + # Mock initializing upload + mock.post(server.fileuploads.baseurl, status_code=201, text=FILE_UPLOAD_INIT.read_text()) + # Mock the upload + mock.put( + f"{server.fileuploads.baseurl}/7720:170fe6b1c1c7422dadff20f944d58a52-1:0", + text=FILE_UPLOAD_APPEND.read_text(), + ) + # Mock the publish + mock.post(server.custom_views.expurl, status_code=201, text=GET_XML.read_text()) + + view = server.custom_views.publish(cv, file_path) + + assert view is not None + assert isinstance(view, TSC.CustomViewItem) + assert view.id is not None + assert view.name is not None + + +def test_populate_pdf(server: TSC.Server) -> None: + server.version = "3.23" + response = CUSTOM_VIEW_POPULATE_PDF.read_bytes() + with requests_mock.mock() as m: + m.get( + server.custom_views.baseurl + + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait&maxAge=5", + content=response, + ) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + size = TSC.PDFRequestOptions.PageType.Letter + orientation = TSC.PDFRequestOptions.Orientation.Portrait + req_option = TSC.PDFRequestOptions(size, orientation, 5) + + server.custom_views.populate_pdf(custom_view, req_option) + assert response == custom_view.pdf + + +def test_populate_csv(server: TSC.Server) -> None: + server.version = "3.23" + response = CUSTOM_VIEW_POPULATE_CSV.read_bytes() + with requests_mock.mock() as m: + m.get(server.custom_views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data?maxAge=1", content=response) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + request_option = TSC.CSVRequestOptions(maxage=1) + server.custom_views.populate_csv(custom_view, request_option) + + csv_file = b"".join(custom_view.csv) + assert response == csv_file + + +def test_populate_csv_default_maxage(server: TSC.Server) -> None: + server.version = "3.23" + response = CUSTOM_VIEW_POPULATE_CSV.read_bytes() + with requests_mock.mock() as m: + m.get(server.custom_views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data", content=response) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + server.custom_views.populate_csv(custom_view) + + csv_file = b"".join(custom_view.csv) + assert response == csv_file + + +def test_pdf_height(server: TSC.Server) -> None: + server.version = "3.23" + response = CUSTOM_VIEW_POPULATE_PDF.read_bytes() + with requests_mock.mock() as m: + m.get( + server.custom_views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?vizHeight=1080&vizWidth=1920", + content=response, + ) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.PDFRequestOptions( + viz_height=1080, + viz_width=1920, + ) + + server.custom_views.populate_pdf(custom_view, req_option) + assert response == custom_view.pdf From 3c6e6e9f507ceb4af0c59151778bce4ac13c342c Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 22:59:05 -0500 Subject: [PATCH 30/44] chore: pytestify test_data_freshness_policy (#1653) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_data_freshness_policy.py | 363 ++++++++++++++--------------- 1 file changed, 178 insertions(+), 185 deletions(-) diff --git a/test/test_data_freshness_policy.py b/test/test_data_freshness_policy.py index 9591a6380..3c5bf5cc2 100644 --- a/test/test_data_freshness_policy.py +++ b/test/test_data_freshness_policy.py @@ -1,189 +1,182 @@ -import os +from pathlib import Path import requests_mock -import unittest + +import pytest import tableauserverclient as TSC -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") - -UPDATE_DFP_ALWAYS_LIVE_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy.xml") -UPDATE_DFP_SITE_DEFAULT_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy2.xml") -UPDATE_DFP_FRESH_EVERY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy3.xml") -UPDATE_DFP_FRESH_AT_DAILY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy4.xml") -UPDATE_DFP_FRESH_AT_WEEKLY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy5.xml") -UPDATE_DFP_FRESH_AT_MONTHLY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy6.xml") - - -class WorkbookTests(unittest.TestCase): - def setUp(self) -> None: - self.server = TSC.Server("http://test", False) - - # Fake sign in - self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - - self.baseurl = self.server.workbooks.baseurl - - def test_update_DFP_always_live(self) -> None: - with open(UPDATE_DFP_ALWAYS_LIVE_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) - single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( - TSC.DataFreshnessPolicyItem.Option.AlwaysLive - ) - single_workbook = self.server.workbooks.update(single_workbook) - - self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) - self.assertEqual("AlwaysLive", single_workbook.data_freshness_policy.option) - - def test_update_DFP_site_default(self) -> None: - with open(UPDATE_DFP_SITE_DEFAULT_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) - single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( - TSC.DataFreshnessPolicyItem.Option.SiteDefault - ) - single_workbook = self.server.workbooks.update(single_workbook) - - self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) - self.assertEqual("SiteDefault", single_workbook.data_freshness_policy.option) - - def test_update_DFP_fresh_every(self) -> None: - with open(UPDATE_DFP_FRESH_EVERY_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) - single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( - TSC.DataFreshnessPolicyItem.Option.FreshEvery - ) - fresh_every_ten_hours = TSC.DataFreshnessPolicyItem.FreshEvery( - TSC.DataFreshnessPolicyItem.FreshEvery.Frequency.Hours, 10 - ) - single_workbook.data_freshness_policy.fresh_every_schedule = fresh_every_ten_hours - single_workbook = self.server.workbooks.update(single_workbook) - - self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) - self.assertEqual("FreshEvery", single_workbook.data_freshness_policy.option) - self.assertEqual("Hours", single_workbook.data_freshness_policy.fresh_every_schedule.frequency) - self.assertEqual(10, single_workbook.data_freshness_policy.fresh_every_schedule.value) - - def test_update_DFP_fresh_every_missing_attributes(self) -> None: - with open(UPDATE_DFP_FRESH_EVERY_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) - single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( - TSC.DataFreshnessPolicyItem.Option.FreshEvery - ) - - self.assertRaises(ValueError, self.server.workbooks.update, single_workbook) - - def test_update_DFP_fresh_at_day(self) -> None: - with open(UPDATE_DFP_FRESH_AT_DAILY_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) - single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( - TSC.DataFreshnessPolicyItem.Option.FreshAt - ) - fresh_at_10pm_daily = TSC.DataFreshnessPolicyItem.FreshAt( - TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Day, "22:00:00", " Asia/Singapore" - ) - single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_10pm_daily - single_workbook = self.server.workbooks.update(single_workbook) - - self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) - self.assertEqual("FreshAt", single_workbook.data_freshness_policy.option) - self.assertEqual("Day", single_workbook.data_freshness_policy.fresh_at_schedule.frequency) - self.assertEqual("22:00:00", single_workbook.data_freshness_policy.fresh_at_schedule.time) - self.assertEqual("Asia/Singapore", single_workbook.data_freshness_policy.fresh_at_schedule.timezone) - - def test_update_DFP_fresh_at_week(self) -> None: - with open(UPDATE_DFP_FRESH_AT_WEEKLY_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) - single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( - TSC.DataFreshnessPolicyItem.Option.FreshAt - ) - fresh_at_10am_mon_wed = TSC.DataFreshnessPolicyItem.FreshAt( - TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Week, - "10:00:00", - "America/Los_Angeles", - ["Monday", "Wednesday"], - ) - single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_10am_mon_wed - single_workbook = self.server.workbooks.update(single_workbook) - - self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) - self.assertEqual("FreshAt", single_workbook.data_freshness_policy.option) - self.assertEqual("Week", single_workbook.data_freshness_policy.fresh_at_schedule.frequency) - self.assertEqual("10:00:00", single_workbook.data_freshness_policy.fresh_at_schedule.time) - self.assertEqual("Wednesday", single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[0]) - self.assertEqual("Monday", single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[1]) - - def test_update_DFP_fresh_at_month(self) -> None: - with open(UPDATE_DFP_FRESH_AT_MONTHLY_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) - single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( - TSC.DataFreshnessPolicyItem.Option.FreshAt - ) - fresh_at_00am_lastDayOfMonth = TSC.DataFreshnessPolicyItem.FreshAt( - TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, "00:00:00", "America/Los_Angeles", ["LastDay"] - ) - single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_00am_lastDayOfMonth - single_workbook = self.server.workbooks.update(single_workbook) - - self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) - self.assertEqual("FreshAt", single_workbook.data_freshness_policy.option) - self.assertEqual("Month", single_workbook.data_freshness_policy.fresh_at_schedule.frequency) - self.assertEqual("00:00:00", single_workbook.data_freshness_policy.fresh_at_schedule.time) - self.assertEqual("LastDay", single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[0]) - - def test_update_DFP_fresh_at_missing_params(self) -> None: - with open(UPDATE_DFP_FRESH_AT_DAILY_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) - single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( - TSC.DataFreshnessPolicyItem.Option.FreshAt - ) - - self.assertRaises(ValueError, self.server.workbooks.update, single_workbook) - - def test_update_DFP_fresh_at_missing_interval(self) -> None: - with open(UPDATE_DFP_FRESH_AT_DAILY_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) - single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) - single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" - single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( - TSC.DataFreshnessPolicyItem.Option.FreshAt - ) - fresh_at_month_no_interval = TSC.DataFreshnessPolicyItem.FreshAt( - TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, "00:00:00", "America/Los_Angeles" - ) - single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_month_no_interval - - self.assertRaises(ValueError, self.server.workbooks.update, single_workbook) +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +UPDATE_DFP_ALWAYS_LIVE_XML = TEST_ASSET_DIR / "workbook_update_data_freshness_policy.xml" +UPDATE_DFP_SITE_DEFAULT_XML = TEST_ASSET_DIR / "workbook_update_data_freshness_policy2.xml" +UPDATE_DFP_FRESH_EVERY_XML = TEST_ASSET_DIR / "workbook_update_data_freshness_policy3.xml" +UPDATE_DFP_FRESH_AT_DAILY_XML = TEST_ASSET_DIR / "workbook_update_data_freshness_policy4.xml" +UPDATE_DFP_FRESH_AT_WEEKLY_XML = TEST_ASSET_DIR / "workbook_update_data_freshness_policy5.xml" +UPDATE_DFP_FRESH_AT_MONTHLY_XML = TEST_ASSET_DIR / "workbook_update_data_freshness_policy6.xml" + + +@pytest.fixture(scope="function") +def server() -> TSC.Server: + server = TSC.Server("http://test", False) + # Fake sign in + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + return server + + +def test_update_DFP_always_live(server) -> None: + response_xml = UPDATE_DFP_ALWAYS_LIVE_XML.read_text() + with requests_mock.mock() as m: + m.put(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.AlwaysLive + ) + single_workbook = server.workbooks.update(single_workbook) + + assert "1f951daf-4061-451a-9df1-69a8062664f2" == single_workbook.id + assert "AlwaysLive" == single_workbook.data_freshness_policy.option + + +def test_update_DFP_site_default(server) -> None: + response_xml = UPDATE_DFP_SITE_DEFAULT_XML.read_text() + with requests_mock.mock() as m: + m.put(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.SiteDefault + ) + single_workbook = server.workbooks.update(single_workbook) + + assert "1f951daf-4061-451a-9df1-69a8062664f2" == single_workbook.id + assert "SiteDefault" == single_workbook.data_freshness_policy.option + + +def test_update_DFP_fresh_every(server) -> None: + response_xml = UPDATE_DFP_FRESH_EVERY_XML.read_text() + with requests_mock.mock() as m: + m.put(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshEvery + ) + fresh_every_ten_hours = TSC.DataFreshnessPolicyItem.FreshEvery( + TSC.DataFreshnessPolicyItem.FreshEvery.Frequency.Hours, 10 + ) + single_workbook.data_freshness_policy.fresh_every_schedule = fresh_every_ten_hours + single_workbook = server.workbooks.update(single_workbook) + + assert "1f951daf-4061-451a-9df1-69a8062664f2" == single_workbook.id + assert "FreshEvery" == single_workbook.data_freshness_policy.option + assert "Hours" == single_workbook.data_freshness_policy.fresh_every_schedule.frequency + assert 10 == single_workbook.data_freshness_policy.fresh_every_schedule.value + + +def test_update_DFP_fresh_every_missing_attributes(server) -> None: + response_xml = UPDATE_DFP_FRESH_EVERY_XML.read_text() + with requests_mock.mock() as m: + m.put(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshEvery + ) + + with pytest.raises(ValueError): + server.workbooks.update(single_workbook) + + +def test_update_DFP_fresh_at_day(server) -> None: + response_xml = UPDATE_DFP_FRESH_AT_DAILY_XML.read_text() + with requests_mock.mock() as m: + m.put(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(TSC.DataFreshnessPolicyItem.Option.FreshAt) + fresh_at_10pm_daily = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Day, "22:00:00", " Asia/Singapore" + ) + single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_10pm_daily + single_workbook = server.workbooks.update(single_workbook) + + assert "1f951daf-4061-451a-9df1-69a8062664f2" == single_workbook.id + assert "FreshAt" == single_workbook.data_freshness_policy.option + assert "Day" == single_workbook.data_freshness_policy.fresh_at_schedule.frequency + assert "22:00:00" == single_workbook.data_freshness_policy.fresh_at_schedule.time + assert "Asia/Singapore" == single_workbook.data_freshness_policy.fresh_at_schedule.timezone + + +def test_update_DFP_fresh_at_week(server) -> None: + response_xml = UPDATE_DFP_FRESH_AT_WEEKLY_XML.read_text() + with requests_mock.mock() as m: + m.put(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(TSC.DataFreshnessPolicyItem.Option.FreshAt) + fresh_at_10am_mon_wed = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Week, + "10:00:00", + "America/Los_Angeles", + ["Monday", "Wednesday"], + ) + single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_10am_mon_wed + single_workbook = server.workbooks.update(single_workbook) + + assert "1f951daf-4061-451a-9df1-69a8062664f2" == single_workbook.id + assert "FreshAt" == single_workbook.data_freshness_policy.option + assert "Week" == single_workbook.data_freshness_policy.fresh_at_schedule.frequency + assert "10:00:00" == single_workbook.data_freshness_policy.fresh_at_schedule.time + assert "Wednesday" == single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[0] + assert "Monday" == single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[1] + + +def test_update_DFP_fresh_at_month(server) -> None: + response_xml = UPDATE_DFP_FRESH_AT_MONTHLY_XML.read_text() + with requests_mock.mock() as m: + m.put(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(TSC.DataFreshnessPolicyItem.Option.FreshAt) + fresh_at_00am_lastDayOfMonth = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, "00:00:00", "America/Los_Angeles", ["LastDay"] + ) + single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_00am_lastDayOfMonth + single_workbook = server.workbooks.update(single_workbook) + + assert "1f951daf-4061-451a-9df1-69a8062664f2" == single_workbook.id + assert "FreshAt" == single_workbook.data_freshness_policy.option + assert "Month" == single_workbook.data_freshness_policy.fresh_at_schedule.frequency + assert "00:00:00" == single_workbook.data_freshness_policy.fresh_at_schedule.time + assert "LastDay" == single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[0] + + +def test_update_DFP_fresh_at_missing_params(server) -> None: + response_xml = UPDATE_DFP_FRESH_AT_DAILY_XML.read_text() + with requests_mock.mock() as m: + m.put(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(TSC.DataFreshnessPolicyItem.Option.FreshAt) + + with pytest.raises(ValueError): + server.workbooks.update(single_workbook) + + +def test_update_DFP_fresh_at_missing_interval(server) -> None: + response_xml = UPDATE_DFP_FRESH_AT_DAILY_XML.read_text() + with requests_mock.mock() as m: + m.put(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem(TSC.DataFreshnessPolicyItem.Option.FreshAt) + fresh_at_month_no_interval = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, "00:00:00", "America/Los_Angeles" + ) + single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_month_no_interval + + with pytest.raises(ValueError): + server.workbooks.update(single_workbook) From 388d5eb84cc0004d266cc5bac3811bb4a896c006 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 23:00:09 -0500 Subject: [PATCH 31/44] chore: pytestify test_data_acceleration_report (#1652) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_data_acceleration_report.py | 61 ++++++++++++++------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/test/test_data_acceleration_report.py b/test/test_data_acceleration_report.py index 8f9f5a49e..c0589a84b 100644 --- a/test/test_data_acceleration_report.py +++ b/test/test_data_acceleration_report.py @@ -1,42 +1,45 @@ -import unittest +from pathlib import Path +import pytest import requests_mock import tableauserverclient as TSC -from ._utils import read_xml_asset -GET_XML = "data_acceleration_report.xml" +TEST_ASSETS_DIR = Path(__file__).parent / "assets" +GET_XML = TEST_ASSETS_DIR / "data_acceleration_report.xml" -class DataAccelerationReportTests(unittest.TestCase): - def setUp(self): - 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.8" +@pytest.fixture(scope="function") +def server(): + server = TSC.Server("http://test", False) - self.baseurl = self.server.data_acceleration_report.baseurl + # Fake signin + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + server.version = "3.8" - def test_get(self): - response_xml = read_xml_asset(GET_XML) - with requests_mock.mock() as m: - m.get(self.baseurl, text=response_xml) - data_acceleration_report = self.server.data_acceleration_report.get() + return server - self.assertEqual(2, len(data_acceleration_report.comparison_records)) - self.assertEqual("site-1", data_acceleration_report.comparison_records[0].site) - self.assertEqual("sheet-1", data_acceleration_report.comparison_records[0].sheet_uri) - self.assertEqual("0", data_acceleration_report.comparison_records[0].unaccelerated_session_count) - self.assertEqual("0.0", data_acceleration_report.comparison_records[0].avg_non_accelerated_plt) - self.assertEqual("1", data_acceleration_report.comparison_records[0].accelerated_session_count) - self.assertEqual("0.166", data_acceleration_report.comparison_records[0].avg_accelerated_plt) +def test_get_data_acceleration_report(server): + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.data_acceleration_report.baseurl, text=response_xml) + data_acceleration_report = server.data_acceleration_report.get() - self.assertEqual("site-2", data_acceleration_report.comparison_records[1].site) - self.assertEqual("sheet-2", data_acceleration_report.comparison_records[1].sheet_uri) - self.assertEqual("2", data_acceleration_report.comparison_records[1].unaccelerated_session_count) - self.assertEqual("1.29", data_acceleration_report.comparison_records[1].avg_non_accelerated_plt) - self.assertEqual("3", data_acceleration_report.comparison_records[1].accelerated_session_count) - self.assertEqual("0.372", data_acceleration_report.comparison_records[1].avg_accelerated_plt) + assert 2 == len(data_acceleration_report.comparison_records) + + assert "site-1" == data_acceleration_report.comparison_records[0].site + assert "sheet-1" == data_acceleration_report.comparison_records[0].sheet_uri + assert "0" == data_acceleration_report.comparison_records[0].unaccelerated_session_count + assert "0.0" == data_acceleration_report.comparison_records[0].avg_non_accelerated_plt + assert "1" == data_acceleration_report.comparison_records[0].accelerated_session_count + assert "0.166" == data_acceleration_report.comparison_records[0].avg_accelerated_plt + + assert "site-2" == data_acceleration_report.comparison_records[1].site + assert "sheet-2" == data_acceleration_report.comparison_records[1].sheet_uri + assert "2" == data_acceleration_report.comparison_records[1].unaccelerated_session_count + assert "1.29" == data_acceleration_report.comparison_records[1].avg_non_accelerated_plt + assert "3" == data_acceleration_report.comparison_records[1].accelerated_session_count + assert "0.372" == data_acceleration_report.comparison_records[1].avg_accelerated_plt From 4becca6211391a09607fd93a6338518fad157ec0 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 23:01:07 -0500 Subject: [PATCH 32/44] chore: pytestify test_dataalert (#1654) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_dataalert.py | 222 +++++++++++++++++++++-------------------- 1 file changed, 115 insertions(+), 107 deletions(-) diff --git a/test/test_dataalert.py b/test/test_dataalert.py index 6f6f1683c..879f5ed00 100644 --- a/test/test_dataalert.py +++ b/test/test_dataalert.py @@ -1,112 +1,120 @@ -import unittest +from pathlib import Path +import pytest import requests_mock import tableauserverclient as TSC -from ._utils import read_xml_asset - -GET_XML = "data_alerts_get.xml" -GET_BY_ID_XML = "data_alerts_get_by_id.xml" -ADD_USER_TO_ALERT = "data_alerts_add_user.xml" -UPDATE_XML = "data_alerts_update.xml" - - -class DataAlertTests(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.2" - - self.baseurl = self.server.data_alerts.baseurl - - def test_get(self) -> None: - response_xml = read_xml_asset(GET_XML) - with requests_mock.mock() as m: - m.get(self.baseurl, text=response_xml) - all_alerts, pagination_item = self.server.data_alerts.get() - - self.assertEqual(1, pagination_item.total_available) - self.assertEqual("5ea59b45-e497-5673-8809-bfe213236f75", all_alerts[0].id) - self.assertEqual("Data Alert test", all_alerts[0].subject) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_alerts[0].creatorId) - self.assertEqual("2020-08-10T23:17:06Z", all_alerts[0].createdAt) - self.assertEqual("2020-08-10T23:17:06Z", all_alerts[0].updatedAt) - self.assertEqual("Daily", all_alerts[0].frequency) - self.assertEqual("true", all_alerts[0].public) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_alerts[0].owner_id) - self.assertEqual("Bob", all_alerts[0].owner_name) - self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", all_alerts[0].view_id) - self.assertEqual("ENDANGERED SAFARI", all_alerts[0].view_name) - self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", all_alerts[0].workbook_id) - self.assertEqual("Safari stats", all_alerts[0].workbook_name) - self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", all_alerts[0].project_id) - self.assertEqual("Default", all_alerts[0].project_name) - - def test_get_by_id(self) -> None: - response_xml = read_xml_asset(GET_BY_ID_XML) - with requests_mock.mock() as m: - m.get(self.baseurl + "/5ea59b45-e497-5673-8809-bfe213236f75", text=response_xml) - alert = self.server.data_alerts.get_by_id("5ea59b45-e497-5673-8809-bfe213236f75") - - self.assertTrue(isinstance(alert.recipients, list)) - self.assertEqual(len(alert.recipients), 1) - self.assertEqual(alert.recipients[0], "dd2239f6-ddf1-4107-981a-4cf94e415794") - - def test_update(self) -> None: - response_xml = read_xml_asset(UPDATE_XML) - with requests_mock.mock() as m: - m.put(self.baseurl + "/5ea59b45-e497-5673-8809-bfe213236f75", text=response_xml) - single_alert = TSC.DataAlertItem() - single_alert._id = "5ea59b45-e497-5673-8809-bfe213236f75" - single_alert._subject = "Data Alert test" - single_alert._frequency = "Daily" - single_alert._public = True - single_alert._owner_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" - single_alert = self.server.data_alerts.update(single_alert) - - self.assertEqual("5ea59b45-e497-5673-8809-bfe213236f75", single_alert.id) - self.assertEqual("Data Alert test", single_alert.subject) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_alert.creatorId) - self.assertEqual("2020-08-10T23:17:06Z", single_alert.createdAt) - self.assertEqual("2020-08-10T23:17:06Z", single_alert.updatedAt) - self.assertEqual("Daily", single_alert.frequency) - self.assertEqual("true", single_alert.public) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_alert.owner_id) - self.assertEqual("Bob", single_alert.owner_name) - self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_alert.view_id) - self.assertEqual("ENDANGERED SAFARI", single_alert.view_name) - self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", single_alert.workbook_id) - self.assertEqual("Safari stats", single_alert.workbook_name) - self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", single_alert.project_id) - self.assertEqual("Default", single_alert.project_name) - - def test_add_user_to_alert(self) -> None: - response_xml = read_xml_asset(ADD_USER_TO_ALERT) + +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +GET_XML = TEST_ASSET_DIR / "data_alerts_get.xml" +GET_BY_ID_XML = TEST_ASSET_DIR / "data_alerts_get_by_id.xml" +ADD_USER_TO_ALERT = TEST_ASSET_DIR / "data_alerts_add_user.xml" +UPDATE_XML = TEST_ASSET_DIR / "data_alerts_update.xml" + + +@pytest.fixture(scope="function") +def server(): + server = TSC.Server("http://test", False) + + # Fake signin + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + server.version = "3.2" + + return server + + +def test_get(server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.data_alerts.baseurl, text=response_xml) + all_alerts, pagination_item = server.data_alerts.get() + + assert 1 == pagination_item.total_available + assert "5ea59b45-e497-5673-8809-bfe213236f75" == all_alerts[0].id + assert "Data Alert test" == all_alerts[0].subject + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == all_alerts[0].creatorId + assert "2020-08-10T23:17:06Z" == all_alerts[0].createdAt + assert "2020-08-10T23:17:06Z" == all_alerts[0].updatedAt + assert "Daily" == all_alerts[0].frequency + assert "true" == all_alerts[0].public + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == all_alerts[0].owner_id + assert "Bob" == all_alerts[0].owner_name + assert "d79634e1-6063-4ec9-95ff-50acbf609ff5" == all_alerts[0].view_id + assert "ENDANGERED SAFARI" == all_alerts[0].view_name + assert "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" == all_alerts[0].workbook_id + assert "Safari stats" == all_alerts[0].workbook_name + assert "5241e88d-d384-4fd7-9c2f-648b5247efc5" == all_alerts[0].project_id + assert "Default" == all_alerts[0].project_name + + +def test_get_by_id(server) -> None: + response_xml = GET_BY_ID_XML.read_text() + with requests_mock.mock() as m: + m.get(server.data_alerts.baseurl + "/5ea59b45-e497-5673-8809-bfe213236f75", text=response_xml) + alert = server.data_alerts.get_by_id("5ea59b45-e497-5673-8809-bfe213236f75") + + assert isinstance(alert.recipients, list) + assert len(alert.recipients) == 1 + assert alert.recipients[0] == "dd2239f6-ddf1-4107-981a-4cf94e415794" + + +def test_update(server) -> None: + response_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.put(server.data_alerts.baseurl + "/5ea59b45-e497-5673-8809-bfe213236f75", text=response_xml) single_alert = TSC.DataAlertItem() - single_alert._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" - in_user = TSC.UserItem("Bob", TSC.UserItem.Roles.Explorer) - in_user._id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" - - with requests_mock.mock() as m: - m.post(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/users", text=response_xml) - - out_user = self.server.data_alerts.add_user_to_alert(single_alert, in_user) - - self.assertEqual(out_user.id, in_user.id) - self.assertEqual(out_user.name, in_user.name) - self.assertEqual(out_user.site_role, in_user.site_role) - - def test_delete(self) -> None: - with requests_mock.mock() as m: - m.delete(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5", status_code=204) - self.server.data_alerts.delete("0448d2ed-590d-4fa0-b272-a2a8a24555b5") - - def test_delete_user_from_alert(self) -> None: - alert_id = "5ea59b45-e497-5673-8809-bfe213236f75" - user_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" - with requests_mock.mock() as m: - m.delete(self.baseurl + f"/{alert_id}/users/{user_id}", status_code=204) - self.server.data_alerts.delete_user_from_alert(alert_id, user_id) + single_alert._id = "5ea59b45-e497-5673-8809-bfe213236f75" + single_alert._subject = "Data Alert test" + single_alert._frequency = "Daily" + single_alert._public = True + single_alert._owner_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" + single_alert = server.data_alerts.update(single_alert) + + assert "5ea59b45-e497-5673-8809-bfe213236f75" == single_alert.id + assert "Data Alert test" == single_alert.subject + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == single_alert.creatorId + assert "2020-08-10T23:17:06Z" == single_alert.createdAt + assert "2020-08-10T23:17:06Z" == single_alert.updatedAt + assert "Daily" == single_alert.frequency + assert "true" == single_alert.public + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == single_alert.owner_id + assert "Bob" == single_alert.owner_name + assert "d79634e1-6063-4ec9-95ff-50acbf609ff5" == single_alert.view_id + assert "ENDANGERED SAFARI" == single_alert.view_name + assert "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" == single_alert.workbook_id + assert "Safari stats" == single_alert.workbook_name + assert "5241e88d-d384-4fd7-9c2f-648b5247efc5" == single_alert.project_id + assert "Default" == single_alert.project_name + + +def test_add_user_to_alert(server) -> None: + response_xml = ADD_USER_TO_ALERT.read_text() + single_alert = TSC.DataAlertItem() + single_alert._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" + in_user = TSC.UserItem("Bob", TSC.UserItem.Roles.Explorer) + in_user._id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" + + with requests_mock.mock() as m: + m.post(server.data_alerts.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/users", text=response_xml) + + out_user = server.data_alerts.add_user_to_alert(single_alert, in_user) + + assert out_user.id == in_user.id + assert out_user.name == in_user.name + assert out_user.site_role == in_user.site_role + + +def test_delete(server) -> None: + with requests_mock.mock() as m: + m.delete(server.data_alerts.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5", status_code=204) + server.data_alerts.delete("0448d2ed-590d-4fa0-b272-a2a8a24555b5") + + +def test_delete_user_from_alert(server) -> None: + alert_id = "5ea59b45-e497-5673-8809-bfe213236f75" + user_id = "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" + with requests_mock.mock() as m: + m.delete(server.data_alerts.baseurl + f"/{alert_id}/users/{user_id}", status_code=204) + server.data_alerts.delete_user_from_alert(alert_id, user_id) From 81f80ca5dff0db3db552940ec2538946ef789c9d Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 23:01:41 -0500 Subject: [PATCH 33/44] chore: pytestify test_database (#1655) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_database.py | 217 +++++++++++++++++++++--------------------- 1 file changed, 108 insertions(+), 109 deletions(-) diff --git a/test/test_database.py b/test/test_database.py index 3fd2c9a67..8eb03c737 100644 --- a/test/test_database.py +++ b/test/test_database.py @@ -1,113 +1,112 @@ -import unittest +from pathlib import Path +import pytest import requests_mock import tableauserverclient as TSC -from ._utils import read_xml_asset, asset - -GET_XML = "database_get.xml" -POPULATE_PERMISSIONS_XML = "database_populate_permissions.xml" -UPDATE_XML = "database_update.xml" -GET_DQW_BY_CONTENT = "dqw_by_content_type.xml" - - -class DatabaseTests(unittest.TestCase): - def setUp(self): - 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.5" - - self.baseurl = self.server.databases.baseurl - - def test_get(self): - response_xml = read_xml_asset(GET_XML) - with requests_mock.mock() as m: - m.get(self.baseurl, text=response_xml) - all_databases, pagination_item = self.server.databases.get() - - self.assertEqual(5, pagination_item.total_available) - self.assertEqual("5ea59b45-e497-4827-8809-bfe213236f75", all_databases[0].id) - self.assertEqual("hyper", all_databases[0].connection_type) - self.assertEqual("hyper_0.hyper", all_databases[0].name) - - self.assertEqual("23591f2c-4802-4d6a-9e28-574a8ea9bc4c", all_databases[1].id) - self.assertEqual("sqlserver", all_databases[1].connection_type) - self.assertEqual("testv1", all_databases[1].name) - self.assertEqual("9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0", all_databases[1].contact_id) - self.assertEqual(True, all_databases[1].certified) - - def test_update(self): - response_xml = read_xml_asset(UPDATE_XML) - with requests_mock.mock() as m: - m.put(self.baseurl + "/23591f2c-4802-4d6a-9e28-574a8ea9bc4c", text=response_xml) - single_database = TSC.DatabaseItem("test") - single_database.contact_id = "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0" - single_database._id = "23591f2c-4802-4d6a-9e28-574a8ea9bc4c" - single_database.certified = True - single_database.certification_note = "Test" - single_database = self.server.databases.update(single_database) - - self.assertEqual("23591f2c-4802-4d6a-9e28-574a8ea9bc4c", single_database.id) - self.assertEqual("9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0", single_database.contact_id) - self.assertEqual(True, single_database.certified) - self.assertEqual("Test", single_database.certification_note) - - def test_populate_permissions(self): - with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions", text=response_xml) - single_database = TSC.DatabaseItem("test") - single_database._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" - - self.server.databases.populate_permissions(single_database) - permissions = single_database.permissions - - self.assertEqual(permissions[0].grantee.tag_name, "group") - self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af") - self.assertDictEqual( - permissions[0].capabilities, - { - TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - }, - ) - - self.assertEqual(permissions[1].grantee.tag_name, "user") - self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a") - self.assertDictEqual( - permissions[1].capabilities, - { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, - }, - ) - - def test_populate_data_quality_warning(self): - with open(asset(GET_DQW_BY_CONTENT), "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get( - self.server.databases._data_quality_warnings.baseurl + "/94441d26-9a52-4a42-b0fb-3f94792d1aac", - text=response_xml, - ) - single_database = TSC.DatabaseItem("test") - single_database._id = "94441d26-9a52-4a42-b0fb-3f94792d1aac" - - self.server.databases.populate_dqw(single_database) - dqws = single_database.dqws - first_dqw = dqws.pop() - self.assertEqual(first_dqw.id, "c2e0e406-84fb-4f4e-9998-f20dd9306710") - self.assertEqual(first_dqw.warning_type, "WARNING") - self.assertEqual(first_dqw.message, "Hello, World!") - self.assertEqual(first_dqw.owner_id, "eddc8c5f-6af0-40be-b6b0-2c790290a43f") - self.assertEqual(first_dqw.active, True) - self.assertEqual(first_dqw.severe, True) - self.assertEqual(str(first_dqw.created_at), "2021-04-09 18:39:54+00:00") - self.assertEqual(str(first_dqw.updated_at), "2021-04-09 18:39:54+00:00") - - def test_delete(self): - with requests_mock.mock() as m: - m.delete(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5", status_code=204) - self.server.databases.delete("0448d2ed-590d-4fa0-b272-a2a8a24555b5") + +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +GET_XML = TEST_ASSET_DIR / "database_get.xml" +POPULATE_PERMISSIONS_XML = TEST_ASSET_DIR / "database_populate_permissions.xml" +UPDATE_XML = TEST_ASSET_DIR / "database_update.xml" +GET_DQW_BY_CONTENT = TEST_ASSET_DIR / "dqw_by_content_type.xml" + + +@pytest.fixture(scope="function") +def server() -> TSC.Server: + server = TSC.Server("http://test", False) + # Fake signin + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + server.version = "3.5" + + return server + + +def test_get(server): + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.databases.baseurl, text=response_xml) + all_databases, pagination_item = server.databases.get() + + assert 5 == pagination_item.total_available + assert "5ea59b45-e497-4827-8809-bfe213236f75" == all_databases[0].id + assert "hyper" == all_databases[0].connection_type + assert "hyper_0.hyper" == all_databases[0].name + + assert "23591f2c-4802-4d6a-9e28-574a8ea9bc4c" == all_databases[1].id + assert "sqlserver" == all_databases[1].connection_type + assert "testv1" == all_databases[1].name + assert "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0" == all_databases[1].contact_id + assert all_databases[1].certified + + +def test_update(server): + response_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.put(server.databases.baseurl + "/23591f2c-4802-4d6a-9e28-574a8ea9bc4c", text=response_xml) + single_database = TSC.DatabaseItem("test") + single_database.contact_id = "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0" + single_database._id = "23591f2c-4802-4d6a-9e28-574a8ea9bc4c" + single_database.certified = True + single_database.certification_note = "Test" + single_database = server.databases.update(single_database) + + assert "23591f2c-4802-4d6a-9e28-574a8ea9bc4c" == single_database.id + assert "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0" == single_database.contact_id + assert single_database.certified + assert "Test" == single_database.certification_note + + +def test_populate_permissions(server): + response_xml = POPULATE_PERMISSIONS_XML.read_text() + with requests_mock.mock() as m: + m.get(server.databases.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions", text=response_xml) + single_database = TSC.DatabaseItem("test") + single_database._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" + + server.databases.populate_permissions(single_database) + permissions = single_database.permissions + + assert permissions[0].grantee.tag_name == "group" + assert permissions[0].grantee.id == "5e5e1978-71fa-11e4-87dd-7382f5c437af" + assert permissions[0].capabilities == { + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + } + + assert permissions[1].grantee.tag_name == "user" + assert permissions[1].grantee.id == "7c37ee24-c4b1-42b6-a154-eaeab7ee330a" + assert permissions[1].capabilities == { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + } + + +def test_populate_data_quality_warning(server): + response_xml = GET_DQW_BY_CONTENT.read_text() + with requests_mock.mock() as m: + m.get( + server.databases._data_quality_warnings.baseurl + "/94441d26-9a52-4a42-b0fb-3f94792d1aac", + text=response_xml, + ) + single_database = TSC.DatabaseItem("test") + single_database._id = "94441d26-9a52-4a42-b0fb-3f94792d1aac" + + server.databases.populate_dqw(single_database) + dqws = single_database.dqws + first_dqw = dqws.pop() + assert first_dqw.id == "c2e0e406-84fb-4f4e-9998-f20dd9306710" + assert first_dqw.warning_type == "WARNING" + assert first_dqw.message, "Hello == World!" + assert first_dqw.owner_id == "eddc8c5f-6af0-40be-b6b0-2c790290a43f" + assert first_dqw.active + assert first_dqw.severe + assert str(first_dqw.created_at) == "2021-04-09 18:39:54+00:00" + assert str(first_dqw.updated_at) == "2021-04-09 18:39:54+00:00" + + +def test_delete(server): + with requests_mock.mock() as m: + m.delete(server.databases.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5", status_code=204) + server.databases.delete("0448d2ed-590d-4fa0-b272-a2a8a24555b5") From 022e6f1798614cee99ccd1f8f24aa5af804c1e52 Mon Sep 17 00:00:00 2001 From: Nivea Valsaraj <62623121+valsarajnivea@users.noreply.github.com> Date: Fri, 17 Oct 2025 00:48:37 -0500 Subject: [PATCH 34/44] feat: add WebAuthoringForFlows capability to Permission class (#1642) Co-authored-by: Jac --- tableauserverclient/models/permissions_item.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index bb3487279..0171e07d1 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -43,6 +43,7 @@ class Capability: CreateRefreshMetrics = "CreateRefreshMetrics" SaveAs = "SaveAs" PulseMetricDefine = "PulseMetricDefine" + WebAuthoringForFlows = "WebAuthoringForFlows" def __repr__(self): return "" From fd187ba26ce9946dcbf56f8a035a07fc437a3e0a Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 17 Oct 2025 00:54:48 -0500 Subject: [PATCH 35/44] feat: support collections in favorites (#1647) * feat: support collections in favorites The API schema shows collections can be returned with favorites. This change adds support for a `CollectionItem`, as well as making the bundled type returned by favorites more specific. * fix: change Self import to make compat with < 3.11 * fix: use parse_datetime --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/__init__.py | 3 +- tableauserverclient/models/__init__.py | 2 + tableauserverclient/models/collection_item.py | 52 +++++++++++++++++++ tableauserverclient/models/favorites_item.py | 29 ++++++++--- tableauserverclient/models/user_item.py | 5 +- test/assets/favorites_get.xml | 14 ++++- test/test_favorites.py | 12 +++++ 7 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 tableauserverclient/models/collection_item.py diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index c15e1a6eb..b041fcdae 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -2,6 +2,7 @@ from tableauserverclient.namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from tableauserverclient.models import ( BackgroundJobItem, + CollectionItem, ColumnItem, ConnectionCredentials, ConnectionItem, @@ -73,7 +74,7 @@ __all__ = [ "BackgroundJobItem", - "BackgroundJobItem", + "CollectionItem", "ColumnItem", "ConnectionCredentials", "ConnectionItem", diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 5ad7ec1c4..67f6553fd 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,3 +1,4 @@ +from tableauserverclient.models.collection_item import CollectionItem from tableauserverclient.models.column_item import ColumnItem from tableauserverclient.models.connection_credentials import ConnectionCredentials from tableauserverclient.models.connection_item import ConnectionItem @@ -53,6 +54,7 @@ from tableauserverclient.models.extract_item import ExtractItem __all__ = [ + "CollectionItem", "ColumnItem", "ConnectionCredentials", "ConnectionItem", diff --git a/tableauserverclient/models/collection_item.py b/tableauserverclient/models/collection_item.py new file mode 100644 index 000000000..4fdb61023 --- /dev/null +++ b/tableauserverclient/models/collection_item.py @@ -0,0 +1,52 @@ +from datetime import datetime +from typing import Optional +from xml.etree.ElementTree import Element + +from defusedxml.ElementTree import fromstring +from typing_extensions import Self + +from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.user_item import UserItem + + +class CollectionItem: + def __init__(self) -> None: + self.id: Optional[str] = None + self.name: Optional[str] = None + self.description: Optional[str] = None + self.created_at: Optional[datetime] = None + self.updated_at: Optional[datetime] = None + self.owner: Optional[UserItem] = None + self.total_item_count: Optional[int] = None + self.permissioned_item_count: Optional[int] = None + self.visibility: Optional[str] = None # Assuming visibility is a string, adjust as necessary + + @classmethod + def from_response(cls, response: bytes, ns) -> list[Self]: + parsed_response = fromstring(response) + + collection_elements = parsed_response.findall(".//t:collection", namespaces=ns) + if not collection_elements: + raise ValueError("No collection element found in the response") + + collections = [cls.from_xml(c, ns) for c in collection_elements] + return collections + + @classmethod + def from_xml(cls, xml: Element, ns) -> Self: + collection_item = cls() + collection_item.id = xml.get("id") + collection_item.name = xml.get("name") + collection_item.description = xml.get("description") + collection_item.created_at = parse_datetime(xml.get("createdAt")) + collection_item.updated_at = parse_datetime(xml.get("updatedAt")) + owner_element = xml.find(".//t:owner", namespaces=ns) + if owner_element is not None: + collection_item.owner = UserItem.from_xml(owner_element, ns) + else: + collection_item.owner = None + collection_item.total_item_count = int(xml.get("totalItemCount", 0)) + collection_item.permissioned_item_count = int(xml.get("permissionedItemCount", 0)) + collection_item.visibility = xml.get("visibility") + + return collection_item diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index 4fea280f7..1189efc31 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,9 +1,8 @@ import logging -from typing import Union +from typing import TypedDict, Union from defusedxml.ElementTree import fromstring - -from tableauserverclient.models.tableau_types import TableauItem +from tableauserverclient.models.collection_item import CollectionItem from tableauserverclient.models.datasource_item import DatasourceItem from tableauserverclient.models.flow_item import FlowItem from tableauserverclient.models.project_item import ProjectItem @@ -13,16 +12,22 @@ from tableauserverclient.helpers.logging import logger -FavoriteType = dict[ - str, - list[TableauItem], -] + +class FavoriteType(TypedDict): + collections: list[CollectionItem] + datasources: list[DatasourceItem] + flows: list[FlowItem] + projects: list[ProjectItem] + metrics: list[MetricItem] + views: list[ViewItem] + workbooks: list[WorkbookItem] class FavoriteItem: @classmethod def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType: favorites: FavoriteType = { + "collections": [], "datasources": [], "flows": [], "projects": [], @@ -32,6 +37,7 @@ def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType: } parsed_response = fromstring(xml) + collections_xml = parsed_response.findall(".//t:favorite/t:collection", namespace) datasources_xml = parsed_response.findall(".//t:favorite/t:datasource", namespace) flows_xml = parsed_response.findall(".//t:favorite/t:flow", namespace) metrics_xml = parsed_response.findall(".//t:favorite/t:metric", namespace) @@ -40,13 +46,14 @@ def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType: workbooks_xml = parsed_response.findall(".//t:favorite/t:workbook", namespace) logger.debug( - "ds: {}, flows: {}, metrics: {}, projects: {}, views: {}, wbs: {}".format( + "ds: {}, flows: {}, metrics: {}, projects: {}, views: {}, wbs: {}, collections: {}".format( len(datasources_xml), len(flows_xml), len(metrics_xml), len(projects_xml), len(views_xml), len(workbooks_xml), + len(collections_xml), ) ) for datasource in datasources_xml: @@ -85,5 +92,11 @@ def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType: logger.debug(fav_workbook) favorites["workbooks"].append(fav_workbook) + for collection in collections_xml: + fav_collection = CollectionItem.from_xml(collection, namespace) + if fav_collection: + logger.debug(fav_collection) + favorites["collections"].append(fav_collection) + logger.debug(favorites) return favorites diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index c995b4e07..8b2dd3dd6 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: from tableauserverclient.server import Pager + from tableauserverclient.models.favorites_item import FavoriteType class UserItem: @@ -131,7 +132,7 @@ def __init__( self._id: Optional[str] = None self._last_login: Optional[datetime] = None self._workbooks = None - self._favorites: Optional[dict[str, list]] = None + self._favorites: Optional["FavoriteType"] = None self._groups = None self.email: Optional[str] = None self.fullname: Optional[str] = None @@ -218,7 +219,7 @@ def workbooks(self) -> "Pager": return self._workbooks() @property - def favorites(self) -> dict[str, list]: + def favorites(self) -> "FavoriteType": if self._favorites is None: error = "User item must be populated with favorites first." raise UnpopulatedPropertyError(error) diff --git a/test/assets/favorites_get.xml b/test/assets/favorites_get.xml index 3d2e2ee6a..8fd780b1d 100644 --- a/test/assets/favorites_get.xml +++ b/test/assets/favorites_get.xml @@ -43,5 +43,17 @@ + + + + + - \ No newline at end of file + diff --git a/test/test_favorites.py b/test/test_favorites.py index 87332d70f..e0b701953 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -3,6 +3,7 @@ import requests_mock import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import parse_datetime from ._utils import read_xml_asset GET_FAVORITES_XML = "favorites_get.xml" @@ -48,6 +49,17 @@ def test_get(self) -> None: self.assertEqual(datasource.id, "e76a1461-3b1d-4588-bf1b-17551a879ad9") self.assertEqual(project.id, "1d0304cd-3796-429f-b815-7258370b9b74") + collection = self.user.favorites["collections"][0] + + assert collection.id == "8c57cb8a-d65f-4a32-813e-5a3f86e8f94e" + assert collection.name == "sample collection" + assert collection.description == "description for sample collection" + assert collection.total_item_count == 3 + assert collection.permissioned_item_count == 2 + assert collection.visibility == "Private" + assert collection.created_at == parse_datetime("2016-08-11T21:22:40Z") + assert collection.updated_at == parse_datetime("2016-08-11T21:34:17Z") + def test_add_favorite_workbook(self) -> None: response_xml = read_xml_asset(ADD_FAVORITE_WORKBOOK_XML) workbook = TSC.WorkbookItem("") From cba111adcdb21ff7ecaa8e7f3fec2e5ce66f143e Mon Sep 17 00:00:00 2001 From: BereketBirbo Date: Wed, 22 Oct 2025 12:08:40 -0700 Subject: [PATCH 36/44] Add UAT (unified access token) support to JWT login (#1671) --- tableauserverclient/models/tableau_auth.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 82bebe385..7922ff562 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -198,19 +198,26 @@ class JWTAuth(Credentials): """ - def __init__(self, jwt: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: + def __init__( + self, + jwt: str, + isUat: bool = False, + site_id: Optional[str] = None, + user_id_to_impersonate: Optional[str] = None, + ) -> None: if jwt is None: raise TabError("Must provide a JWT token when using JWT authentication") super().__init__(site_id, user_id_to_impersonate) self.jwt = jwt + self.isUat = isUat @property def credentials(self) -> dict[str, str]: - return {"jwt": self.jwt} + return {"jwt": self.jwt, "isUat": str(self.isUat).lower()} def __repr__(self): if self.user_id_to_impersonate: uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" else: uid = "" - return f"<{self.__class__.__qualname__} jwt={self.jwt[:5]}... (site={self.site_id}{uid})>" + return f"<{self.__class__.__qualname__} jwt={self.jwt[:5]}... isUat={self.isUat} (site={self.site_id}{uid})>" From 857e1c8cee43835c12a8da08da63bd468b5ce606 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 28 Oct 2025 23:27:02 -0500 Subject: [PATCH 37/44] fix: mypy issues (#1667) --- tableauserverclient/models/data_freshness_policy_item.py | 6 +++--- tableauserverclient/models/flow_item.py | 2 +- tableauserverclient/models/group_item.py | 2 +- tableauserverclient/models/groupset_item.py | 8 ++++++++ tableauserverclient/models/project_item.py | 2 +- tableauserverclient/models/user_item.py | 2 +- tableauserverclient/models/workbook_item.py | 4 ++-- test/test_custom_view.py | 2 ++ 8 files changed, 19 insertions(+), 9 deletions(-) diff --git a/tableauserverclient/models/data_freshness_policy_item.py b/tableauserverclient/models/data_freshness_policy_item.py index 6e0cb9001..209883e8c 100644 --- a/tableauserverclient/models/data_freshness_policy_item.py +++ b/tableauserverclient/models/data_freshness_policy_item.py @@ -66,7 +66,7 @@ def interval_item(self) -> Optional[list[str]]: return self._interval_item @interval_item.setter - def interval_item(self, value: list[str]): + def interval_item(self, value: Optional[list[str]]): self._interval_item = value @property @@ -127,7 +127,7 @@ def fresh_every_schedule(self) -> Optional[FreshEvery]: return self._fresh_every_schedule @fresh_every_schedule.setter - def fresh_every_schedule(self, value: FreshEvery): + def fresh_every_schedule(self, value: Optional[FreshEvery]): self._fresh_every_schedule = value @property @@ -135,7 +135,7 @@ def fresh_at_schedule(self) -> Optional[FreshAt]: return self._fresh_at_schedule @fresh_at_schedule.setter - def fresh_at_schedule(self, value: FreshAt): + def fresh_at_schedule(self, value: Optional[FreshAt]): self._fresh_at_schedule = value @classmethod diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 063897e41..0aed3d257 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -129,7 +129,7 @@ def description(self) -> Optional[str]: return self._description @description.setter - def description(self, value: str) -> None: + def description(self, value: Optional[str]) -> None: self._description = value @property diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index 00f35e518..ad3047d83 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -92,7 +92,7 @@ def name(self) -> Optional[str]: return self._name @name.setter - def name(self, value: str) -> None: + def name(self, value: Optional[str]) -> None: self._name = value @property diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py index aa653a79e..4f082c30b 100644 --- a/tableauserverclient/models/groupset_item.py +++ b/tableauserverclient/models/groupset_item.py @@ -24,6 +24,14 @@ def __str__(self) -> str: def __repr__(self) -> str: return self.__str__() + @property + def name(self) -> Optional[str]: + return self._name + + @name.setter + def name(self, value: Optional[str]) -> None: + self._name = value + @classmethod def from_response(cls, response: bytes, ns: dict[str, str]) -> list["GroupSetItem"]: parsed_response = fromstring(response) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 1ab369ba7..0e4e5af56 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -194,7 +194,7 @@ def name(self) -> Optional[str]: return self._name @name.setter - def name(self, value: str) -> None: + def name(self, value: Optional[str]) -> None: self._name = value @property diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 8b2dd3dd6..dc2bf4f67 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -186,7 +186,7 @@ def name(self) -> Optional[str]: return self._name @name.setter - def name(self, value: str): + def name(self, value: Optional[str]): self._name = value # valid: username, domain/username, username@domain, domain/username@email diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index a3ede65d6..df70df390 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -330,7 +330,7 @@ def thumbnails_user_id(self) -> Optional[str]: return self._thumbnails_user_id @thumbnails_user_id.setter - def thumbnails_user_id(self, value: str): + def thumbnails_user_id(self, value: Optional[str]): self._thumbnails_user_id = value @property @@ -338,7 +338,7 @@ def thumbnails_group_id(self) -> Optional[str]: return self._thumbnails_group_id @thumbnails_group_id.setter - def thumbnails_group_id(self, value: str): + def thumbnails_group_id(self, value: Optional[str]): self._thumbnails_group_id = value @property diff --git a/test/test_custom_view.py b/test/test_custom_view.py index 0df3b849f..98dd9b6a4 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -141,9 +141,11 @@ def test_update(server: TSC.Server) -> None: the_custom_view = TSC.CustomViewItem("1d0304cd-3796-429f-b815-7258370b9b74", name="Best test ever") the_custom_view._id = "1f951daf-4061-451a-9df1-69a8062664f2" the_custom_view.owner = TSC.UserItem() + assert the_custom_view.owner is not None # for mypy the_custom_view.owner.id = "dd2239f6-ddf1-4107-981a-4cf94e415794" the_custom_view = server.custom_views.update(the_custom_view) + assert isinstance(the_custom_view, TSC.CustomViewItem) assert "1f951daf-4061-451a-9df1-69a8062664f2" == the_custom_view.id if the_custom_view.owner: assert "dd2239f6-ddf1-4107-981a-4cf94e415794" == the_custom_view.owner.id From 49ff1aee43f0ab4fcb6e5b2a125123702ec64395 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 28 Oct 2025 23:35:14 -0500 Subject: [PATCH 38/44] Update permissions_item.py --added ExtractRefresh attribute (#1617) (#1669) --- tableauserverclient/models/permissions_item.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 0171e07d1..bc29234c4 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -43,6 +43,7 @@ class Capability: CreateRefreshMetrics = "CreateRefreshMetrics" SaveAs = "SaveAs" PulseMetricDefine = "PulseMetricDefine" + ExtractRefresh = "ExtractRefresh" WebAuthoringForFlows = "WebAuthoringForFlows" def __repr__(self): From 2cb03a8884087c9f481f94279ef5121ddbecceff Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 28 Oct 2025 23:38:44 -0500 Subject: [PATCH 39/44] feat: make refresh consistent between endpoints (#1665) --- .../server/endpoint/datasources_endpoint.py | 6 +++--- .../server/endpoint/flows_endpoint.py | 9 +++++---- test/test_flow.py | 18 +++++++++++++++++- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index f528b3732..6a734f7b3 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -430,7 +430,7 @@ def update_connections( return connection_items @api(version="2.8") - def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> JobItem: + def refresh(self, datasource_item: Union[DatasourceItem, str], incremental: bool = False) -> JobItem: """ Refreshes the extract of an existing workbook. @@ -438,8 +438,8 @@ def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> Parameters ---------- - workbook_item : WorkbookItem | str - The workbook item or workbook ID. + workbook_item : DatasourceItem | str + The datasource item or datasource ID. incremental: bool Whether to do a full refresh or incremental refresh of the extract data diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 42c9d4c1e..ea29f2963 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -308,7 +308,7 @@ def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem return connection @api(version="3.3") - def refresh(self, flow_item: FlowItem) -> JobItem: + def refresh(self, flow_item: Union[FlowItem, str]) -> JobItem: """ Runs the flow to refresh the data. @@ -316,15 +316,16 @@ def refresh(self, flow_item: FlowItem) -> JobItem: Parameters ---------- - flow_item: FlowItem - The flow item to refresh. + flow_item: FlowItem | str + The FlowItem or str of the flow id to refresh. Returns ------- JobItem The job item that was created to refresh the flow. """ - url = f"{self.baseurl}/{flow_item.id}/run" + flow_id = getattr(flow_item, "id", flow_item) + url = f"{self.baseurl}/{flow_id}/run" empty_req = RequestFactory.Empty.empty_req() server_response = self.post_request(url, empty_req) new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/test/test_flow.py b/test/test_flow.py index d458bc77b..54c1f0201 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -197,7 +197,7 @@ def test_publish_file_object(self) -> None: self.assertEqual("default", new_flow.project_name) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_flow.owner_id) - def test_refresh(self): + def test_refresh(self) -> None: with open(asset(REFRESH_XML), "rb") as f: response_xml = f.read().decode("utf-8") with requests_mock.mock() as m: @@ -215,6 +215,22 @@ def test_refresh(self): self.assertEqual(refresh_job.flow_run.flow_id, "92967d2d-c7e2-46d0-8847-4802df58f484") self.assertEqual(format_datetime(refresh_job.flow_run.started_at), "2018-05-22T13:00:29Z") + def test_refresh_id_str(self) -> None: + with open(asset(REFRESH_XML), "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(self.baseurl + "/92967d2d-c7e2-46d0-8847-4802df58f484/run", text=response_xml) + refresh_job = self.server.flows.refresh("92967d2d-c7e2-46d0-8847-4802df58f484") + + self.assertEqual(refresh_job.id, "d1b2ccd0-6dfa-444a-aee4-723dbd6b7c9d") + self.assertEqual(refresh_job.mode, "Asynchronous") + self.assertEqual(refresh_job.type, "RunFlow") + self.assertEqual(format_datetime(refresh_job.created_at), "2018-05-22T13:00:29Z") + self.assertIsInstance(refresh_job.flow_run, TSC.FlowRunItem) + self.assertEqual(refresh_job.flow_run.id, "e0c3067f-2333-4eee-8028-e0a56ca496f6") + self.assertEqual(refresh_job.flow_run.flow_id, "92967d2d-c7e2-46d0-8847-4802df58f484") + self.assertEqual(format_datetime(refresh_job.flow_run.started_at), "2018-05-22T13:00:29Z") + def test_bad_download_response(self) -> None: with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: m.get( From 4417beb0cac1076c8b3e20972df369ab2edcbd24 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 28 Oct 2025 23:40:17 -0500 Subject: [PATCH 40/44] chore: pytestify test_endpoint (#1673) --- test/test_dqw.py | 14 ++-- test/test_endpoint.py | 146 ++++++++++++++++++++++-------------------- 2 files changed, 84 insertions(+), 76 deletions(-) diff --git a/test/test_dqw.py b/test/test_dqw.py index 6d1219f66..5cb17221a 100644 --- a/test/test_dqw.py +++ b/test/test_dqw.py @@ -1,11 +1,9 @@ -import unittest import tableauserverclient as TSC -class DQWTests(unittest.TestCase): - def test_existence(self): - dqw: TSC.DQWItem = TSC.DQWItem() - dqw.message = "message" - dqw.warning_type = TSC.DQWItem.WarningType.STALE - dqw.active = True - dqw.severe = True +def test_dqw_existence(): + dqw: TSC.DQWItem = TSC.DQWItem() + dqw.message = "message" + dqw.warning_type = TSC.DQWItem.WarningType.STALE + dqw.active = True + dqw.severe = True diff --git a/test/test_endpoint.py b/test/test_endpoint.py index ff1ef0f72..0b852ab0e 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -1,83 +1,93 @@ from pathlib import Path import pytest import requests -import unittest import tableauserverclient as TSC +from tableauserverclient.server.endpoint import Endpoint import requests_mock ASSETS = Path(__file__).parent / "assets" -class TestEndpoint(unittest.TestCase): - def setUp(self) -> None: - self.server = TSC.Server("http://test/", use_server_version=False) +@pytest.fixture(scope="function") +def server(): + """Fixture to create a TSC.Server instance for testing.""" + server = TSC.Server("http://test", False) - # Fake signin - self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - return super().setUp() + # Fake signin + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvS" - def test_fallback_request_logic(self) -> None: - url = "http://test/" - endpoint = TSC.server.Endpoint(self.server) - with requests_mock.mock() as m: - m.get(url) - response = endpoint.get_request(url=url) - self.assertIsNotNone(response) + return server - def test_user_friendly_request_returns(self) -> None: - url = "http://test/" - endpoint = TSC.server.Endpoint(self.server) - with requests_mock.mock() as m: - m.get(url) - response = endpoint.send_request_while_show_progress_threaded( - endpoint.parent_srv.session.get, url=url, request_timeout=2 - ) - self.assertIsNotNone(response) - - def test_blocking_request_raises_request_error(self) -> None: - with pytest.raises(requests.exceptions.ConnectionError): - url = "http://test/" - endpoint = TSC.server.Endpoint(self.server) - response = endpoint._blocking_request(endpoint.parent_srv.session.get, url=url) - self.assertIsNotNone(response) - - def test_get_request_stream(self) -> None: + +def test_fallback_request_logic(server: TSC.Server) -> None: + url = "http://test/" + endpoint = Endpoint(server) + with requests_mock.mock() as m: + m.get(url) + response = endpoint.get_request(url=url) + assert response is not None + + +def test_user_friendly_request_returns(server: TSC.Server) -> None: + url = "http://test/" + endpoint = Endpoint(server) + with requests_mock.mock() as m: + m.get(url) + response = endpoint.send_request_while_show_progress_threaded( + endpoint.parent_srv.session.get, url=url, request_timeout=2 + ) + assert response is not None + + +def test_blocking_request_raises_request_error(server: TSC.Server) -> None: + with pytest.raises(requests.exceptions.ConnectionError): url = "http://test/" - endpoint = TSC.server.Endpoint(self.server) - with requests_mock.mock() as m: - m.get(url, headers={"Content-Type": "application/octet-stream"}) - response = endpoint.get_request(url, parameters={"stream": True}) - - self.assertFalse(response._content_consumed) - - def test_binary_log_truncated(self): - class FakeResponse: - headers = {"Content-Type": "application/octet-stream"} - content = b"\x1337" * 1000 - status_code = 200 - - endpoint = TSC.server.Endpoint(self.server) - server_response = FakeResponse() - log = endpoint.log_response_safely(server_response) - self.assertTrue(log.find("[Truncated File Contents]") > 0, log) - - def test_set_user_agent_from_options_headers(self): - params = {"User-Agent": "1", "headers": {"User-Agent": "2"}} - result = TSC.server.Endpoint.set_user_agent(params) - # it should use the value under 'headers' if more than one is given - print(result) - print(result["headers"]["User-Agent"]) - self.assertTrue(result["headers"]["User-Agent"] == "2") - - def test_set_user_agent_from_options(self): - params = {"headers": {"User-Agent": "2"}} - result = TSC.server.Endpoint.set_user_agent(params) - self.assertTrue(result["headers"]["User-Agent"] == "2") - - def test_set_user_agent_when_blank(self): - params = {"headers": {}} - result = TSC.server.Endpoint.set_user_agent(params) - self.assertTrue(result["headers"]["User-Agent"].startswith("Tableau Server Client")) + endpoint = Endpoint(server) + response = endpoint._blocking_request(endpoint.parent_srv.session.get, url=url) + assert response is not None + + +def test_get_request_stream(server: TSC.Server) -> None: + url = "http://test/" + endpoint = Endpoint(server) + with requests_mock.mock() as m: + m.get(url, headers={"Content-Type": "application/octet-stream"}) + response = endpoint.get_request(url, parameters={"stream": True}) + + assert response._content_consumed is False + + +def test_binary_log_truncated(server: TSC.Server) -> None: + class FakeResponse: + headers = {"Content-Type": "application/octet-stream"} + content = b"\x1337" * 1000 + status_code = 200 + + endpoint = Endpoint(server) + server_response = FakeResponse() + log = endpoint.log_response_safely(server_response) # type: ignore + assert log.find("[Truncated File Contents]") > 0 + + +def test_set_user_agent_from_options_headers(server: TSC.Server) -> None: + params = {"User-Agent": "1", "headers": {"User-Agent": "2"}} + result = Endpoint.set_user_agent(params) + # it should use the value under 'headers' if more than one is given + print(result) + print(result["headers"]["User-Agent"]) + assert result["headers"]["User-Agent"] == "2" + + +def test_set_user_agent_from_options(server: TSC.Server) -> None: + params = {"headers": {"User-Agent": "2"}} + result = Endpoint.set_user_agent(params) + assert result["headers"]["User-Agent"] == "2" + + +def test_set_user_agent_when_blank(server: TSC.Server) -> None: + params = {"headers": {}} # type: ignore + result = Endpoint.set_user_agent(params) + assert result["headers"]["User-Agent"].startswith("Tableau Server Client") From e34cfe775e77af8668182423dc919e4a238f7797 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 10 Nov 2025 14:20:58 -0800 Subject: [PATCH 41/44] feat: make ResourceReference hashable (#1668) This allows `ResourceReference` to be used as a key in dicts, as well as added to sets by making it hashable. Also adds a `to_reference` method, while leaving the the `as_reference` static method in place untouched. Closes #1666 --- tableauserverclient/models/group_item.py | 6 ++++++ tableauserverclient/models/groupset_item.py | 6 ++++++ tableauserverclient/models/reference_item.py | 18 ++++++++++++------ tableauserverclient/models/user_item.py | 6 ++++++ tableauserverclient/server/request_factory.py | 5 ++++- 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index ad3047d83..c368b51f7 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,6 +1,7 @@ from typing import Callable, Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring +from typing_extensions import Self from .exceptions import UnpopulatedPropertyError from .property_decorators import property_not_empty, property_is_enum @@ -157,3 +158,8 @@ def from_response(cls, resp, ns) -> list["GroupItem"]: @staticmethod def as_reference(id_: str) -> ResourceReference: return ResourceReference(id_, GroupItem.tag_name) + + def to_reference(self: Self) -> ResourceReference: + if self.id is None: + raise ValueError(f"{self.__class__.__qualname__} must have id to be converted to reference") + return ResourceReference(self.id, self.tag_name) diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py index 4f082c30b..ad00504cd 100644 --- a/tableauserverclient/models/groupset_item.py +++ b/tableauserverclient/models/groupset_item.py @@ -2,6 +2,7 @@ import xml.etree.ElementTree as ET from defusedxml.ElementTree import fromstring +from typing_extensions import Self from tableauserverclient.models.group_item import GroupItem from tableauserverclient.models.reference_item import ResourceReference @@ -59,3 +60,8 @@ def get_group(group_xml: ET.Element) -> GroupItem: @staticmethod def as_reference(id_: str) -> ResourceReference: return ResourceReference(id_, GroupSetItem.tag_name) + + def to_reference(self: Self) -> ResourceReference: + if self.id is None: + raise ValueError(f"{self.__class__.__qualname__} must have id to be converted to reference") + return ResourceReference(self.id, self.tag_name) diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index 4c1fff564..30536b4d0 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -1,9 +1,12 @@ +from typing_extensions import Self + + class ResourceReference: - def __init__(self, id_, tag_name): + def __init__(self, id_: str | None, tag_name: str) -> None: self.id = id_ self.tag_name = tag_name - def __str__(self): + def __str__(self) -> str: return f"" __repr__ = __str__ @@ -13,18 +16,21 @@ def __eq__(self, other: object) -> bool: return False return (self.id == other.id) and (self.tag_name == other.tag_name) + def __hash__(self: Self) -> int: + return hash((self.id, self.tag_name)) + @property - def id(self): + def id(self) -> str | None: return self._id @id.setter - def id(self, value): + def id(self, value: str | None) -> None: self._id = value @property - def tag_name(self): + def tag_name(self) -> str: return self._tag_name @tag_name.setter - def tag_name(self, value): + def tag_name(self, value: str) -> None: self._tag_name = value diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index dc2bf4f67..9add6aec0 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -5,6 +5,7 @@ from typing import Optional, TYPE_CHECKING from defusedxml.ElementTree import fromstring +from typing_extensions import Self from tableauserverclient.datetime_helpers import parse_datetime from tableauserverclient.models.site_item import SiteAuthConfiguration @@ -377,6 +378,11 @@ def _parse_xml(cls, element_name, resp, ns): def as_reference(id_) -> ResourceReference: return ResourceReference(id_, UserItem.tag_name) + def to_reference(self: Self) -> ResourceReference: + if self.id is None: + raise ValueError(f"{self.__class__.__qualname__} must have id to be converted to reference") + return ResourceReference(self.id, self.tag_name) + @staticmethod def _parse_element(user_xml, ns): id = user_xml.get("id", None) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 877a18c39..8445008d6 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -512,7 +512,10 @@ def add_req(self, rules: Iterable[PermissionsRule]) -> bytes: for rule in rules: grantee_capabilities_element = ET.SubElement(permissions_element, "granteeCapabilities") grantee_element = ET.SubElement(grantee_capabilities_element, rule.grantee.tag_name) - grantee_element.attrib["id"] = rule.grantee.id + if rule.grantee.id is not None: + grantee_element.attrib["id"] = rule.grantee.id + else: + raise ValueError("Grantee must have an ID") capabilities_element = ET.SubElement(grantee_capabilities_element, "capabilities") self._add_all_capabilities(capabilities_element, rule.capabilities) From 7b9d73b2edd01c134d05c0538ce6ed86b8a8fcae Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 10 Nov 2025 14:22:39 -0800 Subject: [PATCH 42/44] samples: metadata.paginated_query (#1663) --- samples/metadata_paginated_query.py | 87 +++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 samples/metadata_paginated_query.py diff --git a/samples/metadata_paginated_query.py b/samples/metadata_paginated_query.py new file mode 100644 index 000000000..c812c2e95 --- /dev/null +++ b/samples/metadata_paginated_query.py @@ -0,0 +1,87 @@ +#### +# This script demonstrates how to use the metadata API to query information on a published data source +# +# To run the script, you must have installed Python 3.7 or later. +#### + +import argparse +import logging +from pprint import pprint + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description="Use the metadata API to get information on a published data source.") + # Common options; please keep those in sync across all samples + parser.add_argument("--server", "-s", help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument("--token-name", "-n", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + # Options specific to this sample + parser.add_argument( + "datasource_name", + nargs="?", + help="The name of the published datasource. If not present, we query all data sources.", + ) + + args = parser.parse_args() + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + # Sign in to server + 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): + # Execute the query + result = server.metadata.query( + """ + # Query must declare that it accepts first and afterToken variables + query paged($first:Int, $afterToken:String) { + workbooksConnection(first: $first, after:$afterToken) { + nodes { + luid + name + projectName + description + } + totalCount + pageInfo { + endCursor + hasNextPage + } + } + } + """, + # "first" adjusts the page size. Here we set it to 5 to demonstrate pagination. + # Set it to a higher number to reduce the number of pages. Including + # first and afterToken is optional, and if not included, TSC will + # use its default page size of 100. + variables={"first": 5, "afterToken": None}, + ) + + # Multiple pages are captured in result["pages"]. Each page contains + # the result of one execution of the query above. + for page in result["pages"]: + # Display warnings/errors (if any) + if page.get("errors"): + print("### Errors/Warnings:") + pprint(result["errors"]) + + # Print the results + if result.get("data"): + print("### Results:") + pprint(result["data"]["workbooksConnection"]["nodes"]) + + +if __name__ == "__main__": + main() From e648cd4657d604d59fb70ab8fbb4fedc05129f0f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 10 Nov 2025 14:23:05 -0800 Subject: [PATCH 43/44] sample: basic user creation (#1664) --- samples/create_user.py | 73 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 samples/create_user.py diff --git a/samples/create_user.py b/samples/create_user.py new file mode 100644 index 000000000..8b20f069d --- /dev/null +++ b/samples/create_user.py @@ -0,0 +1,73 @@ +#### +# This script demonstrates how to create a user using the Tableau +# Server Client. +# +# To run the script, you must have installed Python 3.7 or later. +#### + + +import argparse +import logging +import os +import sys +from typing import Sequence + +import tableauserverclient as TSC + + +def parse_args(args: Sequence[str] | None) -> argparse.Namespace: + """ + Parse command line parameters + """ + if args is None: + args = sys.argv[1:] + parser = argparse.ArgumentParser(description="Creates a sample user group.") + # Common options; please keep those in sync across all samples + parser.add_argument("--server", "-s", help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + # Options specific to this sample + # This sample has no additional options, yet. If you add some, please add them here + parser.add_argument("--role", "-r", help="Site Role for the new user", default="Unlicensed") + parser.add_argument( + "--user", + "-u", + help="Username for the new user. If using active directory, it should be in the format of SAMAccountName@FullyQualifiedDomainName", + ) + parser.add_argument( + "--email", "-e", help="Email address of the new user. If using active directory, this field is optional." + ) + + return parser.parse_args(args) + + +def main(): + args = parse_args(None) + + # Set logging level based on user input, or error by default + 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, http_options={"verify": False}) + with server.auth.sign_in(tableau_auth): + # this code shows 2 different error codes for common mistakes + # 400013: Invalid site role + # 409000: user already exists on site + + user = TSC.UserItem(args.user, args.role) + if args.email: + user.email = args.email + user = server.users.add(user) + + +if __name__ == "__main__": + main() From 0fe4e2a4700540f0bccce46385bc3cb20d8d8b4c Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Fri, 14 Nov 2025 10:36:16 -0800 Subject: [PATCH 44/44] Add standard Salesforce CODEOWNERS file (#1694) --- CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 CODEOWNERS diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..10fb2b98c --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,2 @@ +#ECCN:Open Source +#GUSINFO:Open Source,Open Source Workflow