From 8abda4bafdd5526aefa19bc2113afed150658309 Mon Sep 17 00:00:00 2001 From: Vitor Honna Date: Thu, 15 May 2025 16:07:41 -0300 Subject: [PATCH 01/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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/98] 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 From 0f706c6b3efa6d1e2cfbf0f8ac802ceb81a1a818 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 20 Nov 2025 15:11:32 -0800 Subject: [PATCH 45/98] fix: datasource owner/project missing parsing (#1700) Eric Summers pointed out that when the User Visibility setting is set to "Limited," TSC fails to parse because it can't retrieve any owner information. This bug is due to an `UnboundLocalError` where the `owner` and `project` variables were not assigned in cases where the owner and project elements were not included in the XML response. This PR also includes a test for the parsing where the owner and project elements are missing and properly set to `None` on the DatasourceItem. Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/datasource_item.py | 2 ++ test/assets/datasource_get_no_owner.xml | 16 ++++++++++++++++ test/test_datasource.py | 11 +++++++++++ 3 files changed, 29 insertions(+) create mode 100644 test/assets/datasource_get_no_owner.xml diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 2813c370c..f03a86355 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -535,6 +535,7 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: project_id = None project_name = None + project = None project_elem = datasource_xml.find(".//t:project", namespaces=ns) if project_elem is not None: project = ProjectItem.from_xml(project_elem, ns) @@ -542,6 +543,7 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple: project_name = project_elem.get("name", None) owner_id = None + owner = None owner_elem = datasource_xml.find(".//t:owner", namespaces=ns) if owner_elem is not None: owner = UserItem.from_xml(owner_elem, ns) diff --git a/test/assets/datasource_get_no_owner.xml b/test/assets/datasource_get_no_owner.xml new file mode 100644 index 000000000..a0149d5ee --- /dev/null +++ b/test/assets/datasource_get_no_owner.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index e9635874d..6870d319b 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -24,6 +24,7 @@ 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" +GET_NO_OWNER = TEST_ASSET_DIR / "datasource_get_no_owner.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" @@ -850,3 +851,13 @@ def test_get_datasource_all_fields(server) -> None: 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" + + +def test_get_datasource_no_owner(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(server.datasources.baseurl, text=GET_NO_OWNER.read_text()) + datasources, _ = server.datasources.get() + + datasource = datasources[0] + assert datasource.owner is None + assert datasource.project is None From e8890d66ef96e74fcfb3de1d17edf0b28bb3dda8 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 20 Nov 2025 16:00:46 -0800 Subject: [PATCH 46/98] fix: datasource description update and publish (#1682) * fix: datasource description update and publish Publish and update datasource were missing adding in the description to the XML. This PR adds it in. Co-authored-by: raccoooonz * ci: trigger * chore: test publish contains description --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Co-authored-by: Jac --- tableauserverclient/server/request_factory.py | 7 ++- test/test_datasource.py | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 8445008d6..66071bbeb 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -184,6 +184,9 @@ def _generate_xml(self, datasource_item: DatasourceItem, connection_credentials= project_element = ET.SubElement(datasource_element, "project") project_element.attrib["id"] = datasource_item.project_id + if datasource_item.description is not None: + datasource_element.attrib["description"] = datasource_item.description + if connection_credentials is not None and connections is not None: raise RuntimeError("You cannot set both `connections` and `connection_credentials`") @@ -196,7 +199,7 @@ def _generate_xml(self, datasource_item: DatasourceItem, connection_credentials= _add_connections_element(connections_element, connection) return ET.tostring(xml_request) - def update_req(self, datasource_item): + def update_req(self, datasource_item: DatasourceItem) -> bytes: xml_request = ET.Element("tsRequest") datasource_element = ET.SubElement(xml_request, "datasource") if datasource_item.name: @@ -219,6 +222,8 @@ def update_req(self, datasource_item): datasource_element.attrib["certificationNote"] = str(datasource_item.certification_note) if datasource_item.encrypt_extracts is not None: datasource_element.attrib["encryptExtracts"] = str(datasource_item.encrypt_extracts).lower() + if datasource_item.description is not None: + datasource_element.attrib["description"] = datasource_item.description return ET.tostring(xml_request) diff --git a/test/test_datasource.py b/test/test_datasource.py index 6870d319b..7f4cca759 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -853,6 +853,49 @@ def test_get_datasource_all_fields(server) -> None: assert datasources[0].owner.site_role == "SiteAdministratorCreator" +def test_update_description(server: TSC.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." + single_datasource.description = "Sample description" + _ = server.datasources.update(single_datasource) + + history = m.request_history[0] + body = fromstring(history.body) + ds_elem = body.find(".//datasource") + assert ds_elem is not None + assert ds_elem.attrib["description"] == "Sample description" + + +def test_publish_description(server: TSC.Server) -> None: + response_xml = PUBLISH_XML.read_text() + with requests_mock.mock() as m: + m.post(server.datasources.baseurl, 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." + single_datasource.description = "Sample description" + _ = server.datasources.publish(single_datasource, TEST_ASSET_DIR / "SampleDS.tds", server.PublishMode.CreateNew) + + history = m.request_history[0] + boundary = history.body[: history.body.index(b"\r\n")].strip() + parts = history.body.split(boundary) + request_payload = next(part for part in parts if b"request_payload" in part) + xml_payload = request_payload.strip().split(b"\r\n")[-1] + body = fromstring(xml_payload) + ds_elem = body.find(".//datasource") + assert ds_elem is not None + assert ds_elem.attrib["description"] == "Sample description" + def test_get_datasource_no_owner(server: TSC.Server) -> None: with requests_mock.mock() as m: m.get(server.datasources.baseurl, text=GET_NO_OWNER.read_text()) From 39fbcacefda7a0288e4ee5d74856965c2744167c Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 15:32:42 -0600 Subject: [PATCH 47/98] chore: pytestify favorites (#1674) * fix: black ci errors * chore: pytestify favorites --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- pyproject.toml | 3 +- test/test_datasource.py | 3 +- test/test_favorites.py | 267 +++++++++++++++++++++------------------- 3 files changed, 144 insertions(+), 129 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 68f7589ca..52f75f5f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,8 +33,7 @@ repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] test = ["black==24.8", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", - "requests-mock>=1.0,<2.0"] - + "requests-mock>=1.0,<2.0", "types-requests>=2.32.4.20250913"] [tool.black] line-length = 120 target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] diff --git a/test/test_datasource.py b/test/test_datasource.py index 7f4cca759..56eb11ab7 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -895,7 +895,8 @@ def test_publish_description(server: TSC.Server) -> None: ds_elem = body.find(".//datasource") assert ds_elem is not None assert ds_elem.attrib["description"] == "Sample description" - + + def test_get_datasource_no_owner(server: TSC.Server) -> None: with requests_mock.mock() as m: m.get(server.datasources.baseurl, text=GET_NO_OWNER.read_text()) diff --git a/test/test_favorites.py b/test/test_favorites.py index e0b701953..a7bed8d9b 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -1,131 +1,146 @@ -import unittest +from pathlib import Path +import pytest 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" -ADD_FAVORITE_WORKBOOK_XML = "favorites_add_workbook.xml" -ADD_FAVORITE_VIEW_XML = "favorites_add_view.xml" -ADD_FAVORITE_DATASOURCE_XML = "favorites_add_datasource.xml" -ADD_FAVORITE_PROJECT_XML = "favorites_add_project.xml" - - -class FavoritesTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server("http://test", False) - self.server.version = "2.5" - - # Fake signin - self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - - self.baseurl = self.server.favorites.baseurl - self.user = TSC.UserItem("alice", TSC.UserItem.Roles.Viewer) - self.user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - - def test_get(self) -> None: - response_xml = read_xml_asset(GET_FAVORITES_XML) - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{self.user.id}", text=response_xml) - self.server.favorites.get(self.user) - self.assertIsNotNone(self.user._favorites) - self.assertEqual(len(self.user.favorites["workbooks"]), 1) - self.assertEqual(len(self.user.favorites["views"]), 1) - self.assertEqual(len(self.user.favorites["projects"]), 1) - self.assertEqual(len(self.user.favorites["datasources"]), 1) - - workbook = self.user.favorites["workbooks"][0] - print("favorited: ") - print(workbook) - view = self.user.favorites["views"][0] - datasource = self.user.favorites["datasources"][0] - project = self.user.favorites["projects"][0] - - self.assertEqual(workbook.id, "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00") - self.assertEqual(view.id, "d79634e1-6063-4ec9-95ff-50acbf609ff5") - 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("") - workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" - workbook.name = "Superstore" - with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) - self.server.favorites.add_favorite_workbook(self.user, workbook) - - def test_add_favorite_view(self) -> None: - response_xml = read_xml_asset(ADD_FAVORITE_VIEW_XML) - view = TSC.ViewItem() - view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - view._name = "ENDANGERED SAFARI" - with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) - self.server.favorites.add_favorite_view(self.user, view) - - def test_add_favorite_datasource(self) -> None: - response_xml = read_xml_asset(ADD_FAVORITE_DATASOURCE_XML) - datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" - datasource.name = "SampleDS" - with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{self.user.id}", text=response_xml) - self.server.favorites.add_favorite_datasource(self.user, datasource) - - def test_add_favorite_project(self) -> None: - self.server.version = "3.1" - baseurl = self.server.favorites.baseurl - response_xml = read_xml_asset(ADD_FAVORITE_PROJECT_XML) - project = TSC.ProjectItem("Tableau") - project._id = "1d0304cd-3796-429f-b815-7258370b9b74" - with requests_mock.mock() as m: - m.put(f"{baseurl}/{self.user.id}", text=response_xml) - self.server.favorites.add_favorite_project(self.user, project) - - def test_delete_favorite_workbook(self) -> None: - workbook = TSC.WorkbookItem("") - workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" - workbook.name = "Superstore" - with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{self.user.id}/workbooks/{workbook.id}") - self.server.favorites.delete_favorite_workbook(self.user, workbook) - - def test_delete_favorite_view(self) -> None: - view = TSC.ViewItem() - view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - view._name = "ENDANGERED SAFARI" - with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{self.user.id}/views/{view.id}") - self.server.favorites.delete_favorite_view(self.user, view) - - def test_delete_favorite_datasource(self) -> None: - datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" - datasource.name = "SampleDS" - with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{self.user.id}/datasources/{datasource.id}") - self.server.favorites.delete_favorite_datasource(self.user, datasource) - - def test_delete_favorite_project(self) -> None: - self.server.version = "3.1" - baseurl = self.server.favorites.baseurl - project = TSC.ProjectItem("Tableau") - project._id = "1d0304cd-3796-429f-b815-7258370b9b74" - with requests_mock.mock() as m: - m.delete(f"{baseurl}/{self.user.id}/projects/{project.id}") - self.server.favorites.delete_favorite_project(self.user, project) + +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +GET_FAVORITES_XML = TEST_ASSET_DIR / "favorites_get.xml" +ADD_FAVORITE_WORKBOOK_XML = TEST_ASSET_DIR / "favorites_add_workbook.xml" +ADD_FAVORITE_VIEW_XML = TEST_ASSET_DIR / "favorites_add_view.xml" +ADD_FAVORITE_DATASOURCE_XML = TEST_ASSET_DIR / "favorites_add_datasource.xml" +ADD_FAVORITE_PROJECT_XML = TEST_ASSET_DIR / "favorites_add_project.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 + + +@pytest.fixture(scope="function") +def user() -> TSC.UserItem: + user = TSC.UserItem("alice", TSC.UserItem.Roles.Viewer) + user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + return user + + +def test_get(server: TSC.Server, user: TSC.UserItem) -> None: + response_xml = GET_FAVORITES_XML.read_text() + with requests_mock.mock() as m: + m.get(f"{server.favorites.baseurl}/{user.id}", text=response_xml) + server.favorites.get(user) + assert user._favorites is not None + assert len(user.favorites["workbooks"]) == 1 + assert len(user.favorites["views"]) == 1 + assert len(user.favorites["projects"]) == 1 + assert len(user.favorites["datasources"]) == 1 + + workbook = user.favorites["workbooks"][0] + print("favorited: ") + print(workbook) + view = user.favorites["views"][0] + datasource = user.favorites["datasources"][0] + project = user.favorites["projects"][0] + + assert workbook.id == "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" + assert view.id == "d79634e1-6063-4ec9-95ff-50acbf609ff5" + assert datasource.id == "e76a1461-3b1d-4588-bf1b-17551a879ad9" + assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74" + + collection = 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(server: TSC.Server, user: TSC.UserItem) -> None: + response_xml = ADD_FAVORITE_WORKBOOK_XML.read_text() + workbook = TSC.WorkbookItem("") + workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" + workbook.name = "Superstore" + with requests_mock.mock() as m: + m.put(f"{server.favorites.baseurl}/{user.id}", text=response_xml) + server.favorites.add_favorite_workbook(user, workbook) + + +def test_add_favorite_view(server: TSC.Server, user: TSC.UserItem) -> None: + response_xml = ADD_FAVORITE_VIEW_XML.read_text() + view = TSC.ViewItem() + view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + view._name = "ENDANGERED SAFARI" + with requests_mock.mock() as m: + m.put(f"{server.favorites.baseurl}/{user.id}", text=response_xml) + server.favorites.add_favorite_view(user, view) + + +def test_add_favorite_datasource(server: TSC.Server, user: TSC.UserItem) -> None: + response_xml = ADD_FAVORITE_DATASOURCE_XML.read_text() + datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" + datasource.name = "SampleDS" + with requests_mock.mock() as m: + m.put(f"{server.favorites.baseurl}/{user.id}", text=response_xml) + server.favorites.add_favorite_datasource(user, datasource) + + +def test_add_favorite_project(server: TSC.Server, user: TSC.UserItem) -> None: + server.version = "3.1" + baseurl = server.favorites.baseurl + response_xml = ADD_FAVORITE_PROJECT_XML.read_text() + project = TSC.ProjectItem("Tableau") + project._id = "1d0304cd-3796-429f-b815-7258370b9b74" + with requests_mock.mock() as m: + m.put(f"{baseurl}/{user.id}", text=response_xml) + server.favorites.add_favorite_project(user, project) + + +def test_delete_favorite_workbook(server: TSC.Server, user: TSC.UserItem) -> None: + workbook = TSC.WorkbookItem("") + workbook._id = "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" + workbook.name = "Superstore" + with requests_mock.mock() as m: + m.delete(f"{server.favorites.baseurl}/{user.id}/workbooks/{workbook.id}") + server.favorites.delete_favorite_workbook(user, workbook) + + +def test_delete_favorite_view(server: TSC.Server, user: TSC.UserItem) -> None: + view = TSC.ViewItem() + view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + view._name = "ENDANGERED SAFARI" + with requests_mock.mock() as m: + m.delete(f"{server.favorites.baseurl}/{user.id}/views/{view.id}") + server.favorites.delete_favorite_view(user, view) + + +def test_delete_favorite_datasource(server: TSC.Server, user: TSC.UserItem) -> None: + datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + datasource._id = "e76a1461-3b1d-4588-bf1b-17551a879ad9" + datasource.name = "SampleDS" + with requests_mock.mock() as m: + m.delete(f"{server.favorites.baseurl}/{user.id}/datasources/{datasource.id}") + server.favorites.delete_favorite_datasource(user, datasource) + + +def test_delete_favorite_project(server: TSC.Server, user: TSC.UserItem) -> None: + server.version = "3.1" + baseurl = server.favorites.baseurl + project = TSC.ProjectItem("Tableau") + project._id = "1d0304cd-3796-429f-b815-7258370b9b74" + with requests_mock.mock() as m: + m.delete(f"{baseurl}/{user.id}/projects/{project.id}") + server.favorites.delete_favorite_project(user, project) From 89bdc4277a30252c77ccc787af0511cabc963129 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 15:50:13 -0600 Subject: [PATCH 48/98] chore: pytestify filter (#1675) * fix: black ci errors * chore: pytestify filter --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_filter.py | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/test/test_filter.py b/test/test_filter.py index e2121307f..460813dd5 100644 --- a/test/test_filter.py +++ b/test/test_filter.py @@ -1,22 +1,16 @@ -import os -import unittest - import tableauserverclient as TSC -class FilterTests(unittest.TestCase): - def setUp(self): - pass +def test_filter_equal(): + filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "Superstore") - def test_filter_equal(self): - filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "Superstore") + assert str(filter) == "name:eq:Superstore" - self.assertEqual(str(filter), "name:eq:Superstore") - def test_filter_in(self): - # create a IN filter condition with project names that - # contain spaces and "special" characters - projects_to_find = ["default", "Salesforce Sales Projeśt"] - filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, projects_to_find) +def test_filter_in(): + # create a IN filter condition with project names that + # contain spaces and "special" characters + projects_to_find = ["default", "Salesforce Sales Projeśt"] + filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, projects_to_find) - self.assertEqual(str(filter), "name:in:[default,Salesforce Sales Projeśt]") + assert str(filter) == "name:in:[default,Salesforce Sales Projeśt]" From 9c4322e303f8dd1af3699a515afa651500b1609f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 16:34:59 -0600 Subject: [PATCH 49/98] chore: pytestify flows (#1676) * fix: black ci errors * chore: pytestify flows * style: black --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_flow.py | 443 +++++++++++++++++++++++----------------------- 1 file changed, 222 insertions(+), 221 deletions(-) diff --git a/test/test_flow.py b/test/test_flow.py index 54c1f0201..9ebbbe5d6 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -1,241 +1,242 @@ +from io import BytesIO import os +from pathlib import Path import requests_mock import tempfile -import unittest -from io import BytesIO +import pytest import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -from ._utils import read_xml_asset, asset - -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") - -GET_XML = os.path.join(TEST_ASSET_DIR, "flow_get.xml") -POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "flow_populate_connections.xml") -POPULATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, "flow_populate_permissions.xml") -PUBLISH_XML = os.path.join(TEST_ASSET_DIR, "flow_publish.xml") -UPDATE_XML = os.path.join(TEST_ASSET_DIR, "flow_update.xml") -REFRESH_XML = os.path.join(TEST_ASSET_DIR, "flow_refresh.xml") - - -class FlowTests(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.5" - - self.baseurl = self.server.flows.baseurl - - def test_download(self) -> None: - with requests_mock.mock() as m: - m.get( - self.baseurl + "/587daa37-b84d-4400-a9a2-aa90e0be7837/content", - headers={"Content-Disposition": 'name="tableau_flow"; filename="FlowOne.tfl"'}, - ) - file_path = self.server.flows.download("587daa37-b84d-4400-a9a2-aa90e0be7837") - 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 + "/587daa37-b84d-4400-a9a2-aa90e0be7837/content", - headers={"Content-Disposition": 'name="tableau_flow"; filename="FlowOne.tfl"'}, - ) - file_path = self.server.flows.download("587daa37-b84d-4400-a9a2-aa90e0be7837", filepath=file_object) - self.assertTrue(isinstance(file_path, BytesIO)) - - 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_flows, pagination_item = self.server.flows.get() - - self.assertEqual(5, pagination_item.total_available) - self.assertEqual("587daa37-b84d-4400-a9a2-aa90e0be7837", all_flows[0].id) - self.assertEqual("http://tableauserver/#/flows/1", all_flows[0].webpage_url) - self.assertEqual("2019-06-16T21:43:28Z", format_datetime(all_flows[0].created_at)) - self.assertEqual("2019-06-16T21:43:28Z", format_datetime(all_flows[0].updated_at)) - self.assertEqual("Default", all_flows[0].project_name) - self.assertEqual("FlowOne", all_flows[0].name) - self.assertEqual("aa23f4ac-906f-11e9-86fb-3f0f71412e77", all_flows[0].project_id) - self.assertEqual("7ebb3f20-0fd2-4f27-a2f6-c539470999e2", all_flows[0].owner_id) - self.assertEqual({"i_love_tags"}, all_flows[0].tags) - self.assertEqual("Descriptive", all_flows[0].description) - - self.assertEqual("5c36be69-eb30-461b-b66e-3e2a8e27cc35", all_flows[1].id) - self.assertEqual("http://tableauserver/#/flows/4", all_flows[1].webpage_url) - self.assertEqual("2019-06-18T03:08:19Z", format_datetime(all_flows[1].created_at)) - self.assertEqual("2019-06-18T03:08:19Z", format_datetime(all_flows[1].updated_at)) - self.assertEqual("Default", all_flows[1].project_name) - self.assertEqual("FlowTwo", all_flows[1].name) - self.assertEqual("aa23f4ac-906f-11e9-86fb-3f0f71412e77", all_flows[1].project_id) - self.assertEqual("9127d03f-d996-405f-b392-631b25183a0f", all_flows[1].owner_id) - - def test_update(self) -> None: - response_xml = read_xml_asset(UPDATE_XML) - with requests_mock.mock() as m: - m.put(self.baseurl + "/587daa37-b84d-4400-a9a2-aa90e0be7837", text=response_xml) - single_datasource = TSC.FlowItem("test", "aa23f4ac-906f-11e9-86fb-3f0f71412e77") - single_datasource.owner_id = "7ebb3f20-0fd2-4f27-a2f6-c539470999e2" - single_datasource._id = "587daa37-b84d-4400-a9a2-aa90e0be7837" - single_datasource.description = "So fun to see" - single_datasource = self.server.flows.update(single_datasource) - - self.assertEqual("587daa37-b84d-4400-a9a2-aa90e0be7837", single_datasource.id) - self.assertEqual("aa23f4ac-906f-11e9-86fb-3f0f71412e77", single_datasource.project_id) - self.assertEqual("7ebb3f20-0fd2-4f27-a2f6-c539470999e2", single_datasource.owner_id) - self.assertEqual("So fun to see", single_datasource.description) - - 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.FlowItem("test", "aa23f4ac-906f-11e9-86fb-3f0f71412e77") - single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - self.server.flows.populate_connections(single_datasource) - self.assertEqual("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", single_datasource.id) - connections = single_datasource.connections - - self.assertTrue(connections) - conn1, conn2, conn3 = connections - self.assertEqual("405c1e4b-60c9-499f-9c47-a4ef1af69359", conn1.id) - self.assertEqual("excel-direct", conn1.connection_type) - self.assertEqual("", conn1.server_address) - self.assertEqual("", conn1.username) - self.assertEqual(False, conn1.embed_password) - self.assertEqual("b47f41b1-2c47-41a3-8b17-a38ebe8b340c", conn2.id) - self.assertEqual("sqlserver", conn2.connection_type) - self.assertEqual("test.database.com", conn2.server_address) - self.assertEqual("bob", conn2.username) - self.assertEqual(False, conn2.embed_password) - self.assertEqual("4f4a3b78-0554-43a7-b327-9605e9df9dd2", conn3.id) - self.assertEqual("tableau-server-site", conn3.connection_type) - self.assertEqual("http://tableauserver", conn3.server_address) - self.assertEqual("sally", conn3.username) - self.assertEqual(True, conn3.embed_password) - - 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.FlowItem("test") - single_datasource._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" - - self.server.flows.populate_permissions(single_datasource) - permissions = single_datasource.permissions - - self.assertEqual(permissions[0].grantee.tag_name, "group") - self.assertEqual(permissions[0].grantee.id, "aa42f384-906f-11e9-86fc-bb24278874b9") - self.assertDictEqual( - permissions[0].capabilities, - { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - }, - ) - self.assertEqual(permissions[1].grantee.tag_name, "groupSet") - self.assertEqual(permissions[1].grantee.id, "7ea95a1b-6872-44d6-a969-68598a7df4a0") - self.assertDictEqual( - permissions[1].capabilities, - { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - }, - ) +TEST_ASSET_DIR = Path(__file__).parent / "assets" - 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) +GET_XML = TEST_ASSET_DIR / "flow_get.xml" +POPULATE_CONNECTIONS_XML = TEST_ASSET_DIR / "flow_populate_connections.xml" +POPULATE_PERMISSIONS_XML = TEST_ASSET_DIR / "flow_populate_permissions.xml" +PUBLISH_XML = TEST_ASSET_DIR / "flow_publish.xml" +UPDATE_XML = TEST_ASSET_DIR / "flow_update.xml" +REFRESH_XML = TEST_ASSET_DIR / "flow_refresh.xml" - new_flow = TSC.FlowItem(name="SampleFlow", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - sample_flow = os.path.join(TEST_ASSET_DIR, "SampleFlow.tfl") - publish_mode = self.server.PublishMode.CreateNew +@pytest.fixture(scope="function") +def server(): + """Fixture to create a TSC.Server instance for testing.""" + server = TSC.Server("http://test", False) - new_flow = self.server.flows.publish(new_flow, sample_flow, publish_mode) + # Fake signin + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + server.version = "3.3" - self.assertEqual("2457c468-1b24-461a-8f95-a461b3209d32", new_flow.id) - self.assertEqual("SampleFlow", new_flow.name) - self.assertEqual("2023-01-13T09:50:55Z", format_datetime(new_flow.created_at)) - self.assertEqual("2023-01-13T09:50:55Z", format_datetime(new_flow.updated_at)) - self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_flow.project_id) - self.assertEqual("default", new_flow.project_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_flow.owner_id) + return server - def test_publish_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_flow = TSC.FlowItem(name="SampleFlow", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - - sample_flow = os.path.join(TEST_ASSET_DIR, "SampleFlow.tfl") - publish_mode = self.server.PublishMode.CreateNew - with open(sample_flow, "rb") as fp: - publish_mode = self.server.PublishMode.CreateNew +def test_download(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get( + server.flows.baseurl + "/587daa37-b84d-4400-a9a2-aa90e0be7837/content", + headers={"Content-Disposition": 'name="tableau_flow"; filename="FlowOne.tfl"'}, + ) + file_path = server.flows.download("587daa37-b84d-4400-a9a2-aa90e0be7837") + assert os.path.exists(file_path) is True + os.remove(file_path) - new_flow = self.server.flows.publish(new_flow, fp, publish_mode) - self.assertEqual("2457c468-1b24-461a-8f95-a461b3209d32", new_flow.id) - self.assertEqual("SampleFlow", new_flow.name) - self.assertEqual("2023-01-13T09:50:55Z", format_datetime(new_flow.created_at)) - self.assertEqual("2023-01-13T09:50:55Z", format_datetime(new_flow.updated_at)) - self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", new_flow.project_id) - self.assertEqual("default", new_flow.project_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", new_flow.owner_id) - - 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: - m.post(self.baseurl + "/92967d2d-c7e2-46d0-8847-4802df58f484/run", text=response_xml) - flow_item = TSC.FlowItem("test") - flow_item._id = "92967d2d-c7e2-46d0-8847-4802df58f484" - refresh_job = self.server.flows.refresh(flow_item) - - 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_refresh_id_str(self) -> None: - with open(asset(REFRESH_XML), "rb") as f: - response_xml = f.read().decode("utf-8") +def test_download_object(server: TSC.Server) -> None: + with BytesIO() as file_object: 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( - self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", - headers={"Content-Disposition": '''name="tableau_flow"; filename*=UTF-8''"Sample flow.tfl"'''}, + server.flows.baseurl + "/587daa37-b84d-4400-a9a2-aa90e0be7837/content", + headers={"Content-Disposition": 'name="tableau_flow"; filename="FlowOne.tfl"'}, ) - file_path = self.server.flows.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) - self.assertTrue(os.path.exists(file_path)) + file_path = server.flows.download("587daa37-b84d-4400-a9a2-aa90e0be7837", filepath=file_object) + assert isinstance(file_path, BytesIO) + + +def test_get(server: TSC.Server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.flows.baseurl, text=response_xml) + all_flows, pagination_item = server.flows.get() + + assert 5 == pagination_item.total_available + assert "587daa37-b84d-4400-a9a2-aa90e0be7837" == all_flows[0].id + assert "http://tableauserver/#/flows/1" == all_flows[0].webpage_url + assert "2019-06-16T21:43:28Z" == format_datetime(all_flows[0].created_at) + assert "2019-06-16T21:43:28Z" == format_datetime(all_flows[0].updated_at) + assert "Default" == all_flows[0].project_name + assert "FlowOne" == all_flows[0].name + assert "aa23f4ac-906f-11e9-86fb-3f0f71412e77" == all_flows[0].project_id + assert "7ebb3f20-0fd2-4f27-a2f6-c539470999e2" == all_flows[0].owner_id + assert {"i_love_tags"} == all_flows[0].tags + assert "Descriptive" == all_flows[0].description + + assert "5c36be69-eb30-461b-b66e-3e2a8e27cc35" == all_flows[1].id + assert "http://tableauserver/#/flows/4" == all_flows[1].webpage_url + assert "2019-06-18T03:08:19Z" == format_datetime(all_flows[1].created_at) + assert "2019-06-18T03:08:19Z" == format_datetime(all_flows[1].updated_at) + assert "Default" == all_flows[1].project_name + assert "FlowTwo" == all_flows[1].name + assert "aa23f4ac-906f-11e9-86fb-3f0f71412e77" == all_flows[1].project_id + assert "9127d03f-d996-405f-b392-631b25183a0f" == all_flows[1].owner_id + + +def test_update(server: TSC.Server) -> None: + response_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.put(server.flows.baseurl + "/587daa37-b84d-4400-a9a2-aa90e0be7837", text=response_xml) + single_datasource = TSC.FlowItem("test", "aa23f4ac-906f-11e9-86fb-3f0f71412e77") + single_datasource.owner_id = "7ebb3f20-0fd2-4f27-a2f6-c539470999e2" + single_datasource._id = "587daa37-b84d-4400-a9a2-aa90e0be7837" + single_datasource.description = "So fun to see" + single_datasource = server.flows.update(single_datasource) + + assert "587daa37-b84d-4400-a9a2-aa90e0be7837" == single_datasource.id + assert "aa23f4ac-906f-11e9-86fb-3f0f71412e77" == single_datasource.project_id + assert "7ebb3f20-0fd2-4f27-a2f6-c539470999e2" == single_datasource.owner_id + assert "So fun to see" == single_datasource.description + + +def test_populate_connections(server: TSC.Server) -> None: + response_xml = POPULATE_CONNECTIONS_XML.read_text() + with requests_mock.mock() as m: + m.get(server.flows.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", text=response_xml) + single_datasource = TSC.FlowItem("test", "aa23f4ac-906f-11e9-86fb-3f0f71412e77") + single_datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + server.flows.populate_connections(single_datasource) + assert "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" == single_datasource.id + connections = single_datasource.connections + + assert connections is not None + assert len(connections) > 0 + conn1, conn2, conn3 = connections + assert "405c1e4b-60c9-499f-9c47-a4ef1af69359" == conn1.id + assert "excel-direct" == conn1.connection_type + assert "" == conn1.server_address + assert "" == conn1.username + assert conn1.embed_password is False + assert "b47f41b1-2c47-41a3-8b17-a38ebe8b340c" == conn2.id + assert "sqlserver" == conn2.connection_type + assert "test.database.com" == conn2.server_address + assert "bob" == conn2.username + assert conn2.embed_password is False + assert "4f4a3b78-0554-43a7-b327-9605e9df9dd2" == conn3.id + assert "tableau-server-site" == conn3.connection_type + assert "http://tableauserver" == conn3.server_address + assert "sally" == conn3.username + assert conn3.embed_password is True + + +def test_populate_permissions(server: TSC.Server) -> None: + response_xml = POPULATE_PERMISSIONS_XML.read_text() + with requests_mock.mock() as m: + m.get(server.flows.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions", text=response_xml) + single_datasource = TSC.FlowItem("test") + single_datasource._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" + + server.flows.populate_permissions(single_datasource) + permissions = single_datasource.permissions + + assert permissions[0].grantee.tag_name == "group" + assert permissions[0].grantee.id == "aa42f384-906f-11e9-86fc-bb24278874b9" + assert permissions[0].capabilities == { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + } + + assert permissions[1].grantee.tag_name == "groupSet" + assert permissions[1].grantee.id == "7ea95a1b-6872-44d6-a969-68598a7df4a0" + assert permissions[1].capabilities == { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + } + + +def test_publish(server: TSC.Server) -> None: + response_xml = PUBLISH_XML.read_text() + with requests_mock.mock() as m: + m.post(server.flows.baseurl, text=response_xml) + + new_flow = TSC.FlowItem(name="SampleFlow", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + + sample_flow = TEST_ASSET_DIR / "SampleFlow.tfl" + publish_mode = server.PublishMode.CreateNew + + new_flow = server.flows.publish(new_flow, sample_flow, publish_mode) + + assert "2457c468-1b24-461a-8f95-a461b3209d32" == new_flow.id + assert "SampleFlow" == new_flow.name + assert "2023-01-13T09:50:55Z" == format_datetime(new_flow.created_at) + assert "2023-01-13T09:50:55Z" == format_datetime(new_flow.updated_at) + assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == new_flow.project_id + assert "default" == new_flow.project_name + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == new_flow.owner_id + + +def test_publish_file_object(server: TSC.Server) -> None: + response_xml = PUBLISH_XML.read_text() + with requests_mock.mock() as m: + m.post(server.flows.baseurl, text=response_xml) + + new_flow = TSC.FlowItem(name="SampleFlow", project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + sample_flow = os.path.join(TEST_ASSET_DIR, "SampleFlow.tfl") + publish_mode = server.PublishMode.CreateNew + + with open(sample_flow, "rb") as fp: + publish_mode = server.PublishMode.CreateNew + + new_flow = server.flows.publish(new_flow, fp, publish_mode) + + assert "2457c468-1b24-461a-8f95-a461b3209d32" == new_flow.id + assert "SampleFlow" == new_flow.name + assert "2023-01-13T09:50:55Z" == format_datetime(new_flow.created_at) + assert "2023-01-13T09:50:55Z" == format_datetime(new_flow.updated_at) + assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == new_flow.project_id + assert "default" == new_flow.project_name + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == new_flow.owner_id + + +def test_refresh(server: TSC.Server) -> None: + response_xml = REFRESH_XML.read_text() + with requests_mock.mock() as m: + m.post(server.flows.baseurl + "/92967d2d-c7e2-46d0-8847-4802df58f484/run", text=response_xml) + flow_item = TSC.FlowItem("test") + flow_item._id = "92967d2d-c7e2-46d0-8847-4802df58f484" + refresh_job = server.flows.refresh(flow_item) + + assert refresh_job.id == "d1b2ccd0-6dfa-444a-aee4-723dbd6b7c9d" + assert refresh_job.mode == "Asynchronous" + assert refresh_job.type == "RunFlow" + assert format_datetime(refresh_job.created_at) == "2018-05-22T13:00:29Z" + assert isinstance(refresh_job.flow_run, TSC.FlowRunItem) + assert refresh_job.flow_run.id == "e0c3067f-2333-4eee-8028-e0a56ca496f6" + assert refresh_job.flow_run.flow_id == "92967d2d-c7e2-46d0-8847-4802df58f484" + assert format_datetime(refresh_job.flow_run.started_at) == "2018-05-22T13:00:29Z" + + +def test_refresh_id_str(server: TSC.Server) -> None: + response_xml = REFRESH_XML.read_text() + with requests_mock.mock() as m: + m.post(server.flows.baseurl + "/92967d2d-c7e2-46d0-8847-4802df58f484/run", text=response_xml) + refresh_job = server.flows.refresh("92967d2d-c7e2-46d0-8847-4802df58f484") + + assert refresh_job.id == "d1b2ccd0-6dfa-444a-aee4-723dbd6b7c9d" + assert refresh_job.mode == "Asynchronous" + assert refresh_job.type == "RunFlow" + assert format_datetime(refresh_job.created_at) == "2018-05-22T13:00:29Z" + assert isinstance(refresh_job.flow_run, TSC.FlowRunItem) + assert refresh_job.flow_run.id == "e0c3067f-2333-4eee-8028-e0a56ca496f6" + assert refresh_job.flow_run.flow_id == "92967d2d-c7e2-46d0-8847-4802df58f484" + assert format_datetime(refresh_job.flow_run.started_at) == "2018-05-22T13:00:29Z" + + +def test_bad_download_response(server: TSC.Server) -> None: + with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: + m.get( + server.flows.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", + headers={"Content-Disposition": '''name="tableau_flow"; filename*=UTF-8''"Sample flow.tfl"'''}, + ) + file_path = server.flows.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) + assert os.path.exists(file_path) is True From d4de5bdd2673f272fce80126160dc878bacfa975 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 16:35:38 -0600 Subject: [PATCH 50/98] chore: pytestify flowruns (#1677) * fix: black ci errors * chore: pytestify flowruns --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_flowruns.py | 218 ++++++++++++++++++++++-------------------- 1 file changed, 114 insertions(+), 104 deletions(-) diff --git a/test/test_flowruns.py b/test/test_flowruns.py index 8af2540dc..003ee944b 100644 --- a/test/test_flowruns.py +++ b/test/test_flowruns.py @@ -1,111 +1,121 @@ +from pathlib import Path import sys -import unittest +import pytest import requests_mock import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException -from ._utils import read_xml_asset, mocked_time, server_response_error_factory - -GET_XML = "flow_runs_get.xml" -GET_BY_ID_XML = "flow_runs_get_by_id.xml" -GET_BY_ID_FAILED_XML = "flow_runs_get_by_id_failed.xml" -GET_BY_ID_INPROGRESS_XML = "flow_runs_get_by_id_inprogress.xml" - - -class FlowRunTests(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.10" - - self.baseurl = self.server.flow_runs.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_flow_runs = self.server.flow_runs.get() - - self.assertEqual("cc2e652d-4a9b-4476-8c93-b238c45db968", all_flow_runs[0].id) - self.assertEqual("2021-02-11T01:42:55Z", format_datetime(all_flow_runs[0].started_at)) - self.assertEqual("2021-02-11T01:57:38Z", format_datetime(all_flow_runs[0].completed_at)) - self.assertEqual("Success", all_flow_runs[0].status) - self.assertEqual("100", all_flow_runs[0].progress) - self.assertEqual("aa23f4ac-906f-11e9-86fb-3f0f71412e77", all_flow_runs[0].background_job_id) - - self.assertEqual("a3104526-c0c6-4ea5-8362-e03fc7cbd7ee", all_flow_runs[1].id) - self.assertEqual("2021-02-13T04:05:30Z", format_datetime(all_flow_runs[1].started_at)) - self.assertEqual("2021-02-13T04:05:35Z", format_datetime(all_flow_runs[1].completed_at)) - self.assertEqual("Failed", all_flow_runs[1].status) - self.assertEqual("100", all_flow_runs[1].progress) - self.assertEqual("1ad21a9d-2530-4fbf-9064-efd3c736e023", all_flow_runs[1].background_job_id) - - 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 + "/cc2e652d-4a9b-4476-8c93-b238c45db968", text=response_xml) - flow_run = self.server.flow_runs.get_by_id("cc2e652d-4a9b-4476-8c93-b238c45db968") - - self.assertEqual("cc2e652d-4a9b-4476-8c93-b238c45db968", flow_run.id) - self.assertEqual("2021-02-11T01:42:55Z", format_datetime(flow_run.started_at)) - self.assertEqual("2021-02-11T01:57:38Z", format_datetime(flow_run.completed_at)) - self.assertEqual("Success", flow_run.status) - self.assertEqual("100", flow_run.progress) - self.assertEqual("1ad21a9d-2530-4fbf-9064-efd3c736e023", flow_run.background_job_id) - - def test_cancel_id(self) -> None: - with requests_mock.mock() as m: - m.put(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) - self.server.flow_runs.cancel("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - - def test_cancel_item(self) -> None: - run = TSC.FlowRunItem() - run._id = "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - with requests_mock.mock() as m: - m.put(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) - self.server.flow_runs.cancel(run) - - def test_wait_for_job_finished(self) -> None: - # Waiting for an already finished job, directly returns that job's info - response_xml = read_xml_asset(GET_BY_ID_XML) - flow_run_id = "cc2e652d-4a9b-4476-8c93-b238c45db968" - with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) - flow_run = self.server.flow_runs.wait_for_job(flow_run_id) - - self.assertEqual(flow_run_id, flow_run.id) - self.assertEqual(flow_run.progress, "100") - - def test_wait_for_job_failed(self) -> None: - # Waiting for a failed job raises an exception - response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) - flow_run_id = "c2b35d5a-e130-471a-aec8-7bc5435fe0e7" - with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) - with self.assertRaises(FlowRunFailedException): - self.server.flow_runs.wait_for_job(flow_run_id) - - def test_wait_for_job_timeout(self) -> None: - # Waiting for a job which doesn't terminate will throw an exception - response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) - flow_run_id = "71afc22c-9c06-40be-8d0f-4c4166d29e6c" - with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{flow_run_id}", text=response_xml) - with self.assertRaises(TimeoutError): - self.server.flow_runs.wait_for_job(flow_run_id, timeout=30) - - def test_queryset(self) -> None: - response_xml = read_xml_asset(GET_XML) - error_response = server_response_error_factory( - "400006", "Bad Request", "0xB4EAB088 : The start index '9900' is greater than or equal to the total count.)" - ) - with requests_mock.mock() as m: - m.get(f"{self.baseurl}?pageNumber=1", text=response_xml) - m.get(f"{self.baseurl}?pageNumber=2", text=error_response) - queryset = self.server.flow_runs.all() - assert len(queryset) == sys.maxsize +from ._utils import mocked_time, server_response_error_factory + +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +GET_XML = TEST_ASSET_DIR / "flow_runs_get.xml" +GET_BY_ID_XML = TEST_ASSET_DIR / "flow_runs_get_by_id.xml" +GET_BY_ID_FAILED_XML = TEST_ASSET_DIR / "flow_runs_get_by_id_failed.xml" +GET_BY_ID_INPROGRESS_XML = TEST_ASSET_DIR / "flow_runs_get_by_id_inprogress.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.10" + + return server + + +def test_get(server: TSC.Server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.flow_runs.baseurl, text=response_xml) + all_flow_runs = server.flow_runs.get() + + assert "cc2e652d-4a9b-4476-8c93-b238c45db968" == all_flow_runs[0].id + assert "2021-02-11T01:42:55Z" == format_datetime(all_flow_runs[0].started_at) + assert "2021-02-11T01:57:38Z" == format_datetime(all_flow_runs[0].completed_at) + assert "Success" == all_flow_runs[0].status + assert "100" == all_flow_runs[0].progress + assert "aa23f4ac-906f-11e9-86fb-3f0f71412e77" == all_flow_runs[0].background_job_id + + assert "a3104526-c0c6-4ea5-8362-e03fc7cbd7ee" == all_flow_runs[1].id + assert "2021-02-13T04:05:30Z" == format_datetime(all_flow_runs[1].started_at) + assert "2021-02-13T04:05:35Z" == format_datetime(all_flow_runs[1].completed_at) + assert "Failed" == all_flow_runs[1].status + assert "100" == all_flow_runs[1].progress + assert "1ad21a9d-2530-4fbf-9064-efd3c736e023" == all_flow_runs[1].background_job_id + + +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.flow_runs.baseurl + "/cc2e652d-4a9b-4476-8c93-b238c45db968", text=response_xml) + flow_run = server.flow_runs.get_by_id("cc2e652d-4a9b-4476-8c93-b238c45db968") + + assert "cc2e652d-4a9b-4476-8c93-b238c45db968" == flow_run.id + assert "2021-02-11T01:42:55Z" == format_datetime(flow_run.started_at) + assert "2021-02-11T01:57:38Z" == format_datetime(flow_run.completed_at) + assert "Success" == flow_run.status + assert "100" == flow_run.progress + assert "1ad21a9d-2530-4fbf-9064-efd3c736e023" == flow_run.background_job_id + + +def test_cancel_id(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.put(server.flow_runs.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) + server.flow_runs.cancel("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + + +def test_cancel_item(server: TSC.Server) -> None: + run = TSC.FlowRunItem() + run._id = "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + with requests_mock.mock() as m: + m.put(server.flow_runs.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) + server.flow_runs.cancel(run) + + +def test_wait_for_job_finished(server: TSC.Server) -> None: + # Waiting for an already finished job, directly returns that job's info + response_xml = GET_BY_ID_XML.read_text() + flow_run_id = "cc2e652d-4a9b-4476-8c93-b238c45db968" + with mocked_time(), requests_mock.mock() as m: + m.get(f"{server.flow_runs.baseurl}/{flow_run_id}", text=response_xml) + flow_run = server.flow_runs.wait_for_job(flow_run_id) + + assert flow_run_id == flow_run.id + assert flow_run.progress == "100" + + +def test_wait_for_job_failed(server: TSC.Server) -> None: + # Waiting for a failed job raises an exception + response_xml = GET_BY_ID_FAILED_XML.read_text() + flow_run_id = "c2b35d5a-e130-471a-aec8-7bc5435fe0e7" + with mocked_time(), requests_mock.mock() as m: + m.get(f"{server.flow_runs.baseurl}/{flow_run_id}", text=response_xml) + with pytest.raises(FlowRunFailedException): + server.flow_runs.wait_for_job(flow_run_id) + + +def test_wait_for_job_timeout(server: TSC.Server) -> None: + # Waiting for a job which doesn't terminate will throw an exception + response_xml = GET_BY_ID_INPROGRESS_XML.read_text() + flow_run_id = "71afc22c-9c06-40be-8d0f-4c4166d29e6c" + with mocked_time(), requests_mock.mock() as m: + m.get(f"{server.flow_runs.baseurl}/{flow_run_id}", text=response_xml) + with pytest.raises(TimeoutError): + server.flow_runs.wait_for_job(flow_run_id, timeout=30) + + +def test_queryset(server: TSC.Server) -> None: + response_xml = GET_XML.read_text() + error_response = server_response_error_factory( + "400006", "Bad Request", "0xB4EAB088 : The start index '9900' is greater than or equal to the total count.)" + ) + with requests_mock.mock() as m: + m.get(f"{server.flow_runs.baseurl}?pageNumber=1", text=response_xml) + m.get(f"{server.flow_runs.baseurl}?pageNumber=2", text=error_response) + queryset = server.flow_runs.all() + assert len(queryset) == sys.maxsize From 82a5c501dbbb9d6d73b92d527fe3f22fce769d4b Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 16:36:13 -0600 Subject: [PATCH 51/98] chore: pytestify flowtask (#1678) * fix: black ci errors * chore: pytestify flowtask --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../server/endpoint/flow_task_endpoint.py | 2 +- test/test_flowtask.py | 59 +++++++++---------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/tableauserverclient/server/endpoint/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py index 9e21661e6..cb210a9ef 100644 --- a/tableauserverclient/server/endpoint/flow_task_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_task_endpoint.py @@ -18,7 +18,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/flows" @api(version="3.22") - def create(self, flow_item: TaskItem) -> TaskItem: + def create(self, flow_item: TaskItem) -> bytes: if not flow_item: error = "No flow provided" raise ValueError(error) diff --git a/test/test_flowtask.py b/test/test_flowtask.py index 2d9f7c7bd..601446c0e 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -1,47 +1,46 @@ -import os -import unittest from datetime import time from pathlib import Path +import pytest import requests_mock import tableauserverclient as TSC -from tableauserverclient.datetime_helpers import parse_datetime from tableauserverclient.models.task_item import TaskItem TEST_ASSET_DIR = Path(__file__).parent / "assets" -GET_XML_CREATE_FLOW_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_flow_task.xml") +GET_XML_CREATE_FLOW_TASK_RESPONSE = TEST_ASSET_DIR / "tasks_create_flow_task.xml" -class TaskTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server("http://test", False) - self.server.version = "3.22" +@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" + # Fake signin + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + server.version = "3.22" - self.baseurl = self.server.flow_tasks.baseurl + return server - def test_create_flow_task(self): - monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) - monthly_schedule = TSC.ScheduleItem( - "Monthly Schedule", - 50, - TSC.ScheduleItem.Type.Flow, - TSC.ScheduleItem.ExecutionOrder.Parallel, - monthly_interval, - ) - target_item = TSC.Target("flow_id", "flow") - task = TaskItem(None, "RunFlow", None, schedule_item=monthly_schedule, target=target_item) +def test_create_flow_task(server: TSC.Server) -> None: + monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) + monthly_schedule = TSC.ScheduleItem( + "Monthly Schedule", + 50, + TSC.ScheduleItem.Type.Flow, + TSC.ScheduleItem.ExecutionOrder.Parallel, + monthly_interval, + ) + target_item = TSC.Target("flow_id", "flow") - with open(GET_XML_CREATE_FLOW_TASK_RESPONSE, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(f"{self.baseurl}", text=response_xml) - create_response_content = self.server.flow_tasks.create(task).decode("utf-8") + task = TaskItem("", "RunFlow", 0, schedule_item=monthly_schedule, target=target_item) - self.assertTrue("schedule_id" in create_response_content) - self.assertTrue("flow_id" in create_response_content) + response_xml = GET_XML_CREATE_FLOW_TASK_RESPONSE.read_text() + with requests_mock.mock() as m: + m.post(f"{server.flow_tasks.baseurl}", text=response_xml) + create_response_content = server.flow_tasks.create(task).decode("utf-8") + + assert "schedule_id" in create_response_content + assert "flow_id" in create_response_content From 20d738b43de2d876ee3c2f276bb1459466c095d9 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 16:38:09 -0600 Subject: [PATCH 52/98] chore: pytestify groups (#1679) * fix: black ci errors * chore: pytestify groups * fix: windows decoding error * fix: windows decoding error * chore: pytestify test_group_model --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_group.py | 631 ++++++++++++++++++++------------------- test/test_group_model.py | 20 +- 2 files changed, 329 insertions(+), 322 deletions(-) diff --git a/test/test_group.py b/test/test_group.py index b3de07963..734b5fa38 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,335 +1,342 @@ from pathlib import Path -import unittest -import os import requests_mock import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -TEST_ASSET_DIR = Path(__file__).absolute().parent / "assets" +import pytest -# TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +TEST_ASSET_DIR = Path(__file__).absolute().parent / "assets" -GET_XML = os.path.join(TEST_ASSET_DIR, "group_get.xml") +GET_XML = TEST_ASSET_DIR / "group_get.xml" GET_XML_ALL_FIELDS = TEST_ASSET_DIR / "group_get_all_fields.xml" -POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml") -POPULATE_USERS_EMPTY = os.path.join(TEST_ASSET_DIR, "group_populate_users_empty.xml") -ADD_USER = os.path.join(TEST_ASSET_DIR, "group_add_user.xml") +POPULATE_USERS = TEST_ASSET_DIR / "group_populate_users.xml" +POPULATE_USERS_EMPTY = TEST_ASSET_DIR / "group_populate_users_empty.xml" +ADD_USER = TEST_ASSET_DIR / "group_add_user.xml" ADD_USERS = TEST_ASSET_DIR / "group_add_users.xml" -ADD_USER_POPULATE = os.path.join(TEST_ASSET_DIR, "group_users_added.xml") -CREATE_GROUP = os.path.join(TEST_ASSET_DIR, "group_create.xml") -CREATE_GROUP_AD = os.path.join(TEST_ASSET_DIR, "group_create_ad.xml") -CREATE_GROUP_ASYNC = os.path.join(TEST_ASSET_DIR, "group_create_async.xml") -UPDATE_XML = os.path.join(TEST_ASSET_DIR, "group_update.xml") +ADD_USER_POPULATE = TEST_ASSET_DIR / "group_users_added.xml" +CREATE_GROUP = TEST_ASSET_DIR / "group_create.xml" +CREATE_GROUP_AD = TEST_ASSET_DIR / "group_create_ad.xml" +CREATE_GROUP_ASYNC = TEST_ASSET_DIR / "group_create_async.xml" +UPDATE_XML = TEST_ASSET_DIR / "group_update.xml" UPDATE_ASYNC_XML = TEST_ASSET_DIR / "group_update_async.xml" -class GroupTests(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.groups.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_groups, pagination_item = self.server.groups.get() - - self.assertEqual(3, pagination_item.total_available) - self.assertEqual("ef8b19c0-43b6-11e6-af50-63f5805dbe3c", all_groups[0].id) - self.assertEqual("All Users", all_groups[0].name) - self.assertEqual("local", all_groups[0].domain_name) - - self.assertEqual("e7833b48-c6f7-47b5-a2a7-36e7dd232758", all_groups[1].id) - self.assertEqual("Another group", all_groups[1].name) - self.assertEqual("local", all_groups[1].domain_name) - - self.assertEqual("86a66d40-f289-472a-83d0-927b0f954dc8", all_groups[2].id) - self.assertEqual("TableauExample", all_groups[2].name) - self.assertEqual("local", all_groups[2].domain_name) - - def test_get_before_signin(self) -> None: - self.server._auth_token = None - self.assertRaises(TSC.NotSignedInError, self.server.groups.get) - - def test_populate_users(self) -> None: - with open(POPULATE_USERS, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get( - self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users?pageNumber=1&pageSize=100", - text=response_xml, - complete_qs=True, - ) - single_group = TSC.GroupItem(name="Test Group") - single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" - self.server.groups.populate_users(single_group) - - self.assertEqual(1, len(list(single_group.users))) - user = list(single_group.users).pop() - self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", user.id) - self.assertEqual("alice", user.name) - self.assertEqual("Publisher", user.site_role) - self.assertEqual("2016-08-16T23:17:06Z", format_datetime(user.last_login)) - - def test_delete(self) -> None: - with requests_mock.mock() as m: - m.delete(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758", status_code=204) - self.server.groups.delete("e7833b48-c6f7-47b5-a2a7-36e7dd232758") - - def test_remove_user(self) -> None: - with open(POPULATE_USERS, "rb") as f: - response_xml_populate = f.read().decode("utf-8") - - with open(POPULATE_USERS_EMPTY, "rb") as f: - response_xml_empty = f.read().decode("utf-8") - - with requests_mock.mock() as m: - url = self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users" "/dd2239f6-ddf1-4107-981a-4cf94e415794" - - m.delete(url, status_code=204) - # We register the get endpoint twice. The first time we have 1 user, the second we have 'removed' them. - m.get(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml_populate) - - single_group = TSC.GroupItem("test") - single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" - self.server.groups.populate_users(single_group) - self.assertEqual(1, len(list(single_group.users))) - self.server.groups.remove_user(single_group, "dd2239f6-ddf1-4107-981a-4cf94e415794") - - m.get(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml_empty) - self.assertEqual(0, len(list(single_group.users))) - - def test_add_user(self) -> None: - with open(ADD_USER, "rb") as f: - response_xml_add = f.read().decode("utf-8") - with open(ADD_USER_POPULATE, "rb") as f: - response_xml_populate = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml_add) - m.get(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml_populate) - single_group = TSC.GroupItem("test") - single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" - - self.server.groups.add_user(single_group, "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7") - self.server.groups.populate_users(single_group) - self.assertEqual(1, len(list(single_group.users))) - user = list(single_group.users).pop() - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", user.id) - self.assertEqual("testuser", user.name) - self.assertEqual("ServerAdministrator", user.site_role) - - def test_add_users(self) -> None: - self.server.version = "3.21" - self.baseurl = self.server.groups.baseurl - - def make_user(id: str, name: str, siteRole: str) -> TSC.UserItem: - user = TSC.UserItem(name, siteRole) - user._id = id - return user - - users = [ - make_user(id="5de011f8-4aa9-4d5b-b991-f464c8dd6bb7", name="Alice", siteRole="ServerAdministrator"), - make_user(id="5de011f8-3aa9-4d5b-b991-f467c8dd6bb8", name="Bob", siteRole="Explorer"), - make_user(id="5de011f8-2aa9-4d5b-b991-f466c8dd6bb8", name="Charlie", siteRole="Viewer"), - ] - group = TSC.GroupItem("test") - group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" - - with requests_mock.mock() as m: - m.post(f"{self.baseurl}/{group.id}/users", text=ADD_USERS.read_text()) - resp_users = self.server.groups.add_users(group, users) - - for user, resp_user in zip(users, resp_users): - with self.subTest(user=user, resp_user=resp_user): - assert user.id == resp_user.id - assert user.name == resp_user.name - assert user.site_role == resp_user.site_role - - def test_remove_users(self) -> None: - self.server.version = "3.21" - self.baseurl = self.server.groups.baseurl - - def make_user(id: str, name: str, siteRole: str) -> TSC.UserItem: - user = TSC.UserItem(name, siteRole) - user._id = id - return user - - users = [ - make_user(id="5de011f8-4aa9-4d5b-b991-f464c8dd6bb7", name="Alice", siteRole="ServerAdministrator"), - make_user(id="5de011f8-3aa9-4d5b-b991-f467c8dd6bb8", name="Bob", siteRole="Explorer"), - make_user(id="5de011f8-2aa9-4d5b-b991-f466c8dd6bb8", name="Charlie", siteRole="Viewer"), - ] - group = TSC.GroupItem("test") - group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" - - with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{group.id}/users/remove") - self.server.groups.remove_users(group, users) - - def test_add_user_before_populating(self) -> None: - with open(GET_XML, "rb") as f: - get_xml_response = f.read().decode("utf-8") - with open(ADD_USER, "rb") as f: - add_user_response = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl, text=get_xml_response) - m.post( - self.baseurl + "/ef8b19c0-43b6-11e6-af50-63f5805dbe3c/users", - text=add_user_response, - ) - all_groups, pagination_item = self.server.groups.get() - single_group = all_groups[0] - self.server.groups.add_user(single_group, "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7") - - def test_add_user_missing_user_id(self) -> None: - with open(POPULATE_USERS, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml) - single_group = TSC.GroupItem(name="Test Group") - single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" - self.server.groups.populate_users(single_group) - - self.assertRaises(ValueError, self.server.groups.add_user, single_group, "") - - def test_add_user_missing_group_id(self) -> None: +@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: TSC.Server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.groups.baseurl, text=response_xml) + all_groups, pagination_item = server.groups.get() + + assert 3 == pagination_item.total_available + assert "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" == all_groups[0].id + assert "All Users" == all_groups[0].name + assert "local" == all_groups[0].domain_name + + assert "e7833b48-c6f7-47b5-a2a7-36e7dd232758" == all_groups[1].id + assert "Another group" == all_groups[1].name + assert "local" == all_groups[1].domain_name + + assert "86a66d40-f289-472a-83d0-927b0f954dc8" == all_groups[2].id + assert "TableauExample" == all_groups[2].name + assert "local" == all_groups[2].domain_name + + +def test_get_before_signin(server: TSC.Server) -> None: + server._auth_token = None + with pytest.raises(TSC.NotSignedInError): + server.groups.get() + + +def test_populate_users(server: TSC.Server) -> None: + response_xml = POPULATE_USERS.read_text() + with requests_mock.mock() as m: + m.get( + server.groups.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users?pageNumber=1&pageSize=100", + text=response_xml, + complete_qs=True, + ) + single_group = TSC.GroupItem(name="Test Group") + single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" + server.groups.populate_users(single_group) + + assert 1 == len(list(single_group.users)) + user = list(single_group.users).pop() + assert "dd2239f6-ddf1-4107-981a-4cf94e415794" == user.id + assert "alice" == user.name + assert "Publisher" == user.site_role + assert "2016-08-16T23:17:06Z" == format_datetime(user.last_login) + + +def test_delete(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.delete(server.groups.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758", status_code=204) + server.groups.delete("e7833b48-c6f7-47b5-a2a7-36e7dd232758") + + +def test_remove_user(server: TSC.Server) -> None: + response_xml_populate = POPULATE_USERS.read_text() + + response_xml_empty = POPULATE_USERS_EMPTY.read_text() + + with requests_mock.mock() as m: + url = ( + server.groups.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users" + "/dd2239f6-ddf1-4107-981a-4cf94e415794" + ) + + m.delete(url, status_code=204) + # We register the get endpoint twice. The first time we have 1 user, the second we have 'removed' them. + m.get(server.groups.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml_populate) + + single_group = TSC.GroupItem("test") + single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" + server.groups.populate_users(single_group) + assert 1 == len(list(single_group.users)) + server.groups.remove_user(single_group, "dd2239f6-ddf1-4107-981a-4cf94e415794") + + m.get(server.groups.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml_empty) + assert 0 == len(list(single_group.users)) + + +def test_add_user(server: TSC.Server) -> None: + response_xml_add = ADD_USER.read_text() + response_xml_populate = ADD_USER_POPULATE.read_text() + with requests_mock.mock() as m: + m.post(server.groups.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml_add) + m.get(server.groups.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml_populate) single_group = TSC.GroupItem("test") - self.assertRaises( - TSC.MissingRequiredFieldError, - self.server.groups.add_user, + single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" + + server.groups.add_user(single_group, "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7") + server.groups.populate_users(single_group) + assert 1 == len(list(single_group.users)) + user = list(single_group.users).pop() + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == user.id + assert "testuser" == user.name + assert "ServerAdministrator" == user.site_role + + +def test_add_users(server: TSC.Server) -> None: + server.version = "3.21" + + def make_user(id: str, name: str, siteRole: str) -> TSC.UserItem: + user = TSC.UserItem(name, siteRole) + user._id = id + return user + + users = [ + make_user(id="5de011f8-4aa9-4d5b-b991-f464c8dd6bb7", name="Alice", siteRole="ServerAdministrator"), + make_user(id="5de011f8-3aa9-4d5b-b991-f467c8dd6bb8", name="Bob", siteRole="Explorer"), + make_user(id="5de011f8-2aa9-4d5b-b991-f466c8dd6bb8", name="Charlie", siteRole="Viewer"), + ] + group = TSC.GroupItem("test") + group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" + + with requests_mock.mock() as m: + m.post(f"{server.groups.baseurl}/{group.id}/users", text=ADD_USERS.read_text()) + resp_users = server.groups.add_users(group, users) + + for user, resp_user in zip(users, resp_users): + assert user.id == resp_user.id + assert user.name == resp_user.name + assert user.site_role == resp_user.site_role + + +def test_remove_users(server: TSC.Server) -> None: + server.version = "3.21" + + def make_user(id: str, name: str, siteRole: str) -> TSC.UserItem: + user = TSC.UserItem(name, siteRole) + user._id = id + return user + + users = [ + make_user(id="5de011f8-4aa9-4d5b-b991-f464c8dd6bb7", name="Alice", siteRole="ServerAdministrator"), + make_user(id="5de011f8-3aa9-4d5b-b991-f467c8dd6bb8", name="Bob", siteRole="Explorer"), + make_user(id="5de011f8-2aa9-4d5b-b991-f466c8dd6bb8", name="Charlie", siteRole="Viewer"), + ] + group = TSC.GroupItem("test") + group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" + + with requests_mock.mock() as m: + m.put(f"{server.groups.baseurl}/{group.id}/users/remove") + server.groups.remove_users(group, users) + + +def test_add_user_before_populating(server: TSC.Server) -> None: + get_xml_response = GET_XML.read_text() + add_user_response = ADD_USER.read_text() + with requests_mock.mock() as m: + m.get(server.groups.baseurl, text=get_xml_response) + m.post( + server.groups.baseurl + "/ef8b19c0-43b6-11e6-af50-63f5805dbe3c/users", + text=add_user_response, + ) + all_groups, pagination_item = server.groups.get() + single_group = all_groups[0] + server.groups.add_user(single_group, "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7") + + +def test_add_user_missing_user_id(server: TSC.Server) -> None: + response_xml = POPULATE_USERS.read_text() + with requests_mock.mock() as m: + m.get(server.groups.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml) + single_group = TSC.GroupItem(name="Test Group") + single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" + server.groups.populate_users(single_group) + + with pytest.raises(ValueError): + server.groups.add_user(single_group, "") + + +def test_add_user_missing_group_id(server: TSC.Server) -> None: + single_group = TSC.GroupItem("test") + with pytest.raises(TSC.MissingRequiredFieldError): + server.groups.add_user( single_group, "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", ) - def test_remove_user_before_populating(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) - m.delete( - self.baseurl + "/ef8b19c0-43b6-11e6-af50-63f5805dbe3c/users/5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", - text="ok", - ) - all_groups, pagination_item = self.server.groups.get() - single_group = all_groups[0] - self.server.groups.remove_user(single_group, "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7") - - def test_remove_user_missing_user_id(self) -> None: - with open(POPULATE_USERS, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml) - single_group = TSC.GroupItem(name="Test Group") - single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" - self.server.groups.populate_users(single_group) - - self.assertRaises(ValueError, self.server.groups.remove_user, single_group, "") - - def test_remove_user_missing_group_id(self) -> None: - single_group = TSC.GroupItem("test") - self.assertRaises( - TSC.MissingRequiredFieldError, - self.server.groups.remove_user, + +def test_remove_user_before_populating(server: TSC.Server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.groups.baseurl, text=response_xml) + m.delete( + server.groups.baseurl + "/ef8b19c0-43b6-11e6-af50-63f5805dbe3c/users/5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", + text="ok", + ) + all_groups, pagination_item = server.groups.get() + single_group = all_groups[0] + server.groups.remove_user(single_group, "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7") + + +def test_remove_user_missing_user_id(server: TSC.Server) -> None: + response_xml = POPULATE_USERS.read_text() + with requests_mock.mock() as m: + m.get(server.groups.baseurl + "/e7833b48-c6f7-47b5-a2a7-36e7dd232758/users", text=response_xml) + single_group = TSC.GroupItem(name="Test Group") + single_group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" + server.groups.populate_users(single_group) + + with pytest.raises(ValueError): + server.groups.remove_user(single_group, "") + + +def test_remove_user_missing_group_id(server: TSC.Server) -> None: + single_group = TSC.GroupItem("test") + with pytest.raises(TSC.MissingRequiredFieldError): + server.groups.remove_user( single_group, "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", ) - def test_create_group(self) -> None: - with open(CREATE_GROUP, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) - group_to_create = TSC.GroupItem("試供品") - group = self.server.groups.create(group_to_create) - self.assertEqual(group.name, "試供品") - self.assertEqual(group.id, "3e4a9ea0-a07a-4fe6-b50f-c345c8c81034") - - def test_create_ad_group(self) -> None: - with open(CREATE_GROUP_AD, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) - group_to_create = TSC.GroupItem("試供品") - group_to_create.domain_name = "just-has-to-exist" - group = self.server.groups.create_AD_group(group_to_create, False) - self.assertEqual(group.name, "試供品") - self.assertEqual(group.license_mode, "onLogin") - self.assertEqual(group.minimum_site_role, "Creator") - self.assertEqual(group.domain_name, "active-directory-domain-name") - - def test_create_group_async(self) -> None: - with open(CREATE_GROUP_ASYNC, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) - group_to_create = TSC.GroupItem("試供品") - group_to_create.domain_name = "woohoo" - job = self.server.groups.create_AD_group(group_to_create, True) - self.assertEqual(job.mode, "Asynchronous") - self.assertEqual(job.type, "GroupImport") - - 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 + "/ef8b19c0-43b6-11e6-af50-63f5805dbe3c", text=response_xml) - group = TSC.GroupItem(name="Test Group") - group._domain_name = "local" - group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" - group = self.server.groups.update(group) - - self.assertEqual("ef8b19c0-43b6-11e6-af50-63f5805dbe3c", group.id) - self.assertEqual("Group updated name", group.name) - self.assertEqual("ExplorerCanPublish", group.minimum_site_role) - self.assertEqual("onLogin", group.license_mode) - - # async update is not supported for local groups - def test_update_local_async(self) -> None: - group = TSC.GroupItem("myGroup") - group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" - self.assertRaises(ValueError, self.server.groups.update, group, as_job=True) - - # mimic group returned from server where domain name is set to 'local' - group.domain_name = "local" - self.assertRaises(ValueError, self.server.groups.update, group, as_job=True) - def test_update_ad_async(self) -> None: - group = TSC.GroupItem("myGroup", "example.com") +def test_create_group(server: TSC.Server) -> None: + response_xml = CREATE_GROUP.read_text(encoding="utf-8") + with requests_mock.mock() as m: + m.post(server.groups.baseurl, text=response_xml) + group_to_create = TSC.GroupItem("試供品") + group = server.groups.create(group_to_create) + assert group.name == "試供品" + assert group.id == "3e4a9ea0-a07a-4fe6-b50f-c345c8c81034" + + +def test_create_ad_group(server: TSC.Server) -> None: + response_xml = CREATE_GROUP_AD.read_bytes().decode("utf8") + with requests_mock.mock() as m: + m.post(server.groups.baseurl, text=response_xml) + group_to_create = TSC.GroupItem("試供品") + group_to_create.domain_name = "just-has-to-exist" + group = server.groups.create_AD_group(group_to_create, False) + assert group.name == "試供品" + assert group.license_mode == "onLogin" + assert group.minimum_site_role == "Creator" + assert group.domain_name == "active-directory-domain-name" + + +def test_create_group_async(server: TSC.Server) -> None: + response_xml = CREATE_GROUP_ASYNC.read_text() + with requests_mock.mock() as m: + m.post(server.groups.baseurl, text=response_xml) + group_to_create = TSC.GroupItem("試供品") + group_to_create.domain_name = "woohoo" + job = server.groups.create_AD_group(group_to_create, True) + assert job.mode == "Asynchronous" + assert job.type == "GroupImport" + + +def test_update(server: TSC.Server) -> None: + response_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.put(server.groups.baseurl + "/ef8b19c0-43b6-11e6-af50-63f5805dbe3c", text=response_xml) + group = TSC.GroupItem(name="Test Group") + group._domain_name = "local" group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" - group.minimum_site_role = TSC.UserItem.Roles.Viewer - - with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{group.id}?asJob=True", text=UPDATE_ASYNC_XML.read_bytes().decode("utf8")) - job = self.server.groups.update(group, as_job=True) - - self.assertEqual(job.id, "c2566efc-0767-4f15-89cb-56acb4349c1b") - self.assertEqual(job.mode, "Asynchronous") - self.assertEqual(job.type, "GroupSync") - - def test_get_all_fields(self) -> None: - ro = TSC.RequestOptions() - ro.all_fields = True - self.server.version = "3.21" - self.baseurl = self.server.groups.baseurl - with requests_mock.mock() as m: - m.get(f"{self.baseurl}?fields=_all_", text=GET_XML_ALL_FIELDS.read_text()) - groups, pages = self.server.groups.get(req_options=ro) - - assert pages.total_available == 3 - assert len(groups) == 3 - assert groups[0].id == "28c5b855-16df-482f-ad0b-428c1df58859" - assert groups[0].name == "All Users" - assert groups[0].user_count == 2 - assert groups[0].domain_name == "local" - assert groups[1].id == "ace1ee2d-e7dd-4d7a-9504-a1ccaa5212ea" - assert groups[1].name == "group1" - assert groups[1].user_count == 1 - assert groups[2].id == "baf0ed9d-c25d-4114-97ed-5232b8a732fd" - assert groups[2].name == "test" - assert groups[2].user_count == 0 + group = server.groups.update(group) + + assert "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" == group.id + assert "Group updated name" == group.name + assert "ExplorerCanPublish" == group.minimum_site_role + assert "onLogin" == group.license_mode + + +# async update is not supported for local groups +def test_update_local_async(server: TSC.Server) -> None: + group = TSC.GroupItem("myGroup") + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" + with pytest.raises(ValueError): + server.groups.update(group, as_job=True) + + # mimic group returned from server where domain name is set to 'local' + group.domain_name = "local" + with pytest.raises(ValueError): + server.groups.update(group, as_job=True) + + +def test_update_ad_async(server: TSC.Server) -> None: + group = TSC.GroupItem("myGroup", "example.com") + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" + group.minimum_site_role = TSC.UserItem.Roles.Viewer + + with requests_mock.mock() as m: + m.put(f"{server.groups.baseurl}/{group.id}?asJob=True", text=UPDATE_ASYNC_XML.read_bytes().decode("utf8")) + job = server.groups.update(group, as_job=True) + + assert job.id == "c2566efc-0767-4f15-89cb-56acb4349c1b" + assert job.mode == "Asynchronous" + assert job.type == "GroupSync" + + +def test_get_all_fields(server: TSC.Server) -> None: + ro = TSC.RequestOptions() + ro.all_fields = True + server.version = "3.21" + with requests_mock.mock() as m: + m.get(f"{server.groups.baseurl}?fields=_all_", text=GET_XML_ALL_FIELDS.read_text()) + groups, pages = server.groups.get(req_options=ro) + + assert pages.total_available == 3 + assert len(groups) == 3 + assert groups[0].id == "28c5b855-16df-482f-ad0b-428c1df58859" + assert groups[0].name == "All Users" + assert groups[0].user_count == 2 + assert groups[0].domain_name == "local" + assert groups[1].id == "ace1ee2d-e7dd-4d7a-9504-a1ccaa5212ea" + assert groups[1].name == "group1" + assert groups[1].user_count == 1 + assert groups[2].id == "baf0ed9d-c25d-4114-97ed-5232b8a732fd" + assert groups[2].name == "test" + assert groups[2].user_count == 0 diff --git a/test/test_group_model.py b/test/test_group_model.py index 659a3611f..6ca2f6b25 100644 --- a/test/test_group_model.py +++ b/test/test_group_model.py @@ -1,15 +1,15 @@ -import unittest +import pytest import tableauserverclient as TSC -class GroupModelTests(unittest.TestCase): - def test_invalid_minimum_site_role(self): - group = TSC.GroupItem("grp") - with self.assertRaises(ValueError): - group.minimum_site_role = "Captain" +def test_invalid_minimum_site_role(): + group = TSC.GroupItem("grp") + with pytest.raises(ValueError): + group.minimum_site_role = "Captain" - def test_invalid_license_mode(self): - group = TSC.GroupItem("grp") - with self.assertRaises(ValueError): - group.license_mode = "off" + +def test_invalid_license_mode(): + group = TSC.GroupItem("grp") + with pytest.raises(ValueError): + group.license_mode = "off" From 423a042212977edd3ac68e8c16569ccd05333939 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 16:39:48 -0600 Subject: [PATCH 53/98] chore: pytestify models repr (#1684) * fix: black ci errors * chore: pytestify models repr --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/models/test_repr.py | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/test/models/test_repr.py b/test/models/test_repr.py index 92d11978f..0f6057f4f 100644 --- a/test/models/test_repr.py +++ b/test/models/test_repr.py @@ -1,10 +1,10 @@ import inspect +from typing import Any -from unittest import TestCase import _models # type: ignore # did not set types for this import tableauserverclient as TSC -from typing import Any +import pytest # ensure that all models that don't need parameters can be instantiated @@ -31,21 +31,16 @@ def instantiate_class(name: str, obj: Any): print(f"Class '{name}' does not have a constructor (__init__ method).") -class TestAllModels(TestCase): - # not all models have __repr__ yet: see above list - def test_repr_is_implemented(self): - m = _models.get_defined_models() - for model in m: - with self.subTest(model.__name__, model=model): - print(model.__name__, type(model.__repr__).__name__) - self.assertEqual(type(model.__repr__).__name__, "function") +def is_concrete(obj: Any): + return inspect.isclass(obj) and not inspect.isabstract(obj) - # 2 - Iterate through the objects in the module - def test_by_reflection(self): - for class_name, obj in inspect.getmembers(TSC, is_concrete): - with self.subTest(class_name, obj=obj): - instantiate_class(class_name, obj) +@pytest.mark.parametrize("class_name, obj", inspect.getmembers(TSC, is_concrete)) +def test_by_reflection(class_name, obj): + instantiate_class(class_name, obj) -def is_concrete(obj: Any): - return inspect.isclass(obj) and not inspect.isabstract(obj) + +@pytest.mark.parametrize("model", _models.get_defined_models()) +def test_repr_is_implemented(model): + print(model.__name__, type(model.__repr__).__name__) + assert type(model.__repr__).__name__ == "function" From 5e75a7261631678dfdc7de5f80b5816991d71b26 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 16:41:35 -0600 Subject: [PATCH 54/98] chore: pytestify group_sets (#1685) * fix: black ci errors * chore: pytestify group_sets --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_groupsets.py | 257 +++++++++++++++++++++-------------------- 1 file changed, 132 insertions(+), 125 deletions(-) diff --git a/test/test_groupsets.py b/test/test_groupsets.py index 5479809d2..e8276d803 100644 --- a/test/test_groupsets.py +++ b/test/test_groupsets.py @@ -1,7 +1,6 @@ from pathlib import Path -import unittest -from defusedxml.ElementTree import fromstring +import pytest import requests_mock import tableauserverclient as TSC @@ -14,126 +13,134 @@ GROUPSET_UPDATE = TEST_ASSET_DIR / "groupsets_get_by_id.xml" -class TestGroupSets(unittest.TestCase): - def setUp(self) -> None: - self.server = TSC.Server("http://test", False) - self.server.version = "3.22" - - # Fake signin - self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - - self.baseurl = self.server.group_sets.baseurl - - def test_get(self) -> None: - with requests_mock.mock() as m: - m.get(self.baseurl, text=GROUPSETS_GET.read_text()) - groupsets, pagination_item = self.server.group_sets.get() - - assert len(groupsets) == 3 - assert pagination_item.total_available == 3 - assert groupsets[0].id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" - assert groupsets[0].name == "All Users" - assert groupsets[0].group_count == 1 - assert groupsets[0].groups[0].name == "group-one" - assert groupsets[0].groups[0].id == "gs-1" - - assert groupsets[1].id == "9a8a7b6b-5c4c-3d2d-1e0e-9a8a7b6b5b4b" - assert groupsets[1].name == "active-directory-group-import" - assert groupsets[1].group_count == 1 - assert groupsets[1].groups[0].name == "group-two" - assert groupsets[1].groups[0].id == "gs21" - - assert groupsets[2].id == "7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" - assert groupsets[2].name == "local-group-license-on-login" - assert groupsets[2].group_count == 1 - assert groupsets[2].groups[0].name == "group-three" - assert groupsets[2].groups[0].id == "gs-3" - - def test_get_by_id(self) -> None: - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", text=GROUPSET_GET_BY_ID.read_text()) - groupset = self.server.group_sets.get_by_id("1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d") - - assert groupset.id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" - assert groupset.name == "All Users" - assert groupset.group_count == 3 - assert len(groupset.groups) == 3 - - assert groupset.groups[0].name == "group-one" - assert groupset.groups[0].id == "gs-1" - assert groupset.groups[1].name == "group-two" - assert groupset.groups[1].id == "gs21" - assert groupset.groups[2].name == "group-three" - assert groupset.groups[2].id == "gs-3" - - def test_update(self) -> None: - id_ = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" - groupset = TSC.GroupSetItem("All Users") - groupset.id = id_ - with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{id_}", text=GROUPSET_UPDATE.read_text()) - groupset = self.server.group_sets.update(groupset) - - assert groupset.id == id_ - assert groupset.name == "All Users" - assert groupset.group_count == 3 - assert len(groupset.groups) == 3 - - assert groupset.groups[0].name == "group-one" - assert groupset.groups[0].id == "gs-1" - assert groupset.groups[1].name == "group-two" - assert groupset.groups[1].id == "gs21" - assert groupset.groups[2].name == "group-three" - assert groupset.groups[2].id == "gs-3" - - def test_create(self) -> None: - groupset = TSC.GroupSetItem("All Users") - with requests_mock.mock() as m: - m.post(self.baseurl, text=GROUPSET_CREATE.read_text()) - groupset = self.server.group_sets.create(groupset) - - assert groupset.id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" - assert groupset.name == "All Users" - assert groupset.group_count == 0 - assert len(groupset.groups) == 0 - - def test_add_group(self) -> None: - groupset = TSC.GroupSetItem("All") - groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" - group = TSC.GroupItem("Example") - group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" - - with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{groupset.id}/groups/{group._id}") - self.server.group_sets.add_group(groupset, group) - - history = m.request_history - - assert len(history) == 1 - assert history[0].method == "PUT" - assert history[0].url == f"{self.baseurl}/{groupset.id}/groups/{group._id}" - - def test_remove_group(self) -> None: - groupset = TSC.GroupSetItem("All") - groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" - group = TSC.GroupItem("Example") - group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" - - with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{groupset.id}/groups/{group._id}") - self.server.group_sets.remove_group(groupset, group) - - history = m.request_history - - assert len(history) == 1 - assert history[0].method == "DELETE" - assert history[0].url == f"{self.baseurl}/{groupset.id}/groups/{group._id}" - - def test_as_reference(self) -> None: - groupset = TSC.GroupSetItem() - groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" - ref = groupset.as_reference(groupset.id) - assert ref.id == groupset.id - assert ref.tag_name == groupset.tag_name - assert isinstance(ref, ResourceReference) +@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" + server.version = "3.22" + + return server + + +def test_get(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(server.group_sets.baseurl, text=GROUPSETS_GET.read_text()) + groupsets, pagination_item = server.group_sets.get() + + assert len(groupsets) == 3 + assert pagination_item.total_available == 3 + assert groupsets[0].id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + assert groupsets[0].name == "All Users" + assert groupsets[0].group_count == 1 + assert groupsets[0].groups[0].name == "group-one" + assert groupsets[0].groups[0].id == "gs-1" + + assert groupsets[1].id == "9a8a7b6b-5c4c-3d2d-1e0e-9a8a7b6b5b4b" + assert groupsets[1].name == "active-directory-group-import" + assert groupsets[1].group_count == 1 + assert groupsets[1].groups[0].name == "group-two" + assert groupsets[1].groups[0].id == "gs21" + + assert groupsets[2].id == "7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" + assert groupsets[2].name == "local-group-license-on-login" + assert groupsets[2].group_count == 1 + assert groupsets[2].groups[0].name == "group-three" + assert groupsets[2].groups[0].id == "gs-3" + + +def test_get_by_id(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(f"{server.group_sets.baseurl}/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", text=GROUPSET_GET_BY_ID.read_text()) + groupset = server.group_sets.get_by_id("1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d") + + assert groupset.id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + assert groupset.name == "All Users" + assert groupset.group_count == 3 + assert len(groupset.groups) == 3 + + assert groupset.groups[0].name == "group-one" + assert groupset.groups[0].id == "gs-1" + assert groupset.groups[1].name == "group-two" + assert groupset.groups[1].id == "gs21" + assert groupset.groups[2].name == "group-three" + assert groupset.groups[2].id == "gs-3" + + +def test_update(server: TSC.Server) -> None: + id_ = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + groupset = TSC.GroupSetItem("All Users") + groupset.id = id_ + with requests_mock.mock() as m: + m.put(f"{server.group_sets.baseurl}/{id_}", text=GROUPSET_UPDATE.read_text()) + groupset = server.group_sets.update(groupset) + + assert groupset.id == id_ + assert groupset.name == "All Users" + assert groupset.group_count == 3 + assert len(groupset.groups) == 3 + + assert groupset.groups[0].name == "group-one" + assert groupset.groups[0].id == "gs-1" + assert groupset.groups[1].name == "group-two" + assert groupset.groups[1].id == "gs21" + assert groupset.groups[2].name == "group-three" + assert groupset.groups[2].id == "gs-3" + + +def test_create(server: TSC.Server) -> None: + groupset = TSC.GroupSetItem("All Users") + with requests_mock.mock() as m: + m.post(server.group_sets.baseurl, text=GROUPSET_CREATE.read_text()) + groupset = server.group_sets.create(groupset) + + assert groupset.id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + assert groupset.name == "All Users" + assert groupset.group_count == 0 + assert len(groupset.groups) == 0 + + +def test_add_group(server: TSC.Server) -> None: + groupset = TSC.GroupSetItem("All") + groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + group = TSC.GroupItem("Example") + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" + + with requests_mock.mock() as m: + m.put(f"{server.group_sets.baseurl}/{groupset.id}/groups/{group._id}") + server.group_sets.add_group(groupset, group) + + history = m.request_history + + assert len(history) == 1 + assert history[0].method == "PUT" + assert history[0].url == f"{server.group_sets.baseurl}/{groupset.id}/groups/{group._id}" + + +def test_remove_group(server: TSC.Server) -> None: + groupset = TSC.GroupSetItem("All") + groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + group = TSC.GroupItem("Example") + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" + + with requests_mock.mock() as m: + m.delete(f"{server.group_sets.baseurl}/{groupset.id}/groups/{group._id}") + server.group_sets.remove_group(groupset, group) + + history = m.request_history + + assert len(history) == 1 + assert history[0].method == "DELETE" + assert history[0].url == f"{server.group_sets.baseurl}/{groupset.id}/groups/{group._id}" + + +def test_as_reference(server: TSC.Server) -> None: + groupset = TSC.GroupSetItem() + groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + ref = groupset.as_reference(groupset.id) + assert ref.id == groupset.id + assert ref.tag_name == groupset.tag_name + assert isinstance(ref, ResourceReference) From 97124be2ac48e8d710c1291072823196439cfd62 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 16:43:55 -0600 Subject: [PATCH 55/98] chore: pytestify jobs (#1686) * fix: black ci errors * chore: pytestify jobs --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_job.py | 314 +++++++++++++++++++++++++---------------------- 1 file changed, 165 insertions(+), 149 deletions(-) diff --git a/test/test_job.py b/test/test_job.py index b3d7007aa..fa17b9953 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -1,158 +1,174 @@ -import os -import unittest from datetime import datetime +from pathlib import Path +import pytest import requests_mock import tableauserverclient as TSC from tableauserverclient.datetime_helpers import utc from tableauserverclient.server.endpoint.exceptions import JobFailedException -from ._utils import read_xml_asset, mocked_time - -GET_XML = "job_get.xml" -GET_BY_ID_XML = "job_get_by_id.xml" -GET_BY_ID_COMPLETED_XML = "job_get_by_id_completed.xml" -GET_BY_ID_FAILED_XML = "job_get_by_id_failed.xml" -GET_BY_ID_CANCELLED_XML = "job_get_by_id_cancelled.xml" -GET_BY_ID_INPROGRESS_XML = "job_get_by_id_inprogress.xml" -GET_BY_ID_WORKBOOK = "job_get_by_id_failed_workbook.xml" - - -class JobTests(unittest.TestCase): - def setUp(self) -> None: - self.server = TSC.Server("http://test", False) - self.server.version = "3.1" - - # Fake signin - self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - - self.baseurl = self.server.jobs.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_jobs, pagination_item = self.server.jobs.get() - job = all_jobs[0] - created_at = datetime(2018, 5, 22, 13, 0, 29, tzinfo=utc) - started_at = datetime(2018, 5, 22, 13, 0, 37, tzinfo=utc) - ended_at = datetime(2018, 5, 22, 13, 0, 45, tzinfo=utc) - - self.assertEqual(1, pagination_item.total_available) - self.assertEqual("2eef4225-aa0c-41c4-8662-a76d89ed7336", job.id) - self.assertEqual("Success", job.status) - self.assertEqual("50", job.priority) - self.assertEqual("single_subscription_notify", job.type) - self.assertEqual(created_at, job.created_at) - self.assertEqual(started_at, job.started_at) - self.assertEqual(ended_at, job.ended_at) - - def test_get_by_id(self) -> None: - response_xml = read_xml_asset(GET_BY_ID_XML) - job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) - job = self.server.jobs.get_by_id(job_id) - updated_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) - - self.assertEqual(job_id, job.id) - self.assertEqual(updated_at, job.updated_at) - self.assertListEqual(job.notes, ["Job detail notes"]) - - def test_get_before_signin(self) -> None: - self.server._auth_token = None - self.assertRaises(TSC.NotSignedInError, self.server.jobs.get) - - def test_cancel_id(self) -> None: - with requests_mock.mock() as m: - m.put(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) - self.server.jobs.cancel("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - - def test_cancel_item(self) -> None: +from ._utils import mocked_time + + +TEST_ASSET_DIR = Path(__file__).parent / "assets" +GET_XML = TEST_ASSET_DIR / "job_get.xml" +GET_BY_ID_XML = TEST_ASSET_DIR / "job_get_by_id.xml" +GET_BY_ID_COMPLETED_XML = TEST_ASSET_DIR / "job_get_by_id_completed.xml" +GET_BY_ID_FAILED_XML = TEST_ASSET_DIR / "job_get_by_id_failed.xml" +GET_BY_ID_CANCELLED_XML = TEST_ASSET_DIR / "job_get_by_id_cancelled.xml" +GET_BY_ID_INPROGRESS_XML = TEST_ASSET_DIR / "job_get_by_id_inprogress.xml" +GET_BY_ID_WORKBOOK = TEST_ASSET_DIR / "job_get_by_id_failed_workbook.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" + server.version = "3.1" + + return server + + +def test_get(server: TSC.Server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.jobs.baseurl, text=response_xml) + all_jobs, pagination_item = server.jobs.get() + job = all_jobs[0] created_at = datetime(2018, 5, 22, 13, 0, 29, tzinfo=utc) started_at = datetime(2018, 5, 22, 13, 0, 37, tzinfo=utc) - job = TSC.JobItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "backgroundJob", "0", created_at, started_at, None, 0) - with requests_mock.mock() as m: - m.put(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) - self.server.jobs.cancel(job) - - def test_wait_for_job_finished(self) -> None: - # Waiting for an already finished job, directly returns that job's info - response_xml = read_xml_asset(GET_BY_ID_XML) - job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" - with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) - job = self.server.jobs.wait_for_job(job_id) - - self.assertEqual(job_id, job.id) - self.assertListEqual(job.notes, ["Job detail notes"]) - - def test_wait_for_job_completed(self) -> None: - # Waiting for a bridge (cloud) job completion - response_xml = read_xml_asset(GET_BY_ID_COMPLETED_XML) - job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" - with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) - job = self.server.jobs.wait_for_job(job_id) - - self.assertEqual(job_id, job.id) - self.assertListEqual(job.notes, ["Job detail notes"]) - - def test_wait_for_job_failed(self) -> None: - # Waiting for a failed job raises an exception - response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) - job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" - with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) - with self.assertRaises(JobFailedException): - self.server.jobs.wait_for_job(job_id) - - def test_wait_for_job_timeout(self) -> None: - # Waiting for a job which doesn't terminate will throw an exception - response_xml = read_xml_asset(GET_BY_ID_INPROGRESS_XML) - job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" - with mocked_time(), requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) - with self.assertRaises(TimeoutError): - self.server.jobs.wait_for_job(job_id, timeout=30) - - def test_get_job_datasource_id(self) -> None: - response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) - job_id = "777bf7c4-421d-4b2c-a518-11b90187c545" - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) - job = self.server.jobs.get_by_id(job_id) - self.assertEqual(job.datasource_id, "03b9fbec-81f6-4160-ae49-5f9f6d412758") - - def test_get_job_workbook_id(self) -> None: - response_xml = read_xml_asset(GET_BY_ID_WORKBOOK) - job_id = "bb1aab79-db54-4e96-9dd3-461d8f081d08" - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) - job = self.server.jobs.get_by_id(job_id) - self.assertEqual(job.workbook_id, "5998aaaf-1abe-4d38-b4d9-bc53e85bdd13") - - def test_get_job_workbook_name(self) -> None: - response_xml = read_xml_asset(GET_BY_ID_WORKBOOK) - job_id = "bb1aab79-db54-4e96-9dd3-461d8f081d08" - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) - job = self.server.jobs.get_by_id(job_id) - self.assertEqual(job.workbook_name, "Superstore") - - def test_get_job_datasource_name(self) -> None: - response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) - job_id = "777bf7c4-421d-4b2c-a518-11b90187c545" - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{job_id}", text=response_xml) - job = self.server.jobs.get_by_id(job_id) - self.assertEqual(job.datasource_name, "World Indicators") - - def test_background_job_str(self) -> None: - job = TSC.BackgroundJobItem( - "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", datetime.now(), 1, "extractRefresh", "Failed" - ) - assert not str(job).startswith("< None: + response_xml = GET_BY_ID_XML.read_text() + job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" + with requests_mock.mock() as m: + m.get(f"{server.jobs.baseurl}/{job_id}", text=response_xml) + job = server.jobs.get_by_id(job_id) + updated_at = datetime(2020, 5, 13, 20, 25, 18, tzinfo=utc) + + assert job_id == job.id + assert updated_at == job.updated_at + assert job.notes == ["Job detail notes"] + + +def test_get_before_signin(server: TSC.Server) -> None: + server._auth_token = None + with pytest.raises(TSC.NotSignedInError): + server.jobs.get() + + +def test_cancel_id(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.put(server.jobs.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) + server.jobs.cancel("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + + +def test_cancel_item(server: TSC.Server) -> None: + created_at = datetime(2018, 5, 22, 13, 0, 29, tzinfo=utc) + started_at = datetime(2018, 5, 22, 13, 0, 37, tzinfo=utc) + job = TSC.JobItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "backgroundJob", "0", created_at, started_at, None, 0) + with requests_mock.mock() as m: + m.put(server.jobs.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) + server.jobs.cancel(job) + + +def test_wait_for_job_finished(server: TSC.Server) -> None: + # Waiting for an already finished job, directly returns that job's info + response_xml = GET_BY_ID_XML.read_text() + job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" + with mocked_time(), requests_mock.mock() as m: + m.get(f"{server.jobs.baseurl}/{job_id}", text=response_xml) + job = server.jobs.wait_for_job(job_id) + + assert job_id == job.id + assert job.notes == ["Job detail notes"] + + +def test_wait_for_job_completed(server: TSC.Server) -> None: + # Waiting for a bridge (cloud) job completion + response_xml = GET_BY_ID_COMPLETED_XML.read_text() + job_id = "2eef4225-aa0c-41c4-8662-a76d89ed7336" + with mocked_time(), requests_mock.mock() as m: + m.get(f"{server.jobs.baseurl}/{job_id}", text=response_xml) + job = server.jobs.wait_for_job(job_id) + + assert job_id == job.id + assert job.notes == ["Job detail notes"] + + +def test_wait_for_job_failed(server: TSC.Server) -> None: + # Waiting for a failed job raises an exception + response_xml = GET_BY_ID_FAILED_XML.read_text() + job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" + with mocked_time(), requests_mock.mock() as m: + m.get(f"{server.jobs.baseurl}/{job_id}", text=response_xml) + with pytest.raises(JobFailedException): + server.jobs.wait_for_job(job_id) + + +def test_wait_for_job_timeout(server: TSC.Server) -> None: + # Waiting for a job which doesn't terminate will throw an exception + response_xml = GET_BY_ID_INPROGRESS_XML.read_text() + job_id = "77d5e57a-2517-479f-9a3c-a32025f2b64d" + with mocked_time(), requests_mock.mock() as m: + m.get(f"{server.jobs.baseurl}/{job_id}", text=response_xml) + with pytest.raises(TimeoutError): + server.jobs.wait_for_job(job_id, timeout=30) + + +def test_get_job_datasource_id(server: TSC.Server) -> None: + response_xml = GET_BY_ID_FAILED_XML.read_text() + job_id = "777bf7c4-421d-4b2c-a518-11b90187c545" + with requests_mock.mock() as m: + m.get(f"{server.jobs.baseurl}/{job_id}", text=response_xml) + job = server.jobs.get_by_id(job_id) + assert job.datasource_id == "03b9fbec-81f6-4160-ae49-5f9f6d412758" + + +def test_get_job_workbook_id(server: TSC.Server) -> None: + response_xml = GET_BY_ID_WORKBOOK.read_text() + job_id = "bb1aab79-db54-4e96-9dd3-461d8f081d08" + with requests_mock.mock() as m: + m.get(f"{server.jobs.baseurl}/{job_id}", text=response_xml) + job = server.jobs.get_by_id(job_id) + assert job.workbook_id == "5998aaaf-1abe-4d38-b4d9-bc53e85bdd13" + + +def test_get_job_workbook_name(server: TSC.Server) -> None: + response_xml = GET_BY_ID_WORKBOOK.read_text() + job_id = "bb1aab79-db54-4e96-9dd3-461d8f081d08" + with requests_mock.mock() as m: + m.get(f"{server.jobs.baseurl}/{job_id}", text=response_xml) + job = server.jobs.get_by_id(job_id) + assert job.workbook_name == "Superstore" + + +def test_get_job_datasource_name(server: TSC.Server) -> None: + response_xml = GET_BY_ID_FAILED_XML.read_text() + job_id = "777bf7c4-421d-4b2c-a518-11b90187c545" + with requests_mock.mock() as m: + m.get(f"{server.jobs.baseurl}/{job_id}", text=response_xml) + job = server.jobs.get_by_id(job_id) + assert job.datasource_name == "World Indicators" + + +def test_background_job_str() -> None: + job = TSC.BackgroundJobItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", datetime.now(), 1, "extractRefresh", "Failed") + assert not str(job).startswith("< Date: Mon, 22 Dec 2025 17:04:48 -0600 Subject: [PATCH 56/98] chore: pytestify linked_tasks (#1687) * fix: black ci errors * chore: pytestify linked_tasks --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_linked_tasks.py | 234 ++++++++++++++++++++------------------ 1 file changed, 121 insertions(+), 113 deletions(-) diff --git a/test/test_linked_tasks.py b/test/test_linked_tasks.py index 8ea5226d7..288cd2fd7 100644 --- a/test/test_linked_tasks.py +++ b/test/test_linked_tasks.py @@ -1,5 +1,4 @@ from pathlib import Path -import unittest from defusedxml.ElementTree import fromstring import pytest @@ -15,115 +14,124 @@ RUN_LINKED_TASK_NOW = asset_dir / "linked_tasks_run_now.xml" -class TestLinkedTasks(unittest.TestCase): - def setUp(self) -> None: - self.server = TSC.Server("http://test", False) - self.server.version = "3.15" - - # Fake signin - self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - - self.baseurl = self.server.linked_tasks.baseurl - - def test_parse_linked_task_flow_run(self): - xml = fromstring(GET_LINKED_TASKS.read_bytes()) - task_runs = LinkedTaskFlowRunItem._parse_element(xml, self.server.namespace) - assert 1 == len(task_runs) - task = task_runs[0] - assert task.flow_run_id == "e3d1fc25-5644-4e32-af35-58dcbd1dbd73" - assert task.flow_run_priority == 1 - assert task.flow_run_consecutive_failed_count == 3 - assert task.flow_run_task_type == "runFlow" - assert task.flow_id == "ab1231eb-b8ca-461e-a131-83f3c2b6a673" - assert task.flow_name == "flow-name" - - def test_parse_linked_task_step(self): - xml = fromstring(GET_LINKED_TASKS.read_bytes()) - steps = LinkedTaskStepItem.from_task_xml(xml, self.server.namespace) - assert 1 == len(steps) - step = steps[0] - assert step.id == "f554a4df-bb6f-4294-94ee-9a709ef9bda0" - assert step.stop_downstream_on_failure - assert step.step_number == 1 - assert 1 == len(step.task_details) - task = step.task_details[0] - assert task.flow_run_id == "e3d1fc25-5644-4e32-af35-58dcbd1dbd73" - assert task.flow_run_priority == 1 - assert task.flow_run_consecutive_failed_count == 3 - assert task.flow_run_task_type == "runFlow" - assert task.flow_id == "ab1231eb-b8ca-461e-a131-83f3c2b6a673" - assert task.flow_name == "flow-name" - - def test_parse_linked_task(self): - tasks = LinkedTaskItem.from_response(GET_LINKED_TASKS.read_bytes(), self.server.namespace) - assert 1 == len(tasks) - task = tasks[0] - assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" - assert task.num_steps == 1 - assert task.schedule is not None - assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" - - def test_get_linked_tasks(self): - with requests_mock.mock() as m: - m.get(self.baseurl, text=GET_LINKED_TASKS.read_text()) - tasks, pagination_item = self.server.linked_tasks.get() - - assert 1 == len(tasks) - task = tasks[0] - assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" - assert task.num_steps == 1 - assert task.schedule is not None - assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" - - def test_get_by_id_str_linked_task(self): - id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" - - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{id_}", text=GET_LINKED_TASKS.read_text()) - task = self.server.linked_tasks.get_by_id(id_) - - assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" - assert task.num_steps == 1 - assert task.schedule is not None - assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" - - def test_get_by_id_obj_linked_task(self): - id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" - in_task = LinkedTaskItem() - in_task.id = id_ - - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{id_}", text=GET_LINKED_TASKS.read_text()) - task = self.server.linked_tasks.get_by_id(in_task) - - assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" - assert task.num_steps == 1 - assert task.schedule is not None - assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" - - def test_run_now_str_linked_task(self): - id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" - - with requests_mock.mock() as m: - m.post(f"{self.baseurl}/{id_}/runNow", text=RUN_LINKED_TASK_NOW.read_text()) - job = self.server.linked_tasks.run_now(id_) - - assert job.id == "269a1e5a-1220-4a13-ac01-704982693dd8" - assert job.status == "InProgress" - assert job.created_at == parse_datetime("2022-02-15T00:22:22Z") - assert job.linked_task_id == id_ - - def test_run_now_obj_linked_task(self): - id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" - in_task = LinkedTaskItem() - in_task.id = id_ - - with requests_mock.mock() as m: - m.post(f"{self.baseurl}/{id_}/runNow", text=RUN_LINKED_TASK_NOW.read_text()) - job = self.server.linked_tasks.run_now(in_task) - - assert job.id == "269a1e5a-1220-4a13-ac01-704982693dd8" - assert job.status == "InProgress" - assert job.created_at == parse_datetime("2022-02-15T00:22:22Z") - assert job.linked_task_id == id_ +@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" + server.version = "3.15" + + return server + + +def test_parse_linked_task_flow_run(server: TSC.Server) -> None: + xml = fromstring(GET_LINKED_TASKS.read_bytes()) + task_runs = LinkedTaskFlowRunItem._parse_element(xml, server.namespace) + assert 1 == len(task_runs) + task = task_runs[0] + assert task.flow_run_id == "e3d1fc25-5644-4e32-af35-58dcbd1dbd73" + assert task.flow_run_priority == 1 + assert task.flow_run_consecutive_failed_count == 3 + assert task.flow_run_task_type == "runFlow" + assert task.flow_id == "ab1231eb-b8ca-461e-a131-83f3c2b6a673" + assert task.flow_name == "flow-name" + + +def test_parse_linked_task_step(server: TSC.Server) -> None: + xml = fromstring(GET_LINKED_TASKS.read_bytes()) + steps = LinkedTaskStepItem.from_task_xml(xml, server.namespace) + assert 1 == len(steps) + step = steps[0] + assert step.id == "f554a4df-bb6f-4294-94ee-9a709ef9bda0" + assert step.stop_downstream_on_failure + assert step.step_number == 1 + assert 1 == len(step.task_details) + task = step.task_details[0] + assert task.flow_run_id == "e3d1fc25-5644-4e32-af35-58dcbd1dbd73" + assert task.flow_run_priority == 1 + assert task.flow_run_consecutive_failed_count == 3 + assert task.flow_run_task_type == "runFlow" + assert task.flow_id == "ab1231eb-b8ca-461e-a131-83f3c2b6a673" + assert task.flow_name == "flow-name" + + +def test_parse_linked_task(server: TSC.Server) -> None: + tasks = LinkedTaskItem.from_response(GET_LINKED_TASKS.read_bytes(), server.namespace) + assert 1 == len(tasks) + task = tasks[0] + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + +def test_get_linked_tasks(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(server.linked_tasks.baseurl, text=GET_LINKED_TASKS.read_text()) + tasks, pagination_item = server.linked_tasks.get() + + assert 1 == len(tasks) + task = tasks[0] + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + +def test_get_by_id_str_linked_task(server: TSC.Server) -> None: + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + + with requests_mock.mock() as m: + m.get(f"{server.linked_tasks.baseurl}/{id_}", text=GET_LINKED_TASKS.read_text()) + task = server.linked_tasks.get_by_id(id_) + + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + +def test_get_by_id_obj_linked_task(server: TSC.Server) -> None: + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + in_task = LinkedTaskItem() + in_task.id = id_ + + with requests_mock.mock() as m: + m.get(f"{server.linked_tasks.baseurl}/{id_}", text=GET_LINKED_TASKS.read_text()) + task = server.linked_tasks.get_by_id(in_task) + + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + +def test_run_now_str_linked_task(server: TSC.Server) -> None: + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + + with requests_mock.mock() as m: + m.post(f"{server.linked_tasks.baseurl}/{id_}/runNow", text=RUN_LINKED_TASK_NOW.read_text()) + job = server.linked_tasks.run_now(id_) + + assert job.id == "269a1e5a-1220-4a13-ac01-704982693dd8" + assert job.status == "InProgress" + assert job.created_at == parse_datetime("2022-02-15T00:22:22Z") + assert job.linked_task_id == id_ + + +def test_run_now_obj_linked_task(server: TSC.Server) -> None: + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + in_task = LinkedTaskItem() + in_task.id = id_ + + with requests_mock.mock() as m: + m.post(f"{server.linked_tasks.baseurl}/{id_}/runNow", text=RUN_LINKED_TASK_NOW.read_text()) + job = server.linked_tasks.run_now(in_task) + + assert job.id == "269a1e5a-1220-4a13-ac01-704982693dd8" + assert job.status == "InProgress" + assert job.created_at == parse_datetime("2022-02-15T00:22:22Z") + assert job.linked_task_id == id_ From af27931197b9ac3f8ef96738ffdab9387854e3a6 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 17:05:40 -0600 Subject: [PATCH 57/98] chore: pytestify metadata (#1688) * fix: black ci errors * chore: pytestify metadata --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_metadata.py | 166 ++++++++++++++++++++++-------------------- 1 file changed, 87 insertions(+), 79 deletions(-) diff --git a/test/test_metadata.py b/test/test_metadata.py index 1dc9cf1c6..cf3e6ad4a 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -1,21 +1,22 @@ import json -import os.path +from pathlib import Path import unittest +import pytest import requests_mock import tableauserverclient as TSC from tableauserverclient.server.endpoint.exceptions import GraphQLError -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +TEST_ASSET_DIR = Path(__file__).parent / "assets" -METADATA_QUERY_SUCCESS = os.path.join(TEST_ASSET_DIR, "metadata_query_success.json") -METADATA_QUERY_ERROR = os.path.join(TEST_ASSET_DIR, "metadata_query_error.json") -EXPECTED_PAGED_DICT = os.path.join(TEST_ASSET_DIR, "metadata_query_expected_dict.dict") +METADATA_QUERY_SUCCESS = TEST_ASSET_DIR / "metadata_query_success.json" +METADATA_QUERY_ERROR = TEST_ASSET_DIR / "metadata_query_error.json" +EXPECTED_PAGED_DICT = TEST_ASSET_DIR / "metadata_query_expected_dict.dict" -METADATA_PAGE_1 = os.path.join(TEST_ASSET_DIR, "metadata_paged_1.json") -METADATA_PAGE_2 = os.path.join(TEST_ASSET_DIR, "metadata_paged_2.json") -METADATA_PAGE_3 = os.path.join(TEST_ASSET_DIR, "metadata_paged_3.json") +METADATA_PAGE_1 = TEST_ASSET_DIR / "metadata_paged_1.json" +METADATA_PAGE_2 = TEST_ASSET_DIR / "metadata_paged_2.json" +METADATA_PAGE_3 = TEST_ASSET_DIR / "metadata_paged_3.json" EXPECTED_DICT = { "publishedDatasources": [ @@ -29,74 +30,81 @@ EXPECTED_DICT_ERROR = [{"message": "Reached time limit of PT5S for query execution.", "path": None, "extensions": None}] -class MetadataTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server("http://test", False) - self.baseurl = self.server.metadata.baseurl - self.server.version = "3.5" - - self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - - def test_metadata_query(self): - with open(METADATA_QUERY_SUCCESS, "rb") as f: - response_json = json.loads(f.read().decode()) - with requests_mock.mock() as m: - m.post(self.baseurl, json=response_json) - actual = self.server.metadata.query("fake query") - - datasources = actual["data"] - - self.assertDictEqual(EXPECTED_DICT, datasources) - - def test_paged_metadata_query(self): - with open(EXPECTED_PAGED_DICT, "rb") as f: - expected = eval(f.read()) - - # prepare the 3 pages of results - with open(METADATA_PAGE_1, "rb") as f: - result_1 = f.read().decode() - with open(METADATA_PAGE_2, "rb") as f: - result_2 = f.read().decode() - with open(METADATA_PAGE_3, "rb") as f: - result_3 = f.read().decode() - - with requests_mock.mock() as m: - m.post( - self.baseurl, - [ - {"text": result_1, "status_code": 200}, - {"text": result_2, "status_code": 200}, - {"text": result_3, "status_code": 200}, - ], - ) - - # validation checks for endCursor and hasNextPage, - # but the query text doesn't matter for the test - actual = self.server.metadata.paginated_query( - "fake query endCursor hasNextPage", variables={"first": 1, "afterToken": None} - ) - - self.assertDictEqual(expected, actual) - - def test_metadata_query_ignore_error(self): - with open(METADATA_QUERY_ERROR, "rb") as f: - response_json = json.loads(f.read().decode()) - with requests_mock.mock() as m: - m.post(self.baseurl, json=response_json) - actual = self.server.metadata.query("fake query") - datasources = actual["data"] - - self.assertNotEqual(actual.get("errors", None), None) - self.assertListEqual(EXPECTED_DICT_ERROR, actual["errors"]) - self.assertDictEqual(EXPECTED_DICT, datasources) - - def test_metadata_query_abort_on_error(self): - with open(METADATA_QUERY_ERROR, "rb") as f: - response_json = json.loads(f.read().decode()) - with requests_mock.mock() as m: - m.post(self.baseurl, json=response_json) - - with self.assertRaises(GraphQLError) as e: - self.server.metadata.query("fake query", abort_on_error=True) - self.assertListEqual(e.error, EXPECTED_DICT_ERROR) +@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" + server.version = "3.5" + + return server + + +def test_metadata_query(server: TSC.Server) -> None: + with open(METADATA_QUERY_SUCCESS, "rb") as f: + response_json = json.loads(f.read().decode()) + with requests_mock.mock() as m: + m.post(server.metadata.baseurl, json=response_json) + actual = server.metadata.query("fake query") + + datasources = actual["data"] + + assert EXPECTED_DICT == datasources + + +def test_paged_metadata_query(server: TSC.Server) -> None: + with open(EXPECTED_PAGED_DICT, "rb") as f: + expected = eval(f.read()) + + # prepare the 3 pages of results + with open(METADATA_PAGE_1, "rb") as f: + result_1 = f.read().decode() + with open(METADATA_PAGE_2, "rb") as f: + result_2 = f.read().decode() + with open(METADATA_PAGE_3, "rb") as f: + result_3 = f.read().decode() + + with requests_mock.mock() as m: + m.post( + server.metadata.baseurl, + [ + {"text": result_1, "status_code": 200}, + {"text": result_2, "status_code": 200}, + {"text": result_3, "status_code": 200}, + ], + ) + + # validation checks for endCursor and hasNextPage, + # but the query text doesn't matter for the test + actual = server.metadata.paginated_query( + "fake query endCursor hasNextPage", variables={"first": 1, "afterToken": None} + ) + + assert expected == actual + + +def test_metadata_query_ignore_error(server: TSC.Server) -> None: + with open(METADATA_QUERY_ERROR, "rb") as f: + response_json = json.loads(f.read().decode()) + with requests_mock.mock() as m: + m.post(server.metadata.baseurl, json=response_json) + actual = server.metadata.query("fake query") + datasources = actual["data"] + + assert actual.get("errors", None) is not None + assert EXPECTED_DICT_ERROR == actual["errors"] + assert EXPECTED_DICT == datasources + + +def test_metadata_query_abort_on_error(server: TSC.Server) -> None: + with open(METADATA_QUERY_ERROR, "rb") as f: + response_json = json.loads(f.read().decode()) + with requests_mock.mock() as m: + m.post(server.metadata.baseurl, json=response_json) + + with pytest.raises(GraphQLError) as e: + server.metadata.query("fake query", abort_on_error=True) + assert e.error == EXPECTED_DICT_ERROR # type: ignore[attr-defined] From 27dedc4f2129c4b59952f7092ace0d4c4538bf87 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 17:07:55 -0600 Subject: [PATCH 58/98] chore: pytestify pager (#1690) * fix: black ci errors * chore: pytestify pager --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_pager.py | 228 +++++++++++++++++++++++---------------------- 1 file changed, 115 insertions(+), 113 deletions(-) diff --git a/test/test_pager.py b/test/test_pager.py index 1836095bb..0a7ccf00a 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -1,19 +1,32 @@ import contextlib import os -import unittest +from pathlib import Path import xml.etree.ElementTree as ET +import pytest import requests_mock import tableauserverclient as TSC from tableauserverclient.config import config -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +TEST_ASSET_DIR = Path(__file__).parent / "assets" -GET_VIEW_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml") -GET_XML_PAGE1 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_1.xml") -GET_XML_PAGE2 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_2.xml") -GET_XML_PAGE3 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_3.xml") +GET_VIEW_XML = TEST_ASSET_DIR / "view_get.xml" +GET_XML_PAGE1 = TEST_ASSET_DIR / "workbook_get_page_1.xml" +GET_XML_PAGE2 = TEST_ASSET_DIR / "workbook_get_page_2.xml" +GET_XML_PAGE3 = TEST_ASSET_DIR / "workbook_get_page_3.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 @contextlib.contextmanager @@ -27,110 +40,99 @@ def set_env(**environ): os.environ.update(old_environ) -class PagerTests(unittest.TestCase): - def setUp(self): - 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_pager_with_no_options(self) -> None: - with open(GET_XML_PAGE1, "rb") as f: - page_1 = f.read().decode("utf-8") - with open(GET_XML_PAGE2, "rb") as f: - page_2 = f.read().decode("utf-8") - with open(GET_XML_PAGE3, "rb") as f: - page_3 = f.read().decode("utf-8") - with requests_mock.mock() as m: - # Register Pager with default request options - m.get(self.baseurl, text=page_1) - - # Register Pager with some pages - m.get(self.baseurl + "?pageNumber=1&pageSize=1", text=page_1) - m.get(self.baseurl + "?pageNumber=2&pageSize=1", text=page_2) - m.get(self.baseurl + "?pageNumber=3&pageSize=1", text=page_3) - - # No options should get all 3 - workbooks = list(TSC.Pager(self.server.workbooks)) - self.assertTrue(len(workbooks) == 3) - - # Let's check that workbook items aren't duplicates - wb1, wb2, wb3 = workbooks - self.assertEqual(wb1.name, "Page1Workbook") - self.assertEqual(wb2.name, "Page2Workbook") - self.assertEqual(wb3.name, "Page3Workbook") - - def test_pager_with_options(self) -> None: - with open(GET_XML_PAGE1, "rb") as f: - page_1 = f.read().decode("utf-8") - with open(GET_XML_PAGE2, "rb") as f: - page_2 = f.read().decode("utf-8") - with open(GET_XML_PAGE3, "rb") as f: - page_3 = f.read().decode("utf-8") - with requests_mock.mock() as m: - # Register Pager with some pages - m.get(self.baseurl + "?pageNumber=1&pageSize=1", complete_qs=True, text=page_1) - m.get(self.baseurl + "?pageNumber=2&pageSize=1", complete_qs=True, text=page_2) - m.get(self.baseurl + "?pageNumber=3&pageSize=1", complete_qs=True, text=page_3) - m.get(self.baseurl + "?pageNumber=1&pageSize=3", complete_qs=True, text=page_1) - - # Starting on page 2 should get 2 out of 3 - opts = TSC.RequestOptions(2, 1) - workbooks = list(TSC.Pager(self.server.workbooks, opts)) - self.assertTrue(len(workbooks) == 2) - - # Check that the workbooks are the 2 we think they should be - wb2, wb3 = workbooks - self.assertEqual(wb2.name, "Page2Workbook") - self.assertEqual(wb3.name, "Page3Workbook") - - # Starting on 1 with pagesize of 3 should get all 3 - opts = TSC.RequestOptions(1, 3) - workbooks = list(TSC.Pager(self.server.workbooks, opts)) - self.assertTrue(len(workbooks) == 3) - wb1, wb2, wb3 = workbooks - self.assertEqual(wb1.name, "Page1Workbook") - self.assertEqual(wb2.name, "Page2Workbook") - self.assertEqual(wb3.name, "Page3Workbook") - - # Starting on 3 with pagesize of 1 should get the last item - opts = TSC.RequestOptions(3, 1) - workbooks = list(TSC.Pager(self.server.workbooks, opts)) - self.assertTrue(len(workbooks) == 1) - # Should have the last workbook - wb3 = workbooks.pop() - self.assertEqual(wb3.name, "Page3Workbook") - - def test_pager_with_env_var(self) -> None: - with set_env(TSC_PAGE_SIZE="1000"): - assert config.PAGE_SIZE == 1000 - loop = TSC.Pager(self.server.workbooks) - assert loop._options.pagesize == 1000 - - def test_queryset_with_env_var(self) -> None: - with set_env(TSC_PAGE_SIZE="1000"): - assert config.PAGE_SIZE == 1000 - loop = self.server.workbooks.all() - assert loop.request_options.pagesize == 1000 - - def test_pager_view(self) -> None: - with open(GET_VIEW_XML, "rb") as f: - view_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.server.views.baseurl, text=view_xml) - for view in TSC.Pager(self.server.views): - assert view.name is not None - - def test_queryset_no_matches(self) -> None: - elem = ET.Element("tsResponse", xmlns="http://tableau.com/api") - ET.SubElement(elem, "pagination", totalAvailable="0") - ET.SubElement(elem, "groups") - xml = ET.tostring(elem).decode("utf-8") - with requests_mock.mock() as m: - m.get(self.server.groups.baseurl, text=xml) - all_groups = self.server.groups.all() - groups = list(all_groups) - assert len(groups) == 0 +def test_pager_with_no_options(server: TSC.Server) -> None: + page_1 = GET_XML_PAGE1.read_text() + page_2 = GET_XML_PAGE2.read_text() + page_3 = GET_XML_PAGE3.read_text() + with requests_mock.mock() as m: + # Register Pager with default request options + m.get(server.workbooks.baseurl, text=page_1) + + # Register Pager with some pages + m.get(server.workbooks.baseurl + "?pageNumber=1&pageSize=1", text=page_1) + m.get(server.workbooks.baseurl + "?pageNumber=2&pageSize=1", text=page_2) + m.get(server.workbooks.baseurl + "?pageNumber=3&pageSize=1", text=page_3) + + # No options should get all 3 + workbooks = list(TSC.Pager(server.workbooks)) + assert len(workbooks) == 3 + + # Let's check that workbook items aren't duplicates + wb1, wb2, wb3 = workbooks + assert wb1.name == "Page1Workbook" + assert wb2.name == "Page2Workbook" + assert wb3.name == "Page3Workbook" + + +def test_pager_with_options(server: TSC.Server) -> None: + page_1 = GET_XML_PAGE1.read_text() + page_2 = GET_XML_PAGE2.read_text() + page_3 = GET_XML_PAGE3.read_text() + with requests_mock.mock() as m: + # Register Pager with some pages + m.get(server.workbooks.baseurl + "?pageNumber=1&pageSize=1", complete_qs=True, text=page_1) + m.get(server.workbooks.baseurl + "?pageNumber=2&pageSize=1", complete_qs=True, text=page_2) + m.get(server.workbooks.baseurl + "?pageNumber=3&pageSize=1", complete_qs=True, text=page_3) + m.get(server.workbooks.baseurl + "?pageNumber=1&pageSize=3", complete_qs=True, text=page_1) + + # Starting on page 2 should get 2 out of 3 + opts = TSC.RequestOptions(2, 1) + workbooks = list(TSC.Pager(server.workbooks, opts)) + assert len(workbooks) == 2 + + # Check that the workbooks are the 2 we think they should be + wb2, wb3 = workbooks + assert wb2.name == "Page2Workbook" + assert wb3.name == "Page3Workbook" + + # Starting on 1 with pagesize of 3 should get all 3 + opts = TSC.RequestOptions(1, 3) + workbooks = list(TSC.Pager(server.workbooks, opts)) + assert len(workbooks) == 3 + wb1, wb2, wb3 = workbooks + assert wb1.name == "Page1Workbook" + assert wb2.name == "Page2Workbook" + assert wb3.name == "Page3Workbook" + + # Starting on 3 with pagesize of 1 should get the last item + opts = TSC.RequestOptions(3, 1) + workbooks = list(TSC.Pager(server.workbooks, opts)) + assert len(workbooks) == 1 + # Should have the last workbook + wb3 = workbooks.pop() + assert wb3.name == "Page3Workbook" + + +def test_pager_with_env_var(server: TSC.Server) -> None: + with set_env(TSC_PAGE_SIZE="1000"): + assert config.PAGE_SIZE == 1000 + loop = TSC.Pager(server.workbooks) + assert loop._options.pagesize == 1000 + + +def test_queryset_with_env_var(server: TSC.Server) -> None: + with set_env(TSC_PAGE_SIZE="1000"): + assert config.PAGE_SIZE == 1000 + loop = server.workbooks.all() + assert loop.request_options.pagesize == 1000 + + +def test_pager_view(server: TSC.Server) -> None: + with open(GET_VIEW_XML, "rb") as f: + view_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(server.views.baseurl, text=view_xml) + for view in TSC.Pager(server.views): + assert view.name is not None + + +def test_queryset_no_matches(server: TSC.Server) -> None: + elem = ET.Element("tsResponse", xmlns="http://tableau.com/api") + ET.SubElement(elem, "pagination", totalAvailable="0") + ET.SubElement(elem, "groups") + xml = ET.tostring(elem).decode("utf-8") + with requests_mock.mock() as m: + m.get(server.groups.baseurl, text=xml) + all_groups = server.groups.all() + groups = list(all_groups) + assert len(groups) == 0 From 455e208110987b968a77dc4b344dac137316c033 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 17:08:44 -0600 Subject: [PATCH 59/98] chore: pytestify permission_rule (#1691) * fix: black ci errors * chore: pytestify permission_rule --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_permissionsrule.py | 184 +++++++++++++++++------------------ 1 file changed, 92 insertions(+), 92 deletions(-) diff --git a/test/test_permissionsrule.py b/test/test_permissionsrule.py index d7bceb258..3d016057e 100644 --- a/test/test_permissionsrule.py +++ b/test/test_permissionsrule.py @@ -1,104 +1,104 @@ -import unittest - import tableauserverclient as TSC from tableauserverclient.models.reference_item import ResourceReference -class TestPermissionsRules(unittest.TestCase): - def test_and(self): - grantee = ResourceReference("a", "user") - rule1 = TSC.PermissionsRule( - grantee, - { - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, - }, - ) - rule2 = TSC.PermissionsRule( - grantee, - { - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, - }, - ) +def test_and() -> None: + grantee = ResourceReference("a", "user") + rule1 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + rule2 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + + composite = rule1 & rule2 + + assert composite.capabilities.get(TSC.Permission.Capability.ExportData) == TSC.Permission.Mode.Allow + assert composite.capabilities.get(TSC.Permission.Capability.Delete) == TSC.Permission.Mode.Deny + assert composite.capabilities.get(TSC.Permission.Capability.ViewComments) == None + assert composite.capabilities.get(TSC.Permission.Capability.ExportXml) == TSC.Permission.Mode.Deny + - composite = rule1 & rule2 +def test_or() -> None: + grantee = ResourceReference("a", "user") + rule1 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + rule2 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) - self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportData), TSC.Permission.Mode.Allow) - self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.Delete), TSC.Permission.Mode.Deny) - self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ViewComments), None) - self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportXml), TSC.Permission.Mode.Deny) + composite = rule1 | rule2 - def test_or(self): - grantee = ResourceReference("a", "user") - rule1 = TSC.PermissionsRule( - grantee, - { - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, - }, - ) - rule2 = TSC.PermissionsRule( - grantee, - { - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, - }, - ) + assert composite.capabilities.get(TSC.Permission.Capability.ExportData) == TSC.Permission.Mode.Allow + assert composite.capabilities.get(TSC.Permission.Capability.Delete) == TSC.Permission.Mode.Allow + assert composite.capabilities.get(TSC.Permission.Capability.ViewComments) == TSC.Permission.Mode.Allow + assert composite.capabilities.get(TSC.Permission.Capability.ExportXml) == TSC.Permission.Mode.Deny - composite = rule1 | rule2 - self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportData), TSC.Permission.Mode.Allow) - self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.Delete), TSC.Permission.Mode.Allow) - self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ViewComments), TSC.Permission.Mode.Allow) - self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportXml), TSC.Permission.Mode.Deny) +def test_eq_false() -> None: + grantee = ResourceReference("a", "user") + rule1 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + rule2 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) - def test_eq_false(self): - grantee = ResourceReference("a", "user") - rule1 = TSC.PermissionsRule( - grantee, - { - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, - }, - ) - rule2 = TSC.PermissionsRule( - grantee, - { - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, - }, - ) + assert rule1 != rule2 - self.assertNotEqual(rule1, rule2) - def test_eq_true(self): - grantee = ResourceReference("a", "user") - rule1 = TSC.PermissionsRule( - grantee, - { - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, - }, - ) - rule2 = TSC.PermissionsRule( - grantee, - { - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, - }, - ) - self.assertEqual(rule1, rule2) +def test_eq_true() -> None: + grantee = ResourceReference("a", "user") + rule1 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + rule2 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + assert rule1 == rule2 From ea10537b108e8c810720ee9d3bcb2137f3619cae Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 17:10:23 -0600 Subject: [PATCH 60/98] chore: pytestify project (#1692) * fix: black ci errors * chore: pytestify project --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_project.py | 837 +++++++++++++++++++------------------ test/test_project_model.py | 31 +- 2 files changed, 439 insertions(+), 429 deletions(-) diff --git a/test/test_project.py b/test/test_project.py index c51f2e1e6..f2cfab5d1 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -1,438 +1,447 @@ -import os -import unittest +from pathlib import Path +import pytest import requests_mock import tableauserverclient as TSC from tableauserverclient import GroupItem -from ._utils import read_xml_asset, asset - -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") - -GET_XML = asset("project_get.xml") -GET_XML_ALL_FIELDS = asset("project_get_all_fields.xml") -UPDATE_XML = asset("project_update.xml") -SET_CONTENT_PERMISSIONS_XML = asset("project_content_permission.xml") -CREATE_XML = asset("project_create.xml") -POPULATE_PERMISSIONS_XML = "project_populate_permissions.xml" -POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML = "project_populate_workbook_default_permissions.xml" -UPDATE_DATASOURCE_DEFAULT_PERMISSIONS_XML = "project_update_datasource_default_permissions.xml" -POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML = "project_populate_virtualconnection_default_permissions.xml" -UPDATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML = "project_update_virtualconnection_default_permissions.xml" - - -class ProjectTests(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.projects.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_projects, pagination_item = self.server.projects.get() - - self.assertEqual(3, pagination_item.total_available) - self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", all_projects[0].id) - self.assertEqual("default", all_projects[0].name) - self.assertEqual("The default project that was automatically created by Tableau.", all_projects[0].description) - self.assertEqual("ManagedByOwner", all_projects[0].content_permissions) - self.assertEqual(None, all_projects[0].parent_id) - self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", all_projects[0].owner_id) - - self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", all_projects[1].id) - self.assertEqual("Tableau", all_projects[1].name) - self.assertEqual("ManagedByOwner", all_projects[1].content_permissions) - self.assertEqual(None, all_projects[1].parent_id) - self.assertEqual("2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3", all_projects[1].owner_id) - - self.assertEqual("4cc52973-5e3a-4d1f-a4fb-5b5f73796edf", all_projects[2].id) - self.assertEqual("Tableau > Child 1", all_projects[2].name) - self.assertEqual("ManagedByOwner", all_projects[2].content_permissions) - self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", all_projects[2].parent_id) - self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", all_projects[2].owner_id) - - def test_get_before_signin(self) -> None: - self.server._auth_token = None - self.assertRaises(TSC.NotSignedInError, self.server.projects.get) - - def test_delete(self) -> None: - with requests_mock.mock() as m: - m.delete(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) - self.server.projects.delete("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - - def test_delete_missing_id(self) -> None: - self.assertRaises(ValueError, self.server.projects.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 + "/1d0304cd-3796-429f-b815-7258370b9b74", text=response_xml) - single_project = TSC.ProjectItem( - name="Test Project", - content_permissions="LockedToProject", - description="Project created for testing", - parent_id="9a8f2265-70f3-4494-96c5-e5949d7a1120", - ) - single_project._id = "1d0304cd-3796-429f-b815-7258370b9b74" - single_project.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - single_project = self.server.projects.update(single_project) - - self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", single_project.id) - self.assertEqual("Test Project", single_project.name) - self.assertEqual("Project created for testing", single_project.description) - self.assertEqual("LockedToProject", single_project.content_permissions) - self.assertEqual("9a8f2265-70f3-4494-96c5-e5949d7a1120", single_project.parent_id) - self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", single_project.owner_id) - - def test_content_permission_locked_to_project_without_nested(self) -> None: - with open(SET_CONTENT_PERMISSIONS_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.put(self.baseurl + "/cb3759e5-da4a-4ade-b916-7e2b4ea7ec86", text=response_xml) - project_item = TSC.ProjectItem( - name="Test Project Permissions", - content_permissions="LockedToProjectWithoutNested", - description="Project created for testing", - parent_id="7687bc43-a543-42f3-b86f-80caed03a813", - ) - project_item._id = "cb3759e5-da4a-4ade-b916-7e2b4ea7ec86" - project_item = self.server.projects.update(project_item) - self.assertEqual("cb3759e5-da4a-4ade-b916-7e2b4ea7ec86", project_item.id) - self.assertEqual("Test Project Permissions", project_item.name) - self.assertEqual("Project created for testing", project_item.description) - self.assertEqual("LockedToProjectWithoutNested", project_item.content_permissions) - self.assertEqual("7687bc43-a543-42f3-b86f-80caed03a813", project_item.parent_id) - - def test_update_datasource_default_permission(self) -> None: - response_xml = read_xml_asset(UPDATE_DATASOURCE_DEFAULT_PERMISSIONS_XML) - with requests_mock.mock() as m: - m.put( - self.baseurl + "/b4065286-80f0-11ea-af1b-cb7191f48e45/default-permissions/datasources", - text=response_xml, - ) - project = TSC.ProjectItem("test-project") - project._id = "b4065286-80f0-11ea-af1b-cb7191f48e45" - - group = TSC.GroupItem("test-group") - group._id = "b4488bce-80f0-11ea-af1c-976d0c1dab39" - - capabilities = {TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny} - - rules = [TSC.PermissionsRule(grantee=GroupItem.as_reference(group._id), capabilities=capabilities)] - - new_rules = self.server.projects.update_datasource_default_permissions(project, rules) - - self.assertEqual("b4488bce-80f0-11ea-af1c-976d0c1dab39", new_rules[0].grantee.id) - - updated_capabilities = new_rules[0].capabilities - self.assertEqual(4, len(updated_capabilities)) - self.assertEqual("Deny", updated_capabilities["ExportXml"]) - self.assertEqual("Allow", updated_capabilities["Read"]) - self.assertEqual("Allow", updated_capabilities["Write"]) - self.assertEqual("Allow", updated_capabilities["Connect"]) - - def test_update_missing_id(self) -> None: - single_project = TSC.ProjectItem("test") - self.assertRaises(TSC.MissingRequiredFieldError, self.server.projects.update, single_project) - - def test_create(self) -> None: - with open(CREATE_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_project = TSC.ProjectItem(name="Test Project", description="Project created for testing") - new_project.content_permissions = "ManagedByOwner" - new_project.parent_id = "9a8f2265-70f3-4494-96c5-e5949d7a1120" - new_project = self.server.projects.create(new_project) - - self.assertEqual("ccbea03f-77c4-4209-8774-f67bc59c3cef", new_project.id) - self.assertEqual("Test Project", new_project.name) - self.assertEqual("Project created for testing", new_project.description) - self.assertEqual("ManagedByOwner", new_project.content_permissions) - self.assertEqual("9a8f2265-70f3-4494-96c5-e5949d7a1120", new_project.parent_id) - - def test_create_missing_name(self) -> None: - TSC.ProjectItem() - - 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_project = TSC.ProjectItem("Project3") - single_project._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" - - self.server.projects.populate_permissions(single_project) - permissions = single_project.permissions - - self.assertEqual(permissions[0].grantee.tag_name, "group") - self.assertEqual(permissions[0].grantee.id, "c8f2773a-c83a-11e8-8c8f-33e6d787b506") - self.assertDictEqual( - permissions[0].capabilities, - { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - }, - ) - - def test_populate_workbooks(self) -> None: - response_xml = read_xml_asset(POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML) - with requests_mock.mock() as m: - m.get( - self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks", text=response_xml - ) - single_project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") - single_project.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - single_project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - - self.server.projects.populate_workbook_default_permissions(single_project) - permissions = single_project.default_workbook_permissions - - rule1 = permissions.pop() - - self.assertEqual("c8f2773a-c83a-11e8-8c8f-33e6d787b506", rule1.grantee.id) - self.assertEqual("group", rule1.grantee.tag_name) - self.assertDictEqual( - rule1.capabilities, - { - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ViewUnderlyingData: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, - }, + +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +GET_XML = TEST_ASSET_DIR / "project_get.xml" +GET_XML_ALL_FIELDS = TEST_ASSET_DIR / "project_get_all_fields.xml" +UPDATE_XML = TEST_ASSET_DIR / "project_update.xml" +SET_CONTENT_PERMISSIONS_XML = TEST_ASSET_DIR / "project_content_permission.xml" +CREATE_XML = TEST_ASSET_DIR / "project_create.xml" +POPULATE_PERMISSIONS_XML = TEST_ASSET_DIR / "project_populate_permissions.xml" +POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML = TEST_ASSET_DIR / "project_populate_workbook_default_permissions.xml" +UPDATE_DATASOURCE_DEFAULT_PERMISSIONS_XML = TEST_ASSET_DIR / "project_update_datasource_default_permissions.xml" +POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML = ( + TEST_ASSET_DIR / "project_populate_virtualconnection_default_permissions.xml" +) +UPDATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML = ( + TEST_ASSET_DIR / "project_update_virtualconnection_default_permissions.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: TSC.Server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.projects.baseurl, text=response_xml) + all_projects, pagination_item = server.projects.get() + + assert 3 == pagination_item.total_available + assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == all_projects[0].id + assert "default" == all_projects[0].name + assert "The default project that was automatically created by Tableau." == all_projects[0].description + assert "ManagedByOwner" == all_projects[0].content_permissions + assert None == all_projects[0].parent_id + assert "dd2239f6-ddf1-4107-981a-4cf94e415794" == all_projects[0].owner_id + + assert "1d0304cd-3796-429f-b815-7258370b9b74" == all_projects[1].id + assert "Tableau" == all_projects[1].name + assert "ManagedByOwner" == all_projects[1].content_permissions + assert None == all_projects[1].parent_id + assert "2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3" == all_projects[1].owner_id + + assert "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf" == all_projects[2].id + assert "Tableau > Child 1" == all_projects[2].name + assert "ManagedByOwner" == all_projects[2].content_permissions + assert "1d0304cd-3796-429f-b815-7258370b9b74" == all_projects[2].parent_id + assert "dd2239f6-ddf1-4107-981a-4cf94e415794" == all_projects[2].owner_id + + +def test_get_before_signin(server: TSC.Server) -> None: + server._auth_token = None + with pytest.raises(TSC.NotSignedInError): + server.projects.get() + + +def test_delete(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.delete(server.projects.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) + server.projects.delete("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + + +def test_delete_missing_id(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.projects.delete("") + + +def test_update(server: TSC.Server) -> None: + response_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.put(server.projects.baseurl + "/1d0304cd-3796-429f-b815-7258370b9b74", text=response_xml) + single_project = TSC.ProjectItem( + name="Test Project", + content_permissions="LockedToProject", + description="Project created for testing", + parent_id="9a8f2265-70f3-4494-96c5-e5949d7a1120", + ) + single_project._id = "1d0304cd-3796-429f-b815-7258370b9b74" + single_project.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_project = server.projects.update(single_project) + + assert "1d0304cd-3796-429f-b815-7258370b9b74" == single_project.id + assert "Test Project" == single_project.name + assert "Project created for testing" == single_project.description + assert "LockedToProject" == single_project.content_permissions + assert "9a8f2265-70f3-4494-96c5-e5949d7a1120" == single_project.parent_id + assert "dd2239f6-ddf1-4107-981a-4cf94e415794" == single_project.owner_id + + +def test_content_permission_locked_to_project_without_nested(server: TSC.Server) -> None: + response_xml = SET_CONTENT_PERMISSIONS_XML.read_text() + with requests_mock.mock() as m: + m.put(server.projects.baseurl + "/cb3759e5-da4a-4ade-b916-7e2b4ea7ec86", text=response_xml) + project_item = TSC.ProjectItem( + name="Test Project Permissions", + content_permissions="LockedToProjectWithoutNested", + description="Project created for testing", + parent_id="7687bc43-a543-42f3-b86f-80caed03a813", + ) + project_item._id = "cb3759e5-da4a-4ade-b916-7e2b4ea7ec86" + project_item = server.projects.update(project_item) + assert "cb3759e5-da4a-4ade-b916-7e2b4ea7ec86" == project_item.id + assert "Test Project Permissions" == project_item.name + assert "Project created for testing" == project_item.description + assert "LockedToProjectWithoutNested" == project_item.content_permissions + assert "7687bc43-a543-42f3-b86f-80caed03a813" == project_item.parent_id + + +def test_update_datasource_default_permission(server: TSC.Server) -> None: + response_xml = UPDATE_DATASOURCE_DEFAULT_PERMISSIONS_XML.read_text() + with requests_mock.mock() as m: + m.put( + server.projects.baseurl + "/b4065286-80f0-11ea-af1b-cb7191f48e45/default-permissions/datasources", + text=response_xml, + ) + project = TSC.ProjectItem("test-project") + project._id = "b4065286-80f0-11ea-af1b-cb7191f48e45" + + group = TSC.GroupItem("test-group") + group._id = "b4488bce-80f0-11ea-af1c-976d0c1dab39" + + capabilities = {TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny} + + rules = [TSC.PermissionsRule(grantee=GroupItem.as_reference(group._id), capabilities=capabilities)] + + new_rules = server.projects.update_datasource_default_permissions(project, rules) + + assert "b4488bce-80f0-11ea-af1c-976d0c1dab39" == new_rules[0].grantee.id + + updated_capabilities = new_rules[0].capabilities + assert 4 == len(updated_capabilities) + assert "Deny" == updated_capabilities["ExportXml"] + assert "Allow" == updated_capabilities["Read"] + assert "Allow" == updated_capabilities["Write"] + assert "Allow" == updated_capabilities["Connect"] + + +def test_update_missing_id(server: TSC.Server) -> None: + single_project = TSC.ProjectItem("test") + with pytest.raises(TSC.MissingRequiredFieldError): + server.projects.update(single_project) + + +def test_create(server: TSC.Server) -> None: + response_xml = CREATE_XML.read_text() + with requests_mock.mock() as m: + m.post(server.projects.baseurl, text=response_xml) + new_project = TSC.ProjectItem(name="Test Project", description="Project created for testing") + new_project.content_permissions = "ManagedByOwner" + new_project.parent_id = "9a8f2265-70f3-4494-96c5-e5949d7a1120" + new_project = server.projects.create(new_project) + + assert "ccbea03f-77c4-4209-8774-f67bc59c3cef" == new_project.id + assert "Test Project" == new_project.name + assert "Project created for testing" == new_project.description + assert "ManagedByOwner" == new_project.content_permissions + assert "9a8f2265-70f3-4494-96c5-e5949d7a1120" == new_project.parent_id + + +def test_create_missing_name() -> None: + TSC.ProjectItem() + + +def test_populate_permissions(server: TSC.Server) -> None: + response_xml = POPULATE_PERMISSIONS_XML.read_text() + with requests_mock.mock() as m: + m.get(server.projects.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions", text=response_xml) + single_project = TSC.ProjectItem("Project3") + single_project._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" + + server.projects.populate_permissions(single_project) + permissions = single_project.permissions + + assert permissions[0].grantee.tag_name == "group" + assert permissions[0].grantee.id == "c8f2773a-c83a-11e8-8c8f-33e6d787b506" + assert permissions[0].capabilities == { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + } + + +def test_populate_workbooks(server: TSC.Server) -> None: + response_xml = POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML.read_text() + with requests_mock.mock() as m: + m.get( + server.projects.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks", + text=response_xml, + ) + single_project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") + single_project.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + + server.projects.populate_workbook_default_permissions(single_project) + permissions = single_project.default_workbook_permissions + + rule1 = permissions.pop() + + assert "c8f2773a-c83a-11e8-8c8f-33e6d787b506" == rule1.grantee.id + assert "group" == rule1.grantee.tag_name + assert rule1.capabilities == { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ViewUnderlyingData: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, + } + + +def test_delete_permission(server: TSC.Server) -> None: + response_xml = POPULATE_PERMISSIONS_XML.read_text() + with requests_mock.mock() as m: + m.get(server.projects.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5/permissions", text=response_xml) + + single_group = TSC.GroupItem("Group1") + single_group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" + + single_project = TSC.ProjectItem("Project3") + single_project._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" + + server.projects.populate_permissions(single_project) + permissions = single_project.permissions + + capabilities = {} + + for permission in permissions: + if permission.grantee.tag_name == "group": + if permission.grantee.id == single_group._id: + capabilities = permission.capabilities + + rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) + + endpoint = f"{single_project._id}/permissions/groups/{single_group._id}" + m.delete(f"{server.projects.baseurl}/{endpoint}/Read/Allow", status_code=204) + m.delete(f"{server.projects.baseurl}/{endpoint}/Write/Allow", status_code=204) + server.projects.delete_permission(item=single_project, rules=rules) + + +def test_delete_workbook_default_permission(server: TSC.Server) -> None: + response_xml = POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML.read_text() + + with requests_mock.mock() as m: + m.get( + server.projects.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks", + text=response_xml, ) - def test_delete_permission(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_group = TSC.GroupItem("Group1") - single_group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" - - single_project = TSC.ProjectItem("Project3") - single_project._id = "0448d2ed-590d-4fa0-b272-a2a8a24555b5" - - self.server.projects.populate_permissions(single_project) - permissions = single_project.permissions - - capabilities = {} - - for permission in permissions: - if permission.grantee.tag_name == "group": - if permission.grantee.id == single_group._id: - capabilities = permission.capabilities - - rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - - endpoint = f"{single_project._id}/permissions/groups/{single_group._id}" - m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) - self.server.projects.delete_permission(item=single_project, rules=rules) - - def test_delete_workbook_default_permission(self) -> None: - with open(asset(POPULATE_WORKBOOK_DEFAULT_PERMISSIONS_XML), "rb") as f: - response_xml = f.read().decode("utf-8") - - with requests_mock.mock() as m: - m.get( - self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks", text=response_xml - ) - - single_group = TSC.GroupItem("Group1") - single_group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" - - single_project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") - single_project._owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - single_project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - - self.server.projects.populate_workbook_default_permissions(single_project) - permissions = single_project.default_workbook_permissions - - capabilities = { - # View - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, - # Interact/Edit - TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ViewUnderlyingData: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, - # Edit - TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow, - } - - rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) - - endpoint = f"{single_project._id}/default-permissions/workbooks/groups/{single_group._id}" - m.delete(f"{self.baseurl}/{endpoint}/Read/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ExportImage/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ExportData/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ViewComments/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/AddComment/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/Filter/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ViewUnderlyingData/Deny", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ShareView/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/WebAuthoring/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/Write/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ExportXml/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ChangeHierarchy/Allow", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/Delete/Deny", status_code=204) - m.delete(f"{self.baseurl}/{endpoint}/ChangePermissions/Allow", status_code=204) - self.server.projects.delete_workbook_default_permissions(item=single_project, rule=rules) - - def test_populate_virtualconnection_default_permissions(self): - response_xml = read_xml_asset(POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML) - - self.server.version = "3.23" - base_url = self.server.projects.baseurl - - with requests_mock.mock() as m: - m.get( - base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections", - text=response_xml, - ) - project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") - project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - - self.server.projects.populate_virtualconnection_default_permissions(project) - permissions = project.default_virtualconnection_permissions - - rule = permissions.pop() - - self.assertEqual("c8f2773a-c83a-11e8-8c8f-33e6d787b506", rule.grantee.id) - self.assertEqual("group", rule.grantee.tag_name) - self.assertDictEqual( - rule.capabilities, - { - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, - }, + single_group = TSC.GroupItem("Group1") + single_group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" + + single_project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") + single_project._owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + + server.projects.populate_workbook_default_permissions(single_project) + permissions = single_project.default_workbook_permissions + + capabilities = { + # View + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, + # Interact/Edit + TSC.Permission.Capability.Filter: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ViewUnderlyingData: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ShareView: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.WebAuthoring: TSC.Permission.Mode.Allow, + # Edit + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Allow, + } + + rules = TSC.PermissionsRule(grantee=GroupItem.as_reference(single_group._id), capabilities=capabilities) + + endpoint = f"{single_project._id}/default-permissions/workbooks/groups/{single_group._id}" + m.delete(f"{server.projects.baseurl}/{endpoint}/Read/Allow", status_code=204) + m.delete(f"{server.projects.baseurl}/{endpoint}/ExportImage/Allow", status_code=204) + m.delete(f"{server.projects.baseurl}/{endpoint}/ExportData/Allow", status_code=204) + m.delete(f"{server.projects.baseurl}/{endpoint}/ViewComments/Allow", status_code=204) + m.delete(f"{server.projects.baseurl}/{endpoint}/AddComment/Allow", status_code=204) + m.delete(f"{server.projects.baseurl}/{endpoint}/Filter/Allow", status_code=204) + m.delete(f"{server.projects.baseurl}/{endpoint}/ViewUnderlyingData/Deny", status_code=204) + m.delete(f"{server.projects.baseurl}/{endpoint}/ShareView/Allow", status_code=204) + m.delete(f"{server.projects.baseurl}/{endpoint}/WebAuthoring/Allow", status_code=204) + m.delete(f"{server.projects.baseurl}/{endpoint}/Write/Allow", status_code=204) + m.delete(f"{server.projects.baseurl}/{endpoint}/ExportXml/Allow", status_code=204) + m.delete(f"{server.projects.baseurl}/{endpoint}/ChangeHierarchy/Allow", status_code=204) + m.delete(f"{server.projects.baseurl}/{endpoint}/Delete/Deny", status_code=204) + m.delete(f"{server.projects.baseurl}/{endpoint}/ChangePermissions/Allow", status_code=204) + server.projects.delete_workbook_default_permissions(item=single_project, rule=rules) + + +def test_populate_virtualconnection_default_permissions(server: TSC.Server) -> None: + response_xml = POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML.read_text() + + server.version = "3.23" + base_url = server.projects.baseurl + + with requests_mock.mock() as m: + m.get( + base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections", + text=response_xml, ) + project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") + project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + + server.projects.populate_virtualconnection_default_permissions(project) + permissions = project.default_virtualconnection_permissions + + rule = permissions.pop() + + assert "c8f2773a-c83a-11e8-8c8f-33e6d787b506" == rule.grantee.id + assert "group" == rule.grantee.tag_name + assert rule.capabilities == { + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ChangePermissions: TSC.Permission.Mode.Deny, + } + + +def test_update_virtualconnection_default_permissions(server: TSC.Server) -> None: + response_xml = UPDATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML.read_text() - def test_update_virtualconnection_default_permissions(self): - response_xml = read_xml_asset(UPDATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML) - - self.server.version = "3.23" - base_url = self.server.projects.baseurl - - with requests_mock.mock() as m: - m.put( - base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections", - text=response_xml, - ) - project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") - project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - - group = TSC.GroupItem("test-group") - group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" - - capabilities = { - TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny, - } - - rules = [TSC.PermissionsRule(GroupItem.as_reference(group.id), capabilities)] - new_rules = self.server.projects.update_virtualconnection_default_permissions(project, rules) - - rule = new_rules.pop() - - self.assertEqual(group.id, rule.grantee.id) - self.assertEqual("group", rule.grantee.tag_name) - self.assertDictEqual( - rule.capabilities, - { - TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny, - }, + server.version = "3.23" + base_url = server.projects.baseurl + + with requests_mock.mock() as m: + m.put( + base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections", + text=response_xml, ) + project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") + project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + + group = TSC.GroupItem("test-group") + group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" + + capabilities = { + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny, + } + + assert group.id is not None + rules = [TSC.PermissionsRule(GroupItem.as_reference(group.id), capabilities)] + new_rules = server.projects.update_virtualconnection_default_permissions(project, rules) + + rule = new_rules.pop() - def test_delete_virtualconnection_default_permimssions(self): - response_xml = read_xml_asset(POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML) + assert group.id == rule.grantee.id + assert "group" == rule.grantee.tag_name + assert rule.capabilities == { + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny, + } - self.server.version = "3.23" - base_url = self.server.projects.baseurl - with requests_mock.mock() as m: - m.get( - base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections", - text=response_xml, - ) +def test_delete_virtualconnection_default_permimssions(server: TSC.Server) -> None: + response_xml = POPULATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML.read_text() + + server.version = "3.23" + base_url = server.projects.baseurl + + with requests_mock.mock() as m: + m.get( + base_url + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/virtualConnections", + text=response_xml, + ) + + project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") + project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" - project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") - project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + group = TSC.GroupItem("test-group") + group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" - group = TSC.GroupItem("test-group") - group._id = "c8f2773a-c83a-11e8-8c8f-33e6d787b506" + server.projects.populate_virtualconnection_default_permissions(project) + permissions = project.default_virtualconnection_permissions - self.server.projects.populate_virtualconnection_default_permissions(project) - permissions = project.default_virtualconnection_permissions + del_caps = { + TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow, + } - del_caps = { - TSC.Permission.Capability.ChangeHierarchy: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.Connect: TSC.Permission.Mode.Allow, - } + assert group.id is not None + rule = TSC.PermissionsRule(GroupItem.as_reference(group.id), del_caps) - rule = TSC.PermissionsRule(GroupItem.as_reference(group.id), del_caps) + endpoint = f"{project.id}/default-permissions/virtualConnections/groups/{group.id}" + m.delete(f"{base_url}/{endpoint}/ChangeHierarchy/Deny", status_code=204) + m.delete(f"{base_url}/{endpoint}/Connect/Allow", status_code=204) - endpoint = f"{project.id}/default-permissions/virtualConnections/groups/{group.id}" - m.delete(f"{base_url}/{endpoint}/ChangeHierarchy/Deny", status_code=204) - m.delete(f"{base_url}/{endpoint}/Connect/Allow", status_code=204) + server.projects.delete_virtualconnection_default_permissions(project, rule) - self.server.projects.delete_virtualconnection_default_permissions(project, rule) - def test_get_all_fields(self) -> None: - self.server.version = "3.23" - base_url = self.server.projects.baseurl - with open(GET_XML_ALL_FIELDS, "rb") as f: - response_xml = f.read().decode("utf-8") +def test_get_all_fields(server: TSC.Server) -> None: + server.version = "3.23" + base_url = server.projects.baseurl + response_xml = GET_XML_ALL_FIELDS.read_text() - ro = TSC.RequestOptions() - ro.all_fields = True + ro = TSC.RequestOptions() + ro.all_fields = True - with requests_mock.mock() as m: - m.get(f"{base_url}?fields=_all_", text=response_xml) - all_projects, pagination_item = self.server.projects.get(req_options=ro) + with requests_mock.mock() as m: + m.get(f"{base_url}?fields=_all_", text=response_xml) + all_projects, pagination_item = server.projects.get(req_options=ro) - assert pagination_item.total_available == 3 - assert len(all_projects) == 1 - project: TSC.ProjectItem = all_projects[0] - assert isinstance(project, TSC.ProjectItem) - assert project.id == "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - assert project.name == "Samples" - assert project.description == "This project includes automatically uploaded samples." - assert project.top_level_project is True - assert project.content_permissions == "ManagedByOwner" - assert project.parent_id is None - assert project.writeable is True + assert pagination_item.total_available == 3 + assert len(all_projects) == 1 + project: TSC.ProjectItem = all_projects[0] + assert isinstance(project, TSC.ProjectItem) + assert project.id == "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" + assert project.name == "Samples" + assert project.description == "This project includes automatically uploaded samples." + assert project.top_level_project is True + assert project.content_permissions == "ManagedByOwner" + assert project.parent_id is None + assert project.writeable is True diff --git a/test/test_project_model.py b/test/test_project_model.py index ecfe1bd14..b51a218ec 100644 --- a/test/test_project_model.py +++ b/test/test_project_model.py @@ -1,21 +1,22 @@ -import unittest +import pytest import tableauserverclient as TSC -class ProjectModelTests(unittest.TestCase): - def test_nullable_name(self): - TSC.ProjectItem(None) - TSC.ProjectItem("") - project = TSC.ProjectItem("proj") - project.name = None +def test_nullable_name(): + TSC.ProjectItem(None) + TSC.ProjectItem("") + project = TSC.ProjectItem("proj") + project.name = None - def test_invalid_content_permissions(self): - project = TSC.ProjectItem("proj") - with self.assertRaises(ValueError): - project.content_permissions = "Hello" - def test_parent_id(self): - project = TSC.ProjectItem("proj") - project.parent_id = "foo" - self.assertEqual(project.parent_id, "foo") +def test_invalid_content_permissions(): + project = TSC.ProjectItem("proj") + with pytest.raises(ValueError): + project.content_permissions = "Hello" + + +def test_parent_id(): + project = TSC.ProjectItem("proj") + project.parent_id = "foo" + assert project.parent_id == "foo" From 2882019996df237bce2904289f3f43fcdff862fd Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 17:11:07 -0600 Subject: [PATCH 61/98] chore: pytestify regressions (#1693) * fix: black ci errors * chore: pytestify regressions --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_regression_tests.py | 153 +++++++++++++++++----------------- 1 file changed, 77 insertions(+), 76 deletions(-) diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 62e301591..21fbf3848 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -1,4 +1,3 @@ -import unittest from unittest import mock import tableauserverclient.server.request_factory as factory @@ -6,78 +5,80 @@ from tableauserverclient.filesys_helpers import to_filename, make_download_path -class BugFix257(unittest.TestCase): - def test_empty_request_works(self): - result = factory.EmptyRequest().empty_req() - self.assertEqual(b"", result) - - -class FileSysHelpers(unittest.TestCase): - def test_to_filename(self): - invalid = [ - "23brhafbjrjhkbbea.txt", - "a_b_C.txt", - "windows space.txt", - "abc#def.txt", - "t@bL3A()", - ] - - valid = [ - "23brhafbjrjhkbbea.txt", - "a_b_C.txt", - "windows space.txt", - "abcdef.txt", - "tbL3A", - ] - - self.assertTrue(all([(to_filename(i) == v) for i, v in zip(invalid, valid)])) - - def test_make_download_path(self): - no_file_path = (None, "file.ext") - has_file_path_folder = ("/root/folder/", "file.ext") - has_file_path_file = ("outx", "file.ext") - - self.assertEqual("file.ext", make_download_path(*no_file_path)) - self.assertEqual("outx.ext", make_download_path(*has_file_path_file)) - - with mock.patch("os.path.isdir") as mocked_isdir: - mocked_isdir.return_value = True - self.assertEqual("/root/folder/file.ext", make_download_path(*has_file_path_folder)) - - -class LoggingTest(unittest.TestCase): - def test_redact_password_string(self): - redacted = redact_xml( - "this is password: my_super_secret_passphrase_which_nobody_should_ever_see password: value" - ) - assert redacted.find("value") == -1 - assert redacted.find("secret") == -1 - assert redacted.find("ever_see") == -1 - assert redacted.find("my_super_secret_passphrase_which_nobody_should_ever_see") == -1 - - def test_redact_password_bytes(self): - redacted = redact_xml( - b"" - ) - assert redacted.find(b"value") == -1 - assert redacted.find(b"secret") == -1 - - def test_redact_password_with_special_char(self): - redacted = redact_xml( - " " - ) - assert redacted.find("my_s per_secre>_passphrase_which_nobody_should_ever_see with password: value") == -1 - - def test_redact_password_not_xml(self): - redacted = redact_xml( - " " - ) - assert redacted.find("my_s per_secre>_passphrase_which_nobody_should_ever_see") == -1 - - def test_redact_password_really_not_xml(self): - redacted = redact_xml( - "value='this is a nondescript text line which is public' password='my_s per_secre>_passphrase_which_nobody_should_ever_see with password: value and then a cookie " - ) - assert redacted.find("my_s per_secre>_passphrase_which_nobody_should_ever_see") == -1 - assert redacted.find("passphrase") == -1, redacted - assert redacted.find("cookie") == -1, redacted +def test_empty_request_works(): + result = factory.EmptyRequest().empty_req() + assert b"" == result + + +def test_to_filename(): + invalid = [ + "23brhafbjrjhkbbea.txt", + "a_b_C.txt", + "windows space.txt", + "abc#def.txt", + "t@bL3A()", + ] + + valid = [ + "23brhafbjrjhkbbea.txt", + "a_b_C.txt", + "windows space.txt", + "abcdef.txt", + "tbL3A", + ] + + assert all([(to_filename(i) == v) for i, v in zip(invalid, valid)]) + + +def test_make_download_path(): + no_file_path = (None, "file.ext") + has_file_path_folder = ("/root/folder/", "file.ext") + has_file_path_file = ("outx", "file.ext") + + assert "file.ext" == make_download_path(*no_file_path) + assert "outx.ext" == make_download_path(*has_file_path_file) + + with mock.patch("os.path.isdir") as mocked_isdir: + mocked_isdir.return_value = True + assert "/root/folder/file.ext" == make_download_path(*has_file_path_folder) + + +def test_redact_password_string(): + redacted = redact_xml( + "this is password: my_super_secret_passphrase_which_nobody_should_ever_see password: value" + ) + assert redacted.find("value") == -1 + assert redacted.find("secret") == -1 + assert redacted.find("ever_see") == -1 + assert redacted.find("my_super_secret_passphrase_which_nobody_should_ever_see") == -1 + + +def test_redact_password_bytes(): + redacted = redact_xml( + b"" + ) + assert redacted.find(b"value") == -1 + assert redacted.find(b"secret") == -1 + + +def test_redact_password_with_special_char(): + redacted = redact_xml( + " " + ) + assert redacted.find("my_s per_secre>_passphrase_which_nobody_should_ever_see with password: value") == -1 + + +def test_redact_password_not_xml(): + redacted = redact_xml( + " " + ) + assert redacted.find("my_s per_secre>_passphrase_which_nobody_should_ever_see") == -1 + + +def test_redact_password_really_not_xml(): + redacted = redact_xml( + "value='this is a nondescript text line which is public' password='my_s per_secre>_passphrase_which_nobody_should_ever_see with password: value and then a cookie " + ) + assert redacted.find("my_s per_secre>_passphrase_which_nobody_should_ever_see") == -1 + assert redacted.find("passphrase") == -1, redacted + assert redacted.find("cookie") == -1, redacted From 0e652ed7ae38f25c679d29b9e0a218427626f5f2 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 17:15:19 -0600 Subject: [PATCH 62/98] chore: pytestify oidc (#1689) * fix: black ci errors * chore: pytestify oidc --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_oidc.py | 288 +++++++++++++++++++++++----------------------- 1 file changed, 147 insertions(+), 141 deletions(-) diff --git a/test/test_oidc.py b/test/test_oidc.py index 4c3187355..476d902a1 100644 --- a/test/test_oidc.py +++ b/test/test_oidc.py @@ -1,7 +1,8 @@ -import unittest import requests_mock from pathlib import Path +import pytest + import tableauserverclient as TSC assets = Path(__file__).parent / "assets" @@ -11,143 +12,148 @@ 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" +@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" + server.version = "3.24" + + return server + + +def test_oidc_get_by_id(server: TSC.Server) -> None: + luid = "6561daa3-20e8-407f-ba09-709b178c0b4a" + with requests_mock.mock() as m: + m.get(f"{server.oidc.baseurl}/{luid}", text=OIDC_GET.read_text()) + oidc = 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(server: TSC.Server) -> None: + luid = "6561daa3-20e8-407f-ba09-709b178c0b4a" + with requests_mock.mock() as m: + m.put(f"{server.baseurl}/sites/{server.site_id}/disable-site-oidc-configuration") + 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(server: TSC.Server) -> 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"{server.oidc.baseurl}/{luid}", text=OIDC_UPDATE.read_text()) + oidc = 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(server: TSC.Server) -> 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(server.oidc.baseurl, text=OIDC_CREATE.read_text()) + oidc = 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 d61ff8f4cb321076eaa37b0104be68bd94cd1f1c Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 13:03:00 -0600 Subject: [PATCH 63/98] fix: black ci errors (#1713) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> From f3f1ddfac23140e2c7a01a4b8a587a34b979b5df Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:19:15 -0600 Subject: [PATCH 64/98] chore: pytestify users (#1717) * fix: black ci errors * chore: pytestify test_user * chore: pytestify test_user_model --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_user.py | 637 ++++++++++++++++++++-------------------- test/test_user_model.py | 247 ++++++++-------- 2 files changed, 454 insertions(+), 430 deletions(-) diff --git a/test/test_user.py b/test/test_user.py index fa2ac3a12..f2e778bc3 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,322 +1,335 @@ -import os -import unittest +from pathlib import Path from defusedxml import ElementTree as ET +import pytest import requests_mock import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime, parse_datetime -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") - -GET_XML = os.path.join(TEST_ASSET_DIR, "user_get.xml") -GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "user_get_all_fields.xml") -GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "user_get_empty.xml") -GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "user_get_by_id.xml") -UPDATE_XML = os.path.join(TEST_ASSET_DIR, "user_update.xml") -ADD_XML = os.path.join(TEST_ASSET_DIR, "user_add.xml") -POPULATE_WORKBOOKS_XML = os.path.join(TEST_ASSET_DIR, "user_populate_workbooks.xml") -GET_FAVORITES_XML = os.path.join(TEST_ASSET_DIR, "favorites_get.xml") -POPULATE_GROUPS_XML = os.path.join(TEST_ASSET_DIR, "user_populate_groups.xml") - -USERNAMES = os.path.join(TEST_ASSET_DIR, "Data", "usernames.csv") -USERS = os.path.join(TEST_ASSET_DIR, "Data", "user_details.csv") - - -class UserTests(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.users.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 + "?fields=_all_", text=response_xml) - all_users, pagination_item = self.server.users.get() - - self.assertEqual(2, pagination_item.total_available) - self.assertEqual(2, len(all_users)) - - self.assertTrue(any(user.id == "dd2239f6-ddf1-4107-981a-4cf94e415794" for user in all_users)) - single_user = next(user for user in all_users if user.id == "dd2239f6-ddf1-4107-981a-4cf94e415794") - self.assertEqual("alice", single_user.name) - self.assertEqual("Publisher", single_user.site_role) - self.assertEqual("2016-08-16T23:17:06Z", format_datetime(single_user.last_login)) - self.assertEqual("alice cook", single_user.fullname) - self.assertEqual("alicecook@test.com", single_user.email) - - self.assertTrue(any(user.id == "2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3" for user in all_users)) - single_user = next(user for user in all_users if user.id == "2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3") - self.assertEqual("Bob", single_user.name) - self.assertEqual("Interactor", single_user.site_role) - self.assertEqual("Bob Smith", single_user.fullname) - self.assertEqual("bob@test.com", single_user.email) - - 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_users, pagination_item = self.server.users.get() - - self.assertEqual(0, pagination_item.total_available) - self.assertEqual([], all_users) - - def test_get_before_signin(self) -> None: - self.server._auth_token = None - self.assertRaises(TSC.NotSignedInError, self.server.users.get) - - 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 + "/dd2239f6-ddf1-4107-981a-4cf94e415794", text=response_xml) - single_user = self.server.users.get_by_id("dd2239f6-ddf1-4107-981a-4cf94e415794") - - self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", single_user.id) - self.assertEqual("alice", single_user.name) - self.assertEqual("Alice", single_user.fullname) - self.assertEqual("Publisher", single_user.site_role) - self.assertEqual("ServerDefault", single_user.auth_setting) - self.assertEqual("2016-08-16T23:17:06Z", format_datetime(single_user.last_login)) - self.assertEqual("local", single_user.domain_name) - - def test_get_by_id_missing_id(self) -> None: - self.assertRaises(ValueError, self.server.users.get_by_id, "") - - 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 + "/dd2239f6-ddf1-4107-981a-4cf94e415794", text=response_xml) - single_user = TSC.UserItem("test", "Viewer") - single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - single_user.name = "Cassie" - single_user.fullname = "Cassie" - single_user.email = "cassie@email.com" - single_user = self.server.users.update(single_user) - - self.assertEqual("Cassie", single_user.name) - self.assertEqual("Cassie", single_user.fullname) - self.assertEqual("cassie@email.com", single_user.email) - self.assertEqual("Viewer", single_user.site_role) - - def test_update_missing_id(self) -> None: +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +GET_XML = TEST_ASSET_DIR / "user_get.xml" +GET_XML_ALL_FIELDS = TEST_ASSET_DIR / "user_get_all_fields.xml" +GET_EMPTY_XML = TEST_ASSET_DIR / "user_get_empty.xml" +GET_BY_ID_XML = TEST_ASSET_DIR / "user_get_by_id.xml" +UPDATE_XML = TEST_ASSET_DIR / "user_update.xml" +ADD_XML = TEST_ASSET_DIR / "user_add.xml" +POPULATE_WORKBOOKS_XML = TEST_ASSET_DIR / "user_populate_workbooks.xml" +GET_FAVORITES_XML = TEST_ASSET_DIR / "favorites_get.xml" +POPULATE_GROUPS_XML = TEST_ASSET_DIR / "user_populate_groups.xml" + +USERNAMES = TEST_ASSET_DIR / "Data" / "usernames.csv" +USERS = TEST_ASSET_DIR / "Data" / "user_details.csv" + + +@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: TSC.Server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.users.baseurl + "?fields=_all_", text=response_xml) + all_users, pagination_item = server.users.get() + + assert 2 == pagination_item.total_available + assert 2 == len(all_users) + + assert any(user.id == "dd2239f6-ddf1-4107-981a-4cf94e415794" for user in all_users) + single_user = next(user for user in all_users if user.id == "dd2239f6-ddf1-4107-981a-4cf94e415794") + assert "alice" == single_user.name + assert "Publisher" == single_user.site_role + assert "2016-08-16T23:17:06Z" == format_datetime(single_user.last_login) + assert "alice cook" == single_user.fullname + assert "alicecook@test.com" == single_user.email + + assert any(user.id == "2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3" for user in all_users) + single_user = next(user for user in all_users if user.id == "2a47bbf8-8900-4ebb-b0a4-2723bd7c46c3") + assert "Bob" == single_user.name + assert "Interactor" == single_user.site_role + assert "Bob Smith" == single_user.fullname + assert "bob@test.com" == single_user.email + + +def test_get_empty(server: TSC.Server) -> None: + response_xml = GET_EMPTY_XML.read_text() + with requests_mock.mock() as m: + m.get(server.users.baseurl, text=response_xml) + all_users, pagination_item = server.users.get() + + assert 0 == pagination_item.total_available + assert [] == all_users + + +def test_get_before_signin(server: TSC.Server) -> None: + server._auth_token = None + with pytest.raises(TSC.NotSignedInError): + server.users.get() + + +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.users.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", text=response_xml) + single_user = server.users.get_by_id("dd2239f6-ddf1-4107-981a-4cf94e415794") + + assert "dd2239f6-ddf1-4107-981a-4cf94e415794" == single_user.id + assert "alice" == single_user.name + assert "Alice" == single_user.fullname + assert "Publisher" == single_user.site_role + assert "ServerDefault" == single_user.auth_setting + assert "2016-08-16T23:17:06Z" == format_datetime(single_user.last_login) + assert "local" == single_user.domain_name + + +def test_get_by_id_missing_id(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.users.get_by_id("") + + +def test_update(server: TSC.Server) -> None: + response_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.put(server.users.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", text=response_xml) + single_user = TSC.UserItem("test", "Viewer") + single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_user.name = "Cassie" + single_user.fullname = "Cassie" + single_user.email = "cassie@email.com" + single_user = server.users.update(single_user) + + assert "Cassie" == single_user.name + assert "Cassie" == single_user.fullname + assert "cassie@email.com" == single_user.email + assert "Viewer" == single_user.site_role + + +def test_update_missing_id(server: TSC.Server) -> None: + single_user = TSC.UserItem("test", "Interactor") + with pytest.raises(TSC.MissingRequiredFieldError): + server.users.update(single_user) + + +def test_remove(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.delete(server.users.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", status_code=204) + server.users.remove("dd2239f6-ddf1-4107-981a-4cf94e415794") + + +def test_remove_with_replacement(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.delete( + server.users.baseurl + + "/dd2239f6-ddf1-4107-981a-4cf94e415794" + + "?mapAssetsTo=4cc4c17f-898a-4de4-abed-a1681c673ced", + status_code=204, + ) + server.users.remove("dd2239f6-ddf1-4107-981a-4cf94e415794", "4cc4c17f-898a-4de4-abed-a1681c673ced") + + +def test_remove_missing_id(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.users.remove("") + + +def test_add(server: TSC.Server) -> None: + response_xml = ADD_XML.read_text() + with requests_mock.mock() as m: + m.post(server.users.baseurl + "", text=response_xml) + new_user = TSC.UserItem(name="Cassie", site_role="Viewer", auth_setting="ServerDefault") + new_user = server.users.add(new_user) + + assert "4cc4c17f-898a-4de4-abed-a1681c673ced" == new_user.id + assert "Cassie" == new_user.name + assert "Viewer" == new_user.site_role + assert "ServerDefault" == new_user.auth_setting + + +def test_populate_workbooks(server: TSC.Server) -> None: + response_xml = POPULATE_WORKBOOKS_XML.read_text() + with requests_mock.mock() as m: + m.get(server.users.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks", text=response_xml) single_user = TSC.UserItem("test", "Interactor") - self.assertRaises(TSC.MissingRequiredFieldError, self.server.users.update, single_user) - - def test_remove(self) -> None: - with requests_mock.mock() as m: - m.delete(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794", status_code=204) - self.server.users.remove("dd2239f6-ddf1-4107-981a-4cf94e415794") - - def test_remove_with_replacement(self) -> None: - with requests_mock.mock() as m: - m.delete( - self.baseurl - + "/dd2239f6-ddf1-4107-981a-4cf94e415794" - + "?mapAssetsTo=4cc4c17f-898a-4de4-abed-a1681c673ced", - status_code=204, - ) - self.server.users.remove("dd2239f6-ddf1-4107-981a-4cf94e415794", "4cc4c17f-898a-4de4-abed-a1681c673ced") - - def test_remove_missing_id(self) -> None: - self.assertRaises(ValueError, self.server.users.remove, "") - - def test_add(self) -> None: - with open(ADD_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_user = TSC.UserItem(name="Cassie", site_role="Viewer", auth_setting="ServerDefault") - new_user = self.server.users.add(new_user) - - self.assertEqual("4cc4c17f-898a-4de4-abed-a1681c673ced", new_user.id) - self.assertEqual("Cassie", new_user.name) - self.assertEqual("Viewer", new_user.site_role) - self.assertEqual("ServerDefault", new_user.auth_setting) - - def test_populate_workbooks(self) -> None: - with open(POPULATE_WORKBOOKS_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks", text=response_xml) - single_user = TSC.UserItem("test", "Interactor") - single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - self.server.users.populate_workbooks(single_user) - - workbook_list = list(single_user.workbooks) - self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", workbook_list[0].id) - self.assertEqual("SafariSample", workbook_list[0].name) - self.assertEqual("SafariSample", workbook_list[0].content_url) - self.assertEqual(False, workbook_list[0].show_tabs) - self.assertEqual(26, workbook_list[0].size) - self.assertEqual("2016-07-26T20:34:56Z", format_datetime(workbook_list[0].created_at)) - self.assertEqual("2016-07-26T20:35:05Z", format_datetime(workbook_list[0].updated_at)) - self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", workbook_list[0].project_id) - self.assertEqual("default", workbook_list[0].project_name) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", workbook_list[0].owner_id) - self.assertEqual({"Safari", "Sample"}, workbook_list[0].tags) - - def test_populate_owned_workbooks(self) -> None: - with open(POPULATE_WORKBOOKS_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - # Query parameter ownedBy is case sensitive. - with requests_mock.mock(case_sensitive=True) as m: - m.get(self.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks?ownedBy=true", text=response_xml) - single_user = TSC.UserItem("test", "Interactor") - single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - self.server.users.populate_workbooks(single_user, owned_only=True) - list(single_user.workbooks) - - request_history = m.request_history[0] - - assert "ownedBy" in request_history.qs, "ownedBy not in request history" - assert "true" in request_history.qs["ownedBy"], "ownedBy not set to true in request history" - - def test_populate_workbooks_missing_id(self) -> None: + single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + server.users.populate_workbooks(single_user) + + workbook_list = list(single_user.workbooks) + assert "3cc6cd06-89ce-4fdc-b935-5294135d6d42" == workbook_list[0].id + assert "SafariSample" == workbook_list[0].name + assert "SafariSample" == workbook_list[0].content_url + assert False == workbook_list[0].show_tabs + assert 26 == workbook_list[0].size + assert "2016-07-26T20:34:56Z" == format_datetime(workbook_list[0].created_at) + assert "2016-07-26T20:35:05Z" == format_datetime(workbook_list[0].updated_at) + assert "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" == workbook_list[0].project_id + assert "default" == workbook_list[0].project_name + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == workbook_list[0].owner_id + assert {"Safari", "Sample"} == workbook_list[0].tags + + +def test_populate_owned_workbooks(server: TSC.Server) -> None: + response_xml = POPULATE_WORKBOOKS_XML.read_text() + # Query parameter ownedBy is case sensitive. + with requests_mock.mock(case_sensitive=True) as m: + m.get(server.users.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/workbooks?ownedBy=true", text=response_xml) single_user = TSC.UserItem("test", "Interactor") - self.assertRaises(TSC.MissingRequiredFieldError, self.server.users.populate_workbooks, single_user) - - def test_populate_favorites(self) -> None: - self.server.version = "2.5" - baseurl = self.server.favorites.baseurl + single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + server.users.populate_workbooks(single_user, owned_only=True) + list(single_user.workbooks) + + request_history = m.request_history[0] + + assert "ownedBy" in request_history.qs, "ownedBy not in request history" + assert "true" in request_history.qs["ownedBy"], "ownedBy not set to true in request history" + + +def test_populate_workbooks_missing_id(server: TSC.Server) -> None: + single_user = TSC.UserItem("test", "Interactor") + with pytest.raises(TSC.MissingRequiredFieldError): + server.users.populate_workbooks(single_user) + + +def test_populate_favorites(server: TSC.Server) -> None: + server.version = "2.5" + baseurl = server.favorites.baseurl + single_user = TSC.UserItem("test", "Interactor") + response_xml = GET_FAVORITES_XML.read_text() + with requests_mock.mock() as m: + m.get(f"{baseurl}/{single_user.id}", text=response_xml) + server.users.populate_favorites(single_user) + assert single_user._favorites is not None + assert len(single_user.favorites["workbooks"]) == 1 + assert len(single_user.favorites["views"]) == 1 + assert len(single_user.favorites["projects"]) == 1 + assert len(single_user.favorites["datasources"]) == 1 + + workbook = single_user.favorites["workbooks"][0] + view = single_user.favorites["views"][0] + datasource = single_user.favorites["datasources"][0] + project = single_user.favorites["projects"][0] + + assert workbook.id == "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" + assert view.id == "d79634e1-6063-4ec9-95ff-50acbf609ff5" + assert datasource.id == "e76a1461-3b1d-4588-bf1b-17551a879ad9" + assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74" + + +def test_populate_groups(server: TSC.Server) -> None: + server.version = "3.7" + response_xml = POPULATE_GROUPS_XML.read_text() + with requests_mock.mock() as m: + m.get(server.users.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/groups", text=response_xml) single_user = TSC.UserItem("test", "Interactor") - with open(GET_FAVORITES_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(f"{baseurl}/{single_user.id}", text=response_xml) - self.server.users.populate_favorites(single_user) - self.assertIsNotNone(single_user._favorites) - self.assertEqual(len(single_user.favorites["workbooks"]), 1) - self.assertEqual(len(single_user.favorites["views"]), 1) - self.assertEqual(len(single_user.favorites["projects"]), 1) - self.assertEqual(len(single_user.favorites["datasources"]), 1) - - workbook = single_user.favorites["workbooks"][0] - view = single_user.favorites["views"][0] - datasource = single_user.favorites["datasources"][0] - project = single_user.favorites["projects"][0] - - self.assertEqual(workbook.id, "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00") - self.assertEqual(view.id, "d79634e1-6063-4ec9-95ff-50acbf609ff5") - self.assertEqual(datasource.id, "e76a1461-3b1d-4588-bf1b-17551a879ad9") - self.assertEqual(project.id, "1d0304cd-3796-429f-b815-7258370b9b74") - - def test_populate_groups(self) -> None: - self.server.version = "3.7" - with open(POPULATE_GROUPS_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.server.users.baseurl + "/dd2239f6-ddf1-4107-981a-4cf94e415794/groups", text=response_xml) - single_user = TSC.UserItem("test", "Interactor") - single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" - self.server.users.populate_groups(single_user) - - group_list = list(single_user.groups) - - self.assertEqual(3, len(group_list)) - self.assertEqual("ef8b19c0-43b6-11e6-af50-63f5805dbe3c", group_list[0].id) - self.assertEqual("All Users", group_list[0].name) - self.assertEqual("local", group_list[0].domain_name) - - self.assertEqual("e7833b48-c6f7-47b5-a2a7-36e7dd232758", group_list[1].id) - self.assertEqual("Another group", group_list[1].name) - self.assertEqual("local", group_list[1].domain_name) - - self.assertEqual("86a66d40-f289-472a-83d0-927b0f954dc8", group_list[2].id) - self.assertEqual("TableauExample", group_list[2].name) - self.assertEqual("local", group_list[2].domain_name) - - def test_get_usernames_from_file(self): - with open(ADD_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.server.users.baseurl, text=response_xml) - user_list, failures = self.server.users.create_from_file(USERNAMES) - assert user_list[0].name == "Cassie", user_list - assert failures == [], failures - - def test_get_users_from_file(self): - with open(ADD_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.server.users.baseurl, text=response_xml) - users, failures = self.server.users.create_from_file(USERS) - assert users[0].name == "Cassie", users - assert failures == [] - - def test_get_users_all_fields(self) -> None: - self.server.version = "3.7" - baseurl = self.server.users.baseurl - with open(GET_XML_ALL_FIELDS) as f: - response_xml = f.read() - - with requests_mock.mock() as m: - m.get(f"{baseurl}?fields=_all_", text=response_xml) - all_users, _ = self.server.users.get() - - assert all_users[0].auth_setting == "TableauIDWithMFA" - assert all_users[0].email == "bob@example.com" - assert all_users[0].external_auth_user_id == "38c870c3ac5e84ec66e6ced9fb23681835b07e56d5660371ac1f705cc65bd610" - assert all_users[0].fullname == "Bob Smith" - assert all_users[0].id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" - assert all_users[0].last_login == parse_datetime("2025-02-04T06:39:20Z") - assert all_users[0].name == "bob@example.com" - assert all_users[0].site_role == "SiteAdministratorCreator" - assert all_users[0].locale is None - assert all_users[0].language == "en" - assert all_users[0].idp_configuration_id == "22222222-2222-2222-2222-222222222222" - assert all_users[0].domain_name == "TABID_WITH_MFA" - assert all_users[1].auth_setting == "TableauIDWithMFA" - assert all_users[1].email == "alice@example.com" - assert all_users[1].external_auth_user_id == "96f66b893b22669cdfa632275d354cd1d92cea0266f3be7702151b9b8c52be29" - assert all_users[1].fullname == "Alice Jones" - assert all_users[1].id == "f6d72445-285b-48e5-8380-f90b519ce682" - assert all_users[1].name == "alice@example.com" - assert all_users[1].site_role == "ExplorerCanPublish" - assert all_users[1].locale is None - assert all_users[1].language == "en" - assert all_users[1].idp_configuration_id == "22222222-2222-2222-2222-222222222222" - assert all_users[1].domain_name == "TABID_WITH_MFA" - - def test_add_user_idp_configuration(self) -> None: - with open(ADD_XML) as f: - response_xml = f.read() - user = TSC.UserItem(name="Cassie", site_role="Viewer") - user.idp_configuration_id = "012345" - - with requests_mock.mock() as m: - m.post(self.server.users.baseurl, text=response_xml) - user = self.server.users.add(user) - - history = m.request_history[0] - - tree = ET.fromstring(history.text) - user_elem = tree.find(".//user") - assert user_elem is not None - assert user_elem.attrib["idpConfigurationId"] == "012345" - - def test_update_user_idp_configuration(self) -> None: - with open(ADD_XML) as f: - response_xml = f.read() - user = TSC.UserItem(name="Cassie", site_role="Viewer") - user._id = "0123456789" - user.idp_configuration_id = "012345" - - with requests_mock.mock() as m: - m.put(f"{self.server.users.baseurl}/{user.id}", text=response_xml) - user = self.server.users.update(user) - - history = m.request_history[0] - - tree = ET.fromstring(history.text) - user_elem = tree.find(".//user") - assert user_elem is not None - assert user_elem.attrib["idpConfigurationId"] == "012345" + single_user._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + server.users.populate_groups(single_user) + + group_list = list(single_user.groups) + + assert 3 == len(group_list) + assert "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" == group_list[0].id + assert "All Users" == group_list[0].name + assert "local" == group_list[0].domain_name + + assert "e7833b48-c6f7-47b5-a2a7-36e7dd232758" == group_list[1].id + assert "Another group" == group_list[1].name + assert "local" == group_list[1].domain_name + + assert "86a66d40-f289-472a-83d0-927b0f954dc8" == group_list[2].id + assert "TableauExample" == group_list[2].name + assert "local" == group_list[2].domain_name + + +def test_get_usernames_from_file(server: TSC.Server): + response_xml = ADD_XML.read_text() + with requests_mock.mock() as m: + m.post(server.users.baseurl, text=response_xml) + user_list, failures = server.users.create_from_file(str(USERNAMES)) + assert user_list[0].name == "Cassie", user_list + assert failures == [], failures + + +def test_get_users_from_file(server: TSC.Server): + response_xml = ADD_XML.read_text() + with requests_mock.mock() as m: + m.post(server.users.baseurl, text=response_xml) + users, failures = server.users.create_from_file(str(USERS)) + assert users[0].name == "Cassie", users + assert failures == [] + + +def test_get_users_all_fields(server: TSC.Server) -> None: + server.version = "3.7" + baseurl = server.users.baseurl + response_xml = GET_XML_ALL_FIELDS.read_text() + + with requests_mock.mock() as m: + m.get(f"{baseurl}?fields=_all_", text=response_xml) + all_users, _ = server.users.get() + + assert all_users[0].auth_setting == "TableauIDWithMFA" + assert all_users[0].email == "bob@example.com" + assert all_users[0].external_auth_user_id == "38c870c3ac5e84ec66e6ced9fb23681835b07e56d5660371ac1f705cc65bd610" + assert all_users[0].fullname == "Bob Smith" + assert all_users[0].id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert all_users[0].last_login == parse_datetime("2025-02-04T06:39:20Z") + assert all_users[0].name == "bob@example.com" + assert all_users[0].site_role == "SiteAdministratorCreator" + assert all_users[0].locale is None + assert all_users[0].language == "en" + assert all_users[0].idp_configuration_id == "22222222-2222-2222-2222-222222222222" + assert all_users[0].domain_name == "TABID_WITH_MFA" + assert all_users[1].auth_setting == "TableauIDWithMFA" + assert all_users[1].email == "alice@example.com" + assert all_users[1].external_auth_user_id == "96f66b893b22669cdfa632275d354cd1d92cea0266f3be7702151b9b8c52be29" + assert all_users[1].fullname == "Alice Jones" + assert all_users[1].id == "f6d72445-285b-48e5-8380-f90b519ce682" + assert all_users[1].name == "alice@example.com" + assert all_users[1].site_role == "ExplorerCanPublish" + assert all_users[1].locale is None + assert all_users[1].language == "en" + assert all_users[1].idp_configuration_id == "22222222-2222-2222-2222-222222222222" + assert all_users[1].domain_name == "TABID_WITH_MFA" + + +def test_add_user_idp_configuration(server: TSC.Server) -> None: + response_xml = ADD_XML.read_text() + user = TSC.UserItem(name="Cassie", site_role="Viewer") + user.idp_configuration_id = "012345" + + with requests_mock.mock() as m: + m.post(server.users.baseurl, text=response_xml) + user = server.users.add(user) + + history = m.request_history[0] + + tree = ET.fromstring(history.text) + user_elem = tree.find(".//user") + assert user_elem is not None + assert user_elem.attrib["idpConfigurationId"] == "012345" + + +def test_update_user_idp_configuration(server: TSC.Server) -> None: + response_xml = ADD_XML.read_text() + user = TSC.UserItem(name="Cassie", site_role="Viewer") + user._id = "0123456789" + user.idp_configuration_id = "012345" + + with requests_mock.mock() as m: + m.put(f"{server.users.baseurl}/{user.id}", text=response_xml) + user = server.users.update(user) + + history = m.request_history[0] + + tree = ET.fromstring(history.text) + user_elem = tree.find(".//user") + assert user_elem is not None + assert user_elem.attrib["idpConfigurationId"] == "012345" diff --git a/test/test_user_model.py b/test/test_user_model.py index a8a2c51cb..49e8dc25c 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -1,5 +1,4 @@ import logging -import unittest from unittest.mock import * import io @@ -8,120 +7,132 @@ import tableauserverclient as TSC -class UserModelTests(unittest.TestCase): - def test_invalid_auth_setting(self): - user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher) - with self.assertRaises(ValueError): - user.auth_setting = "Hello" - - def test_invalid_site_role(self): - user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher) - with self.assertRaises(ValueError): - user.site_role = "Hello" - - -class UserDataTest(unittest.TestCase): - logger = logging.getLogger("UserDataTest") - - role_inputs = [ - ["creator", "system", "yes", "SiteAdministrator"], - ["None", "system", "no", "SiteAdministrator"], - ["explorer", "SysTEm", "no", "SiteAdministrator"], - ["creator", "site", "yes", "SiteAdministratorCreator"], - ["explorer", "site", "yes", "SiteAdministratorExplorer"], - ["creator", "SITE", "no", "SiteAdministratorCreator"], - ["creator", "none", "yes", "Creator"], - ["explorer", "none", "yes", "ExplorerCanPublish"], - ["viewer", "None", "no", "Viewer"], - ["explorer", "no", "yes", "ExplorerCanPublish"], - ["EXPLORER", "noNO", "yes", "ExplorerCanPublish"], - ["explorer", "no", "no", "Explorer"], - ["unlicensed", "none", "no", "Unlicensed"], - ["Chef", "none", "yes", "Unlicensed"], - ["yes", "yes", "yes", "Unlicensed"], - ] - - valid_import_content = [ - "username, pword, fname, creator, site, yes, email", - "username, pword, fname, explorer, none, no, email", - "", - "u", - "p", - ] - - valid_username_content = ["jfitzgerald@tableau.com"] - - usernames = [ - "valid", - "valid@email.com", - "domain/valid", - "domain/valid@tmail.com", - "va!@#$%^&*()lid", - "in@v@lid", - "in valid", - "", - ] - - def test_validate_usernames(self): - TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[0]) - TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[1]) - TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[2]) - TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[3]) - TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[4]) - with self.assertRaises(AttributeError): - TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[5]) - with self.assertRaises(AttributeError): - TSC.UserItem.validate_username_or_throw(UserDataTest.usernames[6]) - - def test_evaluate_role(self): - for line in UserDataTest.role_inputs: - actual = TSC.UserItem.CSVImport._evaluate_site_role(line[0], line[1], line[2]) - assert actual == line[3], line + [actual] - - def test_get_user_detail_empty_line(self): - test_line = "" - test_user = TSC.UserItem.CSVImport.create_user_from_line(test_line) - assert test_user is None - - def test_get_user_detail_standard(self): - test_line = "username, pword, fname, license, admin, pub, email" - test_user: TSC.UserItem = TSC.UserItem.CSVImport.create_user_from_line(test_line) - assert test_user.name == "username", test_user.name - assert test_user.fullname == "fname", test_user.fullname - assert test_user.site_role == "Unlicensed", test_user.site_role - assert test_user.email == "email", test_user.email - - def test_get_user_details_only_username(self): - test_line = "username" - test_user: TSC.UserItem = TSC.UserItem.CSVImport.create_user_from_line(test_line) - - def test_populate_user_details_only_some(self): - values = "username, , , creator, admin" - user = TSC.UserItem.CSVImport.create_user_from_line(values) - assert user.name == "username" - - def test_validate_user_detail_standard(self): - test_line = "username, pword, fname, creator, site, 1, email" - TSC.UserItem.CSVImport._validate_import_line_or_throw(test_line, UserDataTest.logger) - TSC.UserItem.CSVImport.create_user_from_line(test_line) - - # for file handling - def _mock_file_content(self, content: list[str]) -> io.TextIOWrapper: - # the empty string represents EOF - # the tests run through the file twice, first to validate then to fetch - mock = MagicMock(io.TextIOWrapper) - content.append("") # EOF - mock.readline.side_effect = content - mock.name = "file-mock" - return mock - - def test_validate_import_file(self): - test_data = self._mock_file_content(UserDataTest.valid_import_content) - valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 2, f"Expected two lines to be parsed, got {valid}" - assert invalid == [], f"Expected no failures, got {invalid}" - - def test_validate_usernames_file(self): - test_data = self._mock_file_content(UserDataTest.usernames) - valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, UserDataTest.logger) - assert valid == 5, f"Exactly 5 of the lines were valid, counted {valid + invalid}" +def test_invalid_auth_setting(): + user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher) + with pytest.raises(ValueError): + user.auth_setting = "Hello" + + +def test_invalid_site_role(): + user = TSC.UserItem("me", TSC.UserItem.Roles.Publisher) + with pytest.raises(ValueError): + user.site_role = "Hello" + + +logger = logging.getLogger("UserModelTest") + + +role_inputs = [ + ["creator", "system", "yes", "SiteAdministrator"], + ["None", "system", "no", "SiteAdministrator"], + ["explorer", "SysTEm", "no", "SiteAdministrator"], + ["creator", "site", "yes", "SiteAdministratorCreator"], + ["explorer", "site", "yes", "SiteAdministratorExplorer"], + ["creator", "SITE", "no", "SiteAdministratorCreator"], + ["creator", "none", "yes", "Creator"], + ["explorer", "none", "yes", "ExplorerCanPublish"], + ["viewer", "None", "no", "Viewer"], + ["explorer", "no", "yes", "ExplorerCanPublish"], + ["EXPLORER", "noNO", "yes", "ExplorerCanPublish"], + ["explorer", "no", "no", "Explorer"], + ["unlicensed", "none", "no", "Unlicensed"], + ["Chef", "none", "yes", "Unlicensed"], + ["yes", "yes", "yes", "Unlicensed"], +] + +valid_import_content = [ + "username, pword, fname, creator, site, yes, email", + "username, pword, fname, explorer, none, no, email", + "", + "u", + "p", +] + +valid_username_content = ["jfitzgerald@tableau.com"] + +usernames = [ + "valid", + "valid@email.com", + "domain/valid", + "domain/valid@tmail.com", + "va!@#$%^&*()lid", + "in@v@lid", + "in valid", + "", +] + + +def test_validate_usernames() -> None: + TSC.UserItem.validate_username_or_throw(usernames[0]) + TSC.UserItem.validate_username_or_throw(usernames[1]) + TSC.UserItem.validate_username_or_throw(usernames[2]) + TSC.UserItem.validate_username_or_throw(usernames[3]) + TSC.UserItem.validate_username_or_throw(usernames[4]) + with pytest.raises(AttributeError): + TSC.UserItem.validate_username_or_throw(usernames[5]) + with pytest.raises(AttributeError): + TSC.UserItem.validate_username_or_throw(usernames[6]) + + +def test_evaluate_role() -> None: + for line in role_inputs: + actual = TSC.UserItem.CSVImport._evaluate_site_role(line[0], line[1], line[2]) + assert actual == line[3], line + [actual] + + +def test_get_user_detail_empty_line() -> None: + test_line = "" + test_user = TSC.UserItem.CSVImport.create_user_from_line(test_line) + assert test_user is None + + +def test_get_user_detail_standard() -> None: + test_line = "username, pword, fname, license, admin, pub, email" + test_user = TSC.UserItem.CSVImport.create_user_from_line(test_line) + assert test_user is not None + assert test_user.name == "username", test_user.name + assert test_user.fullname == "fname", test_user.fullname + assert test_user.site_role == "Unlicensed", test_user.site_role + assert test_user.email == "email", test_user.email + + +def test_get_user_details_only_username() -> None: + test_line = "username" + test_user = TSC.UserItem.CSVImport.create_user_from_line(test_line) + + +def test_populate_user_details_only_some() -> None: + values = "username, , , creator, admin" + user = TSC.UserItem.CSVImport.create_user_from_line(values) + assert user is not None + assert user.name == "username" + + +def test_validate_user_detail_standard() -> None: + test_line = "username, pword, fname, creator, site, 1, email" + TSC.UserItem.CSVImport._validate_import_line_or_throw(test_line, logger) + TSC.UserItem.CSVImport.create_user_from_line(test_line) + + +# for file handling +def _mock_file_content(content: list[str]) -> io.TextIOWrapper: + # the empty string represents EOF + # the tests run through the file twice, first to validate then to fetch + mock = MagicMock(io.TextIOWrapper) + content.append("") # EOF + mock.readline.side_effect = content + mock.name = "file-mock" + return mock + + +def test_validate_import_file() -> None: + test_data = _mock_file_content(valid_import_content) + valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, logger) + assert valid == 2, f"Expected two lines to be parsed, got {valid}" + assert invalid == [], f"Expected no failures, got {invalid}" + + +def test_validate_usernames_file() -> None: + test_data = _mock_file_content(usernames) + valid, invalid = TSC.UserItem.CSVImport.validate_file_for_import(test_data, logger) + assert valid == 5, f"Exactly 5 of the lines were valid, counted {valid + len(invalid)}" From 4f38f0a9f06391944c8c03316bb180e9478d9501 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:21:17 -0600 Subject: [PATCH 65/98] chore: pytestify request_option (#1695) * fix: black ci errors * chore: pytestify request_option * fix: encoding error --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_request_option.py | 801 +++++++++++++++++++----------------- 1 file changed, 413 insertions(+), 388 deletions(-) diff --git a/test/test_request_option.py b/test/test_request_option.py index dbf6dc996..2d7402d23 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -1,404 +1,429 @@ -import os from pathlib import Path -import re -import unittest from urllib.parse import parse_qs +import pytest import requests_mock import tableauserverclient as TSC TEST_ASSET_DIR = Path(__file__).parent / "assets" -PAGINATION_XML = os.path.join(TEST_ASSET_DIR, "request_option_pagination.xml") -PAGE_NUMBER_XML = os.path.join(TEST_ASSET_DIR, "request_option_page_number.xml") -PAGE_SIZE_XML = os.path.join(TEST_ASSET_DIR, "request_option_page_size.xml") -FILTER_EQUALS = os.path.join(TEST_ASSET_DIR, "request_option_filter_equals.xml") -FILTER_NAME_IN = os.path.join(TEST_ASSET_DIR, "request_option_filter_name_in.xml") -FILTER_TAGS_IN = os.path.join(TEST_ASSET_DIR, "request_option_filter_tags_in.xml") -FILTER_MULTIPLE = os.path.join(TEST_ASSET_DIR, "request_option_filter_tags_in.xml") -SLICING_QUERYSET = os.path.join(TEST_ASSET_DIR, "request_option_slicing_queryset.xml") +PAGINATION_XML = TEST_ASSET_DIR / "request_option_pagination.xml" +PAGE_NUMBER_XML = TEST_ASSET_DIR / "request_option_page_number.xml" +PAGE_SIZE_XML = TEST_ASSET_DIR / "request_option_page_size.xml" +FILTER_EQUALS = TEST_ASSET_DIR / "request_option_filter_equals.xml" +FILTER_NAME_IN = TEST_ASSET_DIR / "request_option_filter_name_in.xml" +FILTER_TAGS_IN = TEST_ASSET_DIR / "request_option_filter_tags_in.xml" +FILTER_MULTIPLE = TEST_ASSET_DIR / "request_option_filter_tags_in.xml" +SLICING_QUERYSET = TEST_ASSET_DIR / "request_option_slicing_queryset.xml" SLICING_QUERYSET_PAGE_1 = TEST_ASSET_DIR / "queryset_slicing_page_1.xml" SLICING_QUERYSET_PAGE_2 = TEST_ASSET_DIR / "queryset_slicing_page_2.xml" -class RequestOptionTests(unittest.TestCase): - def setUp(self) -> None: - self.server = TSC.Server("http://test", False, http_options={"timeout": 5}) - - # Fake signin - self.server.version = "3.10" - self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - - self.baseurl = f"{self.server.sites.baseurl}/{self.server._site_id}" - - def test_pagination(self) -> None: - with open(PAGINATION_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/views?pageNumber=1&pageSize=10", text=response_xml) - req_option = TSC.RequestOptions().page_size(10) - all_views, pagination_item = self.server.views.get(req_option) - - self.assertEqual(1, pagination_item.page_number) - self.assertEqual(10, pagination_item.page_size) - self.assertEqual(33, pagination_item.total_available) - self.assertEqual(10, len(all_views)) - - def test_page_number(self) -> None: - with open(PAGE_NUMBER_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/views?pageNumber=3", text=response_xml) - req_option = TSC.RequestOptions().page_number(3) - all_views, pagination_item = self.server.views.get(req_option) - - self.assertEqual(3, pagination_item.page_number) - self.assertEqual(100, pagination_item.page_size) - self.assertEqual(210, pagination_item.total_available) - self.assertEqual(10, len(all_views)) - - def test_page_size(self) -> None: - with open(PAGE_SIZE_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/views?pageSize=5", text=response_xml) - req_option = TSC.RequestOptions().page_size(5) - all_views, pagination_item = self.server.views.get(req_option) - - self.assertEqual(1, pagination_item.page_number) - self.assertEqual(5, pagination_item.page_size) - self.assertEqual(33, pagination_item.total_available) - self.assertEqual(5, len(all_views)) - - def test_filter_equals(self) -> None: - with open(FILTER_EQUALS, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/workbooks?filter=name:eq:RESTAPISample", text=response_xml) - req_option = TSC.RequestOptions() - req_option.filter.add( - TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "RESTAPISample") +@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_pagination(server: TSC.Server) -> None: + response_xml = PAGINATION_XML.read_text() + with requests_mock.mock() as m: + m.get(server.views.baseurl + "?pageNumber=1&pageSize=10", text=response_xml) + req_option = TSC.RequestOptions().page_size(10) + all_views, pagination_item = server.views.get(req_option) + + assert 1 == pagination_item.page_number + assert 10 == pagination_item.page_size + assert 33 == pagination_item.total_available + assert 10 == len(all_views) + + +def test_page_number(server: TSC.Server) -> None: + response_xml = PAGE_NUMBER_XML.read_text() + with requests_mock.mock() as m: + m.get(server.views.baseurl + "?pageNumber=3", text=response_xml) + req_option = TSC.RequestOptions().page_number(3) + all_views, pagination_item = server.views.get(req_option) + + assert 3 == pagination_item.page_number + assert 100 == pagination_item.page_size + assert 210 == pagination_item.total_available + assert 10 == len(all_views) + + +def test_page_size(server: TSC.Server) -> None: + response_xml = PAGE_SIZE_XML.read_text() + with requests_mock.mock() as m: + m.get(server.views.baseurl + "?pageSize=5", text=response_xml) + req_option = TSC.RequestOptions().page_size(5) + all_views, pagination_item = server.views.get(req_option) + + assert 1 == pagination_item.page_number + assert 5 == pagination_item.page_size + assert 33 == pagination_item.total_available + assert 5 == len(all_views) + + +def test_filter_equals(server: TSC.Server) -> None: + response_xml = FILTER_EQUALS.read_text() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl + "?filter=name:eq:RESTAPISample", text=response_xml) + req_option = TSC.RequestOptions() + req_option.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "RESTAPISample") + ) + matching_workbooks, pagination_item = server.workbooks.get(req_option) + + assert 2 == pagination_item.total_available + assert "RESTAPISample" == matching_workbooks[0].name + assert "RESTAPISample" == matching_workbooks[1].name + + +def test_filter_equals_shorthand(server: TSC.Server) -> None: + response_xml = FILTER_EQUALS.read_text() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl + "?filter=name:eq:RESTAPISample", text=response_xml) + matching_workbooks = server.workbooks.filter(name="RESTAPISample").order_by("name") + + assert 2 == matching_workbooks.total_available + assert "RESTAPISample" == matching_workbooks[0].name + assert "RESTAPISample" == matching_workbooks[1].name + + +def test_filter_tags_in(server: TSC.Server) -> None: + response_xml = FILTER_TAGS_IN.read_text() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl + "?filter=tags:in:[sample,safari,weather]", text=response_xml) + req_option = TSC.RequestOptions() + req_option.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["sample", "safari", "weather"]) + ) + matching_workbooks, pagination_item = server.workbooks.get(req_option) + + assert 3 == pagination_item.total_available + assert {"weather"} == matching_workbooks[0].tags + assert {"safari"} == matching_workbooks[1].tags + assert {"sample"} == matching_workbooks[2].tags + + +# check if filtered projects with spaces & special characters +# get correctly returned +def test_filter_name_in(server: TSC.Server) -> None: + response_xml = FILTER_NAME_IN.read_text("utf8") + with requests_mock.mock() as m: + m.get( + server.projects.baseurl + "?filter=name%3Ain%3A%5Bdefault%2CSalesforce+Sales+Proje%C5%9Bt%5D", + text=response_xml, + ) + req_option = TSC.RequestOptions() + req_option.filter.add( + TSC.Filter( + TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Operator.In, + ["default", "Salesforce Sales Projeśt"], ) - matching_workbooks, pagination_item = self.server.workbooks.get(req_option) - - self.assertEqual(2, pagination_item.total_available) - self.assertEqual("RESTAPISample", matching_workbooks[0].name) - self.assertEqual("RESTAPISample", matching_workbooks[1].name) - - def test_filter_equals_shorthand(self) -> None: - with open(FILTER_EQUALS, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/workbooks?filter=name:eq:RESTAPISample", text=response_xml) - matching_workbooks = self.server.workbooks.filter(name="RESTAPISample").order_by("name") - - self.assertEqual(2, matching_workbooks.total_available) - self.assertEqual("RESTAPISample", matching_workbooks[0].name) - self.assertEqual("RESTAPISample", matching_workbooks[1].name) - - def test_filter_tags_in(self) -> None: - with open(FILTER_TAGS_IN, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/workbooks?filter=tags:in:[sample,safari,weather]", text=response_xml) - req_option = TSC.RequestOptions() - req_option.filter.add( - TSC.Filter( - TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["sample", "safari", "weather"] - ) - ) - matching_workbooks, pagination_item = self.server.workbooks.get(req_option) - - self.assertEqual(3, pagination_item.total_available) - self.assertEqual({"weather"}, matching_workbooks[0].tags) - self.assertEqual({"safari"}, matching_workbooks[1].tags) - self.assertEqual({"sample"}, matching_workbooks[2].tags) - - # check if filtered projects with spaces & special characters - # get correctly returned - def test_filter_name_in(self) -> None: - with open(FILTER_NAME_IN, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get( - self.baseurl + "/projects?filter=name%3Ain%3A%5Bdefault%2CSalesforce+Sales+Proje%C5%9Bt%5D", - text=response_xml, - ) - req_option = TSC.RequestOptions() - req_option.filter.add( - TSC.Filter( - TSC.RequestOptions.Field.Name, - TSC.RequestOptions.Operator.In, - ["default", "Salesforce Sales Projeśt"], - ) - ) - matching_projects, pagination_item = self.server.projects.get(req_option) - - self.assertEqual(2, pagination_item.total_available) - self.assertEqual("default", matching_projects[0].name) - self.assertEqual("Salesforce Sales Projeśt", matching_projects[1].name) - - def test_filter_tags_in_shorthand(self) -> None: - with open(FILTER_TAGS_IN, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/workbooks?filter=tags:in:[sample,safari,weather]", text=response_xml) - matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"]) - - self.assertEqual(3, matching_workbooks.total_available) - self.assertEqual({"weather"}, matching_workbooks[0].tags) - self.assertEqual({"safari"}, matching_workbooks[1].tags) - self.assertEqual({"sample"}, matching_workbooks[2].tags) - - def test_invalid_shorthand_option(self) -> None: - with self.assertRaises(ValueError): - self.server.workbooks.filter(nonexistant__in=["sample", "safari"]) - - def test_multiple_filter_options(self) -> None: - with open(FILTER_MULTIPLE, "rb") as f: - response_xml = f.read().decode("utf-8") - # To ensure that this is deterministic, run this a few times - with requests_mock.mock() as m: - # Sometimes pep8 requires you to do things you might not otherwise do - url = "".join( - ( - self.baseurl, - "/workbooks?pageNumber=1&pageSize=100&", - "filter=name:eq:foo,tags:in:[sample,safari,weather]", - ) - ) - m.get(url, text=response_xml) - req_option = TSC.RequestOptions() - req_option.filter.add( - TSC.Filter( - TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["sample", "safari", "weather"] - ) - ) - req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "foo")) - for _ in range(5): - matching_workbooks, pagination_item = self.server.workbooks.get(req_option) - self.assertEqual(3, pagination_item.total_available) - - # Test req_options if url already has query params - def test_double_query_params(self) -> None: - with requests_mock.mock() as m: - m.get(requests_mock.ANY) - url = self.baseurl + "/views?queryParamExists=true" - opts = TSC.RequestOptions() - - opts.filter.add( - TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["stocks", "market"]) - ) - opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Asc)) - - resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search("queryparamexists=true", resp.request.query)) - self.assertTrue(re.search("filter=tags%3ain%3a%5bstocks%2cmarket%5d", resp.request.query)) - self.assertTrue(re.search("sort=name%3aasc", resp.request.query)) - - # Test req_options for versions below 3.7 - def test_filter_sort_legacy(self) -> None: - self.server.version = "3.6" - with requests_mock.mock() as m: - m.get(requests_mock.ANY) - url = self.baseurl + "/views?queryParamExists=true" - opts = TSC.RequestOptions() - - opts.filter.add( - TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["stocks", "market"]) + ) + matching_projects, pagination_item = server.projects.get(req_option) + + assert 2 == pagination_item.total_available + assert "default" == matching_projects[0].name + assert "Salesforce Sales Projeśt" == matching_projects[1].name + + +def test_filter_tags_in_shorthand(server: TSC.Server) -> None: + response_xml = FILTER_TAGS_IN.read_text() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl + "?filter=tags:in:[sample,safari,weather]", text=response_xml) + matching_workbooks = server.workbooks.filter(tags__in=["sample", "safari", "weather"]) + + assert 3 == matching_workbooks.total_available + assert {"weather"} == matching_workbooks[0].tags + assert {"safari"} == matching_workbooks[1].tags + assert {"sample"} == matching_workbooks[2].tags + + +def test_invalid_shorthand_option(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.workbooks.filter(nonexistant__in=["sample", "safari"]) + + +def test_multiple_filter_options(server: TSC.Server) -> None: + response_xml = FILTER_MULTIPLE.read_text() + # To ensure that this is deterministic, run this a few times + with requests_mock.mock() as m: + # Sometimes pep8 requires you to do things you might not otherwise do + url = "".join( + ( + server.workbooks.baseurl, + "?pageNumber=1&pageSize=100&", + "filter=name:eq:foo,tags:in:[sample,safari,weather]", ) - opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Asc)) - - resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search("queryparamexists=true", resp.request.query)) - self.assertTrue(re.search("filter=tags:in:%5bstocks,market%5d", resp.request.query)) - self.assertTrue(re.search("sort=name:asc", resp.request.query)) - - def test_vf(self) -> None: - with requests_mock.mock() as m: - m.get(requests_mock.ANY) - url = self.baseurl + "/views/456/data" - opts = TSC.PDFRequestOptions() - opts.vf("name1#", "value1") - opts.vf("name2$", "value2") - opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid - - resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search("vf_name1%23=value1", resp.request.query)) - self.assertTrue(re.search("vf_name2%24=value2", resp.request.query)) - self.assertTrue(re.search("type=tabloid", resp.request.query)) - - # Test req_options for versions beloe 3.7 - def test_vf_legacy(self) -> None: - self.server.version = "3.6" - with requests_mock.mock() as m: - m.get(requests_mock.ANY) - url = self.baseurl + "/views/456/data" - opts = TSC.PDFRequestOptions() - opts.vf("name1@", "value1") - opts.vf("name2$", "value2") - opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid - - resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search("vf_name1@=value1", resp.request.query)) - self.assertTrue(re.search("vf_name2\\$=value2", resp.request.query)) - self.assertTrue(re.search("type=tabloid", resp.request.query)) - - def test_all_fields(self) -> None: - with requests_mock.mock() as m: - m.get(requests_mock.ANY) - url = self.baseurl + "/views/456/data" - opts = TSC.RequestOptions() - opts.all_fields = True - - resp = self.server.users.get_request(url, request_object=opts) - self.assertTrue(re.search("fields=_all_", resp.request.query)) - - def test_multiple_filter_options_shorthand(self) -> None: - with open(FILTER_MULTIPLE, "rb") as f: - response_xml = f.read().decode("utf-8") - # To ensure that this is deterministic, run this a few times - with requests_mock.mock() as m: - # Sometimes pep8 requires you to do things you might not otherwise do - url = "".join( - ( - self.baseurl, - "/workbooks?pageNumber=1&pageSize=100&", - "filter=name:eq:foo,tags:in:[sample,safari,weather]", - ) + ) + m.get(url, text=response_xml) + req_option = TSC.RequestOptions() + req_option.filter.add( + TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["sample", "safari", "weather"]) + ) + req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "foo")) + for _ in range(5): + matching_workbooks, pagination_item = server.workbooks.get(req_option) + assert 3 == pagination_item.total_available + + +# Test req_options if url already has query params +def test_double_query_params(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = server.workbooks.baseurl + "?queryParamExists=true" + opts = TSC.RequestOptions() + + opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["stocks", "market"])) + opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Asc)) + + resp = server.workbooks.get_request(url, request_object=opts) + query_string = parse_qs(resp.request.query) + assert "queryparamexists" in query_string + assert "true" in query_string["queryparamexists"] + assert "filter" in query_string + assert "tags:in:[stocks,market]" in query_string["filter"] + assert "sort" in query_string + assert "name:asc" in query_string["sort"] + + +# Test req_options for versions below 3.7 +def test_filter_sort_legacy(server: TSC.Server) -> None: + server.version = "3.6" + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = server.workbooks.baseurl + "?queryParamExists=true" + opts = TSC.RequestOptions() + + opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["stocks", "market"])) + opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Asc)) + + resp = server.workbooks.get_request(url, request_object=opts) + query_string = parse_qs(resp.request.query) + assert "queryparamexists" in query_string + assert "true" in query_string["queryparamexists"] + assert "filter" in query_string + assert "tags:in:[stocks,market]" in query_string["filter"] + assert "sort" in query_string + assert "name:asc" in query_string["sort"] + + +def test_vf(server: TSC.Server) -> None: + server.version = "3.10" + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = server.workbooks.baseurl + "/456/data" + opts = TSC.PDFRequestOptions() + opts.vf("name1#", "value1") + opts.vf("name2$", "value2") + opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid + + resp = server.workbooks.get_request(url, request_object=opts) + query_string = parse_qs(resp.request.query) + assert "vf_name1#" in query_string + assert "value1" in query_string["vf_name1#"] + assert "vf_name2$" in query_string + assert "value2" in query_string["vf_name2$"] + assert "type" in query_string + assert "tabloid" in query_string["type"] + + +# Test req_options for versions below 3.7 +def test_vf_legacy(server: TSC.Server) -> None: + server.version = "3.6" + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = server.workbooks.baseurl + opts = TSC.PDFRequestOptions() + opts.vf("name1@", "value1") + opts.vf("name2$", "value2") + opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid + + resp = server.workbooks.get_request(url, request_object=opts) + query_string = parse_qs(resp.request.query) + assert "vf_name1@" in query_string + assert "value1" in query_string["vf_name1@"] + assert "vf_name2$" in query_string + assert "value2" in query_string["vf_name2$"] + assert "type" in query_string + assert "tabloid" in query_string["type"] + + +def test_all_fields(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = server.views.baseurl + "/456/data" + opts = TSC.RequestOptions() + opts.all_fields = True + + resp = server.users.get_request(url, request_object=opts) + query_string = parse_qs(resp.request.query) + assert "fields" in query_string + assert ["_all_"] == query_string["fields"] + + +def test_multiple_filter_options_shorthand(server: TSC.Server) -> None: + response_xml = FILTER_MULTIPLE.read_text() + # To ensure that this is deterministic, run this a few times + with requests_mock.mock() as m: + # Sometimes pep8 requires you to do things you might not otherwise do + url = "".join( + ( + server.workbooks.baseurl, + "?pageNumber=1&pageSize=100&", + "filter=name:eq:foo,tags:in:[sample,safari,weather]", ) - m.get(url, text=response_xml) - - for _ in range(5): - matching_workbooks = self.server.workbooks.filter(tags__in=["sample", "safari", "weather"], name="foo") - self.assertEqual(3, matching_workbooks.total_available) - - def test_slicing_queryset(self) -> None: - with open(SLICING_QUERYSET, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/views?pageNumber=1", text=response_xml) - all_views = self.server.views.all() - - self.assertEqual(10, len(all_views[::])) - self.assertEqual(5, len(all_views[::2])) - self.assertEqual(8, len(all_views[2:])) - self.assertEqual(2, len(all_views[:2])) - self.assertEqual(3, len(all_views[2:5])) - self.assertEqual(3, len(all_views[-3:])) - self.assertEqual(3, len(all_views[-6:-3])) - self.assertEqual(3, len(all_views[3:6:-1])) - self.assertEqual(3, len(all_views[6:3:-1])) - self.assertEqual(10, len(all_views[::-1])) - self.assertEqual(all_views[3:6], list(reversed(all_views[3:6:-1]))) - - self.assertEqual(all_views[-3].id, "2df55de2-3a2d-4e34-b515-6d4e70b830e9") - - with self.assertRaises(IndexError): - all_views[100] - - def test_slicing_queryset_multi_page(self) -> None: - with requests_mock.mock() as m: - m.get(self.baseurl + "/views?pageNumber=1", text=SLICING_QUERYSET_PAGE_1.read_text()) - m.get(self.baseurl + "/views?pageNumber=2", text=SLICING_QUERYSET_PAGE_2.read_text()) - sliced_views = self.server.views.all()[9:12] - - self.assertEqual(sliced_views[0].id, "2e6d6c81-da71-4b41-892c-ba80d4e7a6d0") - self.assertEqual(sliced_views[1].id, "47ffcb8e-3f7a-4ecf-8ab3-605da9febe20") - self.assertEqual(sliced_views[2].id, "6757fea8-0aa9-4160-a87c-9be27b1d1c8c") - - def test_queryset_filter_args_error(self) -> None: - with self.assertRaises(RuntimeError): - workbooks = self.server.workbooks.filter("argument") - - def test_filtering_parameters(self) -> None: - self.server.version = "3.6" - with requests_mock.mock() as m: - m.get(requests_mock.ANY) - url = self.baseurl + "/views/456/data" - opts = TSC.PDFRequestOptions() - opts.parameter("name1@", "value1") - opts.parameter("name2$", "value2") - opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid - - resp = self.server.workbooks.get_request(url, request_object=opts) - query_params = parse_qs(resp.request.query) - self.assertIn("name1@", query_params) - self.assertIn("value1", query_params["name1@"]) - self.assertIn("name2$", query_params) - self.assertIn("value2", query_params["name2$"]) - self.assertIn("type", query_params) - self.assertIn("tabloid", query_params["type"]) - - def test_queryset_endpoint_pagesize_all(self) -> None: - for page_size in (1, 10, 100, 1000): - with self.subTest(page_size): - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) - queryset = self.server.views.all(page_size=page_size) - assert queryset.request_options.pagesize == page_size - _ = list(queryset) - - def test_queryset_endpoint_pagesize_filter(self) -> None: - for page_size in (1, 10, 100, 1000): - with self.subTest(page_size): - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) - queryset = self.server.views.filter(page_size=page_size) - assert queryset.request_options.pagesize == page_size - _ = list(queryset) - - def test_queryset_pagesize_filter(self) -> None: - for page_size in (1, 10, 100, 1000): - with self.subTest(page_size): - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) - queryset = self.server.views.all().filter(page_size=page_size) - assert queryset.request_options.pagesize == page_size - _ = list(queryset) - - def test_language_export(self) -> None: - with requests_mock.mock() as m: - m.get(requests_mock.ANY) - url = self.baseurl + "/views/456/data" - opts = TSC.PDFRequestOptions() - opts.language = "en-US" - - resp = self.server.users.get_request(url, request_object=opts) - self.assertTrue(re.search("language=en-us", resp.request.query)) - - def test_queryset_fields(self) -> None: - loop = self.server.users.fields("id") - assert "id" in loop.request_options.fields - assert "_default_" in loop.request_options.fields - - 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_" + ) + m.get(url, text=response_xml) + + for _ in range(5): + matching_workbooks = server.workbooks.filter(tags__in=["sample", "safari", "weather"], name="foo") + assert 3 == matching_workbooks.total_available + + +def test_slicing_queryset(server: TSC.Server) -> None: + response_xml = SLICING_QUERYSET.read_text() + with requests_mock.mock() as m: + m.get(server.views.baseurl + "?pageNumber=1", text=response_xml) + all_views = server.views.all() + + assert 10 == len(all_views[::]) + assert 5 == len(all_views[::2]) + assert 8 == len(all_views[2:]) + assert 2 == len(all_views[:2]) + assert 3 == len(all_views[2:5]) + assert 3 == len(all_views[-3:]) + assert 3 == len(all_views[-6:-3]) + assert 3 == len(all_views[3:6:-1]) + assert 3 == len(all_views[6:3:-1]) + assert 10 == len(all_views[::-1]) + assert all_views[3:6] == list(reversed(all_views[3:6:-1])) + + assert all_views[-3].id == "2df55de2-3a2d-4e34-b515-6d4e70b830e9" + + with pytest.raises(IndexError): + all_views[100] + + +def test_slicing_queryset_multi_page(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(server.views.baseurl + "?pageNumber=1", text=SLICING_QUERYSET_PAGE_1.read_text()) + m.get(server.views.baseurl + "?pageNumber=2", text=SLICING_QUERYSET_PAGE_2.read_text()) + sliced_views = server.views.all()[9:12] + + assert sliced_views[0].id == "2e6d6c81-da71-4b41-892c-ba80d4e7a6d0" + assert sliced_views[1].id == "47ffcb8e-3f7a-4ecf-8ab3-605da9febe20" + assert sliced_views[2].id == "6757fea8-0aa9-4160-a87c-9be27b1d1c8c" + + +def test_queryset_filter_args_error(server: TSC.Server) -> None: + with pytest.raises(RuntimeError): + workbooks = server.workbooks.filter("argument") + + +def test_filtering_parameters(server: TSC.Server) -> None: + server.version = "3.6" + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = server.workbooks.baseurl + "/456/data" + opts = TSC.PDFRequestOptions() + opts.parameter("name1@", "value1") + opts.parameter("name2$", "value2") + opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid + + resp = server.workbooks.get_request(url, request_object=opts) + query_params = parse_qs(resp.request.query) + assert "name1@" in query_params + assert "value1" in query_params["name1@"] + assert "name2$" in query_params + assert "value2" in query_params["name2$"] + assert "type" in query_params + assert "tabloid" in query_params["type"] + + +@pytest.mark.parametrize("page_size", [1, 10, 100, 1_000]) +def test_queryset_endpoint_pagesize_all(server: TSC.Server, page_size: int) -> None: + with requests_mock.mock() as m: + m.get(f"{server.views.baseurl}?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) + queryset = server.views.all(page_size=page_size) + assert queryset.request_options.pagesize == page_size + _ = list(queryset) + + +@pytest.mark.parametrize("page_size", [1, 10, 100, 1_000]) +def test_queryset_endpoint_pagesize_filter(server: TSC.Server, page_size: int) -> None: + with requests_mock.mock() as m: + m.get(f"{server.views.baseurl}?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) + queryset = server.views.filter(page_size=page_size) + assert queryset.request_options.pagesize == page_size + _ = list(queryset) + + +@pytest.mark.parametrize("page_size", [1, 10, 100, 1_000]) +def test_queryset_pagesize_filter(server: TSC.Server, page_size: int) -> None: + with requests_mock.mock() as m: + m.get(f"{server.views.baseurl}?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) + queryset = server.views.all().filter(page_size=page_size) + assert queryset.request_options.pagesize == page_size + _ = list(queryset) + + +def test_language_export(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = server.views.baseurl + "/456/data" + opts = TSC.PDFRequestOptions() + opts.language = "en-US" + + resp = server.users.get_request(url, request_object=opts) + query_string = parse_qs(resp.request.query) + assert "language" in query_string + assert "en-us" in query_string["language"] + + +def test_queryset_fields(server: TSC.Server) -> None: + loop = server.users.fields("id") + assert "id" in loop.request_options.fields + assert "_default_" in loop.request_options.fields + + +def test_queryset_only_fields(server: TSC.Server) -> None: + loop = 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(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(server.views.baseurl, text=SLICING_QUERYSET_PAGE_1.read_text()) + loop = 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(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(server.views.baseurl, text=SLICING_QUERYSET_PAGE_1.read_text()) + loop = server.views.fields("id", "name", "_all_") + list(loop) + history = m.request_history[0] + + fields = history.qs.get("fields", [""])[0] + + assert fields == "_all_" From 07be8405958c6b9d9d776bd06b94e3254755ccc9 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:21:29 -0600 Subject: [PATCH 66/98] chore: pytestify requests (#1696) * fix: black ci errors * chore: pytestify requests * style: black * chore: pytestify request_factory object requests * chore: pytestify ssl_config --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../test_datasource_requests.py | 18 +-- .../request_factory/test_workbook_requests.py | 95 ++++++------ test/test_requests.py | 114 +++++++------- test/test_ssl_config.py | 139 ++++++++---------- 4 files changed, 183 insertions(+), 183 deletions(-) diff --git a/test/request_factory/test_datasource_requests.py b/test/request_factory/test_datasource_requests.py index 75bb535d5..66b7a373e 100644 --- a/test/request_factory/test_datasource_requests.py +++ b/test/request_factory/test_datasource_requests.py @@ -1,15 +1,13 @@ -import unittest import tableauserverclient as TSC import tableauserverclient.server.request_factory as TSC_RF from tableauserverclient import DatasourceItem -class DatasourceRequestTests(unittest.TestCase): - def test_generate_xml(self): - datasource_item: TSC.DatasourceItem = TSC.DatasourceItem("name") - datasource_item.name = "a ds" - datasource_item.description = "described" - datasource_item.use_remote_query_agent = False - datasource_item.ask_data_enablement = DatasourceItem.AskDataEnablement.Enabled - datasource_item.project_id = "testval" - TSC_RF.RequestFactory.Datasource._generate_xml(datasource_item) +def test_generate_xml(): + datasource_item: TSC.DatasourceItem = TSC.DatasourceItem("name") + datasource_item.name = "a ds" + datasource_item.description = "described" + datasource_item.use_remote_query_agent = False + datasource_item.ask_data_enablement = DatasourceItem.AskDataEnablement.Enabled + datasource_item.project_id = "testval" + TSC_RF.RequestFactory.Datasource._generate_xml(datasource_item) diff --git a/test/request_factory/test_workbook_requests.py b/test/request_factory/test_workbook_requests.py index 332b6defa..b114e04a0 100644 --- a/test/request_factory/test_workbook_requests.py +++ b/test/request_factory/test_workbook_requests.py @@ -1,4 +1,3 @@ -import unittest import tableauserverclient as TSC import tableauserverclient.server.request_factory as TSC_RF from tableauserverclient.helpers.strings import redact_xml @@ -6,50 +5,54 @@ import sys -class WorkbookRequestTests(unittest.TestCase): - def test_embedded_extract_req(self): - include_all = True - embedded_datasources = None - xml_result = TSC_RF.RequestFactory.Workbook.embedded_extract_req(include_all, embedded_datasources) - - def test_generate_xml(self): - workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") - TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item) - - def test_generate_xml_invalid_connection(self): - workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") - conn = TSC.ConnectionItem() - with self.assertRaises(ValueError): - request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn]) - - def test_generate_xml_invalid_connection_credentials(self): - workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") - conn = TSC.ConnectionItem() - conn.server_address = "address" - creds = TSC.ConnectionCredentials("username", "password") - creds.name = None - conn.connection_credentials = creds - with self.assertRaises(ValueError): - request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn]) - - def test_generate_xml_valid_connection_credentials(self): - workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") - conn = TSC.ConnectionItem() - conn.server_address = "address" - creds = TSC.ConnectionCredentials("username", "DELETEME") - conn.connection_credentials = creds +def test_embedded_extract_req() -> None: + include_all = True + embedded_datasources = None + xml_result = TSC_RF.RequestFactory.Workbook.embedded_extract_req(include_all, embedded_datasources) + + +def test_generate_xml() -> None: + workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") + TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item) + + +def test_generate_xml_invalid_connection() -> None: + workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") + conn = TSC.ConnectionItem() + with pytest.raises(ValueError): request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn]) - assert request.find(b"DELETEME") > 0 - - def test_redact_passwords_in_xml(self): - if sys.version_info < (3, 7): - pytest.skip("Redaction is only implemented for 3.7+.") - workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") - conn = TSC.ConnectionItem() - conn.server_address = "address" - creds = TSC.ConnectionCredentials("username", "DELETEME") - conn.connection_credentials = creds + + +def test_generate_xml_invalid_connection_credentials() -> None: + workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") + conn = TSC.ConnectionItem() + conn.server_address = "address" + creds = TSC.ConnectionCredentials("username", "password") + creds.name = None + conn.connection_credentials = creds + with pytest.raises(ValueError): request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn]) - redacted = redact_xml(request) - assert request.find(b"DELETEME") > 0, request - assert redacted.find(b"DELETEME") == -1, redacted + + +def test_generate_xml_valid_connection_credentials() -> None: + workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") + conn = TSC.ConnectionItem() + conn.server_address = "address" + creds = TSC.ConnectionCredentials("username", "DELETEME") + conn.connection_credentials = creds + request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn]) + assert request.find(b"DELETEME") > 0 + + +def test_redact_passwords_in_xml() -> None: + if sys.version_info < (3, 7): + pytest.skip("Redaction is only implemented for 3.7+.") + workbook_item: TSC.WorkbookItem = TSC.WorkbookItem("name", "project_id") + conn = TSC.ConnectionItem() + conn.server_address = "address" + creds = TSC.ConnectionCredentials("username", "DELETEME") + conn.connection_credentials = creds + request = TSC_RF.RequestFactory.Workbook._generate_xml(workbook_item, connections=[conn]) + redacted = redact_xml(request) + assert request.find(b"DELETEME") > 0, request + assert redacted.find(b"DELETEME") == -1, redacted diff --git a/test/test_requests.py b/test/test_requests.py index 5c0d090ba..5ee68b020 100644 --- a/test/test_requests.py +++ b/test/test_requests.py @@ -1,6 +1,6 @@ -import re -import unittest +from urllib.parse import parse_qs +import pytest import requests import requests_mock @@ -8,54 +8,62 @@ from tableauserverclient.server.endpoint.exceptions import InternalServerError, NonXMLResponseError -class RequestTests(unittest.TestCase): - def setUp(self): - 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_make_get_request(self): - with requests_mock.mock() as m: - m.get(requests_mock.ANY) - url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" - opts = TSC.RequestOptions(pagesize=13, pagenumber=15) - resp = self.server.workbooks.get_request(url, request_object=opts) - - self.assertTrue(re.search("pagesize=13", resp.request.query)) - self.assertTrue(re.search("pagenumber=15", resp.request.query)) - - def test_make_post_request(self): - with requests_mock.mock() as m: - m.post(requests_mock.ANY) - url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" - resp = self.server.workbooks._make_request( - requests.post, - url, - content=b"1337", - auth_token="j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM", - content_type="multipart/mixed", - ) - self.assertEqual(resp.request.headers["x-tableau-auth"], "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM") - self.assertEqual(resp.request.headers["content-type"], "multipart/mixed") - self.assertTrue(re.search("Tableau Server Client", resp.request.headers["user-agent"])) - self.assertEqual(resp.request.body, b"1337") - - # Test that 500 server errors are handled properly - def test_internal_server_error(self): - self.server.version = "3.2" - server_response = "500: Internal Server Error" - with requests_mock.mock() as m: - m.register_uri("GET", self.server.server_info.baseurl, status_code=500, text=server_response) - self.assertRaisesRegex(InternalServerError, server_response, self.server.server_info.get) - - # Test that non-xml server errors are handled properly - def test_non_xml_error(self): - self.server.version = "3.2" - server_response = "this is not xml" - with requests_mock.mock() as m: - m.register_uri("GET", self.server.server_info.baseurl, status_code=499, text=server_response) - self.assertRaisesRegex(NonXMLResponseError, server_response, self.server.server_info.get) +@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_make_get_request(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" + opts = TSC.RequestOptions(pagesize=13, pagenumber=15) + resp = server.workbooks.get_request(url, request_object=opts) + + query = parse_qs(resp.request.query) + assert query.get("pagesize") == ["13"] + assert query.get("pagenumber") == ["15"] + + +def test_make_post_request(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.post(requests_mock.ANY) + url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" + resp = server.workbooks._make_request( + requests.post, + url, + content=b"1337", + auth_token="j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM", + content_type="multipart/mixed", + ) + assert resp.request.headers["x-tableau-auth"] == "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + assert resp.request.headers["content-type"] == "multipart/mixed" + assert "Tableau Server Client" in resp.request.headers["user-agent"] + assert resp.request.body == b"1337" + + +# Test that 500 server errors are handled properly +def test_internal_server_error(server: TSC.Server) -> None: + server.version = "3.2" + server_response = "500: Internal Server Error" + with requests_mock.mock() as m: + m.register_uri("GET", server.server_info.baseurl, status_code=500, text=server_response) + with pytest.raises(InternalServerError, match=server_response): + server.server_info.get() + + +# Test that non-xml server errors are handled properly +def test_non_xml_error(server: TSC.Server) -> None: + server.version = "3.2" + server_response = "this is not xml" + with requests_mock.mock() as m: + m.register_uri("GET", server.server_info.baseurl, status_code=499, text=server_response) + with pytest.raises(NonXMLResponseError, match=server_response): + server.server_info.get() diff --git a/test/test_ssl_config.py b/test/test_ssl_config.py index 036a326ca..28ef3fc5e 100644 --- a/test/test_ssl_config.py +++ b/test/test_ssl_config.py @@ -1,77 +1,68 @@ -import unittest -import ssl -from unittest.mock import patch, MagicMock -from tableauserverclient import Server -from tableauserverclient.server.endpoint import Endpoint import logging +from unittest.mock import MagicMock +import pytest -class TestSSLConfig(unittest.TestCase): - @patch("requests.session") - @patch("tableauserverclient.server.endpoint.Endpoint.set_parameters") - def setUp(self, mock_set_parameters, mock_session): - """Set up test fixtures with mocked session and request validation""" - # Mock the session - self.mock_session = MagicMock() - mock_session.return_value = self.mock_session - - # Mock request preparation - self.mock_request = MagicMock() - self.mock_session.prepare_request.return_value = self.mock_request - - # Create server instance with mocked components - self.server = Server("http://test") - - def test_default_ssl_config(self): - """Test that by default, no custom SSL context is used""" - self.assertIsNone(self.server._ssl_context) - self.assertNotIn("verify", self.server.http_options) - - @patch("ssl.create_default_context") - def test_weak_dh_config(self, mock_create_context): - """Test that weak DH keys can be allowed when configured""" - # Setup mock SSL context - mock_context = MagicMock() - mock_create_context.return_value = mock_context - - # Configure SSL with weak DH - self.server.configure_ssl(allow_weak_dh=True) - - # Verify SSL context was created and configured correctly - mock_create_context.assert_called_once() - mock_context.set_dh_parameters.assert_called_once_with(min_key_bits=512) - - # Verify context was added to http options - self.assertEqual(self.server.http_options["verify"], mock_context) - - @patch("ssl.create_default_context") - def test_disable_weak_dh_config(self, mock_create_context): - """Test that SSL config can be reset to defaults""" - # Setup mock SSL context - mock_context = MagicMock() - mock_create_context.return_value = mock_context - - # First enable weak DH - self.server.configure_ssl(allow_weak_dh=True) - self.assertIsNotNone(self.server._ssl_context) - self.assertIn("verify", self.server.http_options) - - # Then disable it - self.server.configure_ssl(allow_weak_dh=False) - self.assertIsNone(self.server._ssl_context) - self.assertNotIn("verify", self.server.http_options) - - @patch("ssl.create_default_context") - def test_warning_on_weak_dh(self, mock_create_context): - """Test that a warning is logged when enabling weak DH keys""" - logging.getLogger().setLevel(logging.WARNING) - with self.assertLogs(level="WARNING") as log: - self.server.configure_ssl(allow_weak_dh=True) - self.assertTrue( - any("WARNING: Allowing weak Diffie-Hellman keys" in record for record in log.output), - "Expected warning about weak DH keys was not logged", - ) - - -if __name__ == "__main__": - unittest.main() +import tableauserverclient as TSC + + +@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_default_ssl_config(server): + """Test that by default, no custom SSL context is used""" + assert server._ssl_context is None + assert "verify" not in server.http_options + + +def test_weak_dh_config(server, monkeypatch): + """Test that weak DH keys can be allowed when configured""" + mock_context = MagicMock() + mock_create_context = MagicMock(return_value=mock_context) + monkeypatch.setattr("ssl.create_default_context", mock_create_context) + + server.configure_ssl(allow_weak_dh=True) + + mock_create_context.assert_called_once() + mock_context.set_dh_parameters.assert_called_once_with(min_key_bits=512) + assert server.http_options["verify"] == mock_context + + +def test_disable_weak_dh_config(server, monkeypatch): + """Test that SSL config can be reset to defaults""" + mock_context = MagicMock() + mock_create_context = MagicMock(return_value=mock_context) + monkeypatch.setattr("ssl.create_default_context", mock_create_context) + + # First enable weak DH + server.configure_ssl(allow_weak_dh=True) + assert server._ssl_context is not None + assert "verify" in server.http_options + + # Then disable it + server.configure_ssl(allow_weak_dh=False) + assert server._ssl_context is None + assert "verify" not in server.http_options + + +def test_warning_on_weak_dh(server, monkeypatch, caplog): + """Test that a warning is logged when enabling weak DH keys""" + mock_context = MagicMock() + mock_create_context = MagicMock(return_value=mock_context) + monkeypatch.setattr("ssl.create_default_context", mock_create_context) + + with caplog.at_level(logging.WARNING): + server.configure_ssl(allow_weak_dh=True) + + assert any( + "Allowing weak Diffie-Hellman keys" in record.getMessage() for record in caplog.records + ), "Expected warning about weak DH keys was not logged" From bc9154b84a19ad93c4a718bf5a7c1e013e752d5b Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:21:42 -0600 Subject: [PATCH 67/98] chore: pytestify schedule (#1697) * fix: black ci errors * chore: pytestify schedule * chore: remove unused imports --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_schedule.py | 834 +++++++++++++++++++++--------------------- 1 file changed, 416 insertions(+), 418 deletions(-) diff --git a/test/test_schedule.py b/test/test_schedule.py index 4fcc85e18..307bc0e51 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -1,425 +1,423 @@ -import os -import unittest +from pathlib import Path from datetime import time +import pytest import requests_mock import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") - -GET_XML = os.path.join(TEST_ASSET_DIR, "schedule_get.xml") -GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_by_id.xml") -GET_HOURLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_hourly_id.xml") -GET_DAILY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_daily_id.xml") -GET_MONTHLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_monthly_id.xml") -GET_MONTHLY_ID_2_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_monthly_id_2.xml") -GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_empty.xml") -CREATE_HOURLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_hourly.xml") -CREATE_DAILY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_daily.xml") -CREATE_WEEKLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_weekly.xml") -CREATE_MONTHLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_monthly.xml") -UPDATE_XML = os.path.join(TEST_ASSET_DIR, "schedule_update.xml") -ADD_WORKBOOK_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_workbook.xml") -ADD_WORKBOOK_TO_SCHEDULE_WITH_WARNINGS = os.path.join(TEST_ASSET_DIR, "schedule_add_workbook_with_warnings.xml") -ADD_DATASOURCE_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_datasource.xml") -ADD_FLOW_TO_SCHEDULE = os.path.join(TEST_ASSET_DIR, "schedule_add_flow.xml") -GET_EXTRACT_TASKS_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_extract_refresh_tasks.xml") - -WORKBOOK_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id.xml") -DATASOURCE_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "datasource_get_by_id.xml") -FLOW_GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "flow_get_by_id.xml") - - -class ScheduleTests(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.schedules.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_schedules, pagination_item = self.server.schedules.get() - - extract = all_schedules[0] - subscription = all_schedules[1] - flow = all_schedules[2] - system = all_schedules[3] - - self.assertEqual(2, pagination_item.total_available) - self.assertEqual("c9cff7f9-309c-4361-99ff-d4ba8c9f5467", extract.id) - self.assertEqual("Weekday early mornings", extract.name) - self.assertEqual("Active", extract.state) - self.assertEqual(50, extract.priority) - self.assertEqual("2016-07-06T20:19:00Z", format_datetime(extract.created_at)) - self.assertEqual("2016-09-13T11:00:32Z", format_datetime(extract.updated_at)) - self.assertEqual("Extract", extract.schedule_type) - self.assertEqual("2016-09-14T11:00:00Z", format_datetime(extract.next_run_at)) - - self.assertEqual("bcb79d07-6e47-472f-8a65-d7f51f40c36c", subscription.id) - self.assertEqual("Saturday night", subscription.name) - self.assertEqual("Active", subscription.state) - self.assertEqual(80, subscription.priority) - self.assertEqual("2016-07-07T20:19:00Z", format_datetime(subscription.created_at)) - self.assertEqual("2016-09-12T16:39:38Z", format_datetime(subscription.updated_at)) - self.assertEqual("Subscription", subscription.schedule_type) - self.assertEqual("2016-09-18T06:00:00Z", format_datetime(subscription.next_run_at)) - - self.assertEqual("f456e8f2-aeb2-4a8e-b823-00b6f08640f0", flow.id) - self.assertEqual("First of the month 1:00AM", flow.name) - self.assertEqual("Active", flow.state) - self.assertEqual(50, flow.priority) - self.assertEqual("2019-02-19T18:52:19Z", format_datetime(flow.created_at)) - self.assertEqual("2019-02-19T18:55:51Z", format_datetime(flow.updated_at)) - self.assertEqual("Flow", flow.schedule_type) - self.assertEqual("2019-03-01T09:00:00Z", format_datetime(flow.next_run_at)) - - self.assertEqual("3cfa4713-ce7c-4fa7-aa2e-f752bfc8dd04", system.id) - self.assertEqual("First of the month 2:00AM", system.name) - self.assertEqual("Active", system.state) - self.assertEqual(30, system.priority) - self.assertEqual("2019-02-19T18:52:19Z", format_datetime(system.created_at)) - self.assertEqual("2019-02-19T18:55:51Z", format_datetime(system.updated_at)) - self.assertEqual("System", system.schedule_type) - self.assertEqual("2019-03-01T09:00:00Z", format_datetime(system.next_run_at)) - - 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_schedules, pagination_item = self.server.schedules.get() - - self.assertEqual(0, pagination_item.total_available) - self.assertEqual([], all_schedules) - - def test_get_by_id(self) -> None: - self.server.version = "3.8" - with open(GET_BY_ID_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" - m.get(baseurl, text=response_xml) - schedule = self.server.schedules.get_by_id(schedule_id) - self.assertIsNotNone(schedule) - self.assertEqual(schedule_id, schedule.id) - self.assertEqual("Weekday early mornings", schedule.name) - self.assertEqual("Active", schedule.state) - - def test_get_hourly_by_id(self) -> None: - self.server.version = "3.8" - with open(GET_HOURLY_ID_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" - m.get(baseurl, text=response_xml) - schedule = self.server.schedules.get_by_id(schedule_id) - self.assertIsNotNone(schedule) - self.assertEqual(schedule_id, schedule.id) - self.assertEqual("Hourly schedule", schedule.name) - self.assertEqual("Active", schedule.state) - self.assertEqual(("Monday", 0.5), schedule.interval_item.interval) - - def test_get_daily_by_id(self) -> None: - self.server.version = "3.8" - with open(GET_DAILY_ID_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" - m.get(baseurl, text=response_xml) - schedule = self.server.schedules.get_by_id(schedule_id) - self.assertIsNotNone(schedule) - self.assertEqual(schedule_id, schedule.id) - self.assertEqual("Daily schedule", schedule.name) - self.assertEqual("Active", schedule.state) - self.assertEqual(("Monday", 2.0), schedule.interval_item.interval) - - def test_get_monthly_by_id(self) -> None: - self.server.version = "3.8" - with open(GET_MONTHLY_ID_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" - m.get(baseurl, text=response_xml) - schedule = self.server.schedules.get_by_id(schedule_id) - self.assertIsNotNone(schedule) - self.assertEqual(schedule_id, schedule.id) - self.assertEqual("Monthly multiple days", schedule.name) - self.assertEqual("Active", schedule.state) - self.assertEqual(("1", "2"), schedule.interval_item.interval) - - def test_get_monthly_by_id_2(self) -> None: - self.server.version = "3.15" - with open(GET_MONTHLY_ID_2_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - schedule_id = "8c5caf33-6223-4724-83c3-ccdc1e730a07" - baseurl = f"{self.server.baseurl}/schedules/{schedule_id}" - m.get(baseurl, text=response_xml) - schedule = self.server.schedules.get_by_id(schedule_id) - self.assertIsNotNone(schedule) - self.assertEqual(schedule_id, schedule.id) - self.assertEqual("Monthly First Monday!", schedule.name) - self.assertEqual("Active", schedule.state) - self.assertEqual(("Monday", "First"), schedule.interval_item.interval) - - def test_delete(self) -> None: - with requests_mock.mock() as m: - m.delete(self.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", status_code=204) - self.server.schedules.delete("c9cff7f9-309c-4361-99ff-d4ba8c9f5467") - - def test_create_hourly(self) -> None: - with open(CREATE_HOURLY_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) - hourly_interval = TSC.HourlyInterval(start_time=time(2, 30), end_time=time(23, 0), interval_value=2) - new_schedule = TSC.ScheduleItem( - "hourly-schedule-1", - 50, - TSC.ScheduleItem.Type.Extract, - TSC.ScheduleItem.ExecutionOrder.Parallel, - hourly_interval, - ) - new_schedule = self.server.schedules.create(new_schedule) - - self.assertEqual("5f42be25-8a43-47ba-971a-63f2d4e7029c", new_schedule.id) - self.assertEqual("hourly-schedule-1", new_schedule.name) - self.assertEqual("Active", new_schedule.state) - self.assertEqual(50, new_schedule.priority) - self.assertEqual("2016-09-15T20:47:33Z", format_datetime(new_schedule.created_at)) - self.assertEqual("2016-09-15T20:47:33Z", format_datetime(new_schedule.updated_at)) - self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) - self.assertEqual("2016-09-16T01:30:00Z", format_datetime(new_schedule.next_run_at)) - self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, new_schedule.execution_order) - self.assertEqual(time(2, 30), new_schedule.interval_item.start_time) - self.assertEqual(time(23), new_schedule.interval_item.end_time) # type: ignore[union-attr] - self.assertEqual(("8",), new_schedule.interval_item.interval) # type: ignore[union-attr] - - def test_create_daily(self) -> None: - with open(CREATE_DAILY_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) - daily_interval = TSC.DailyInterval(time(4, 50)) - new_schedule = TSC.ScheduleItem( - "daily-schedule-1", - 90, - TSC.ScheduleItem.Type.Subscription, - TSC.ScheduleItem.ExecutionOrder.Serial, - daily_interval, - ) - new_schedule = self.server.schedules.create(new_schedule) - - self.assertEqual("907cae38-72fd-417c-892a-95540c4664cd", new_schedule.id) - self.assertEqual("daily-schedule-1", new_schedule.name) - self.assertEqual("Active", new_schedule.state) - self.assertEqual(90, new_schedule.priority) - self.assertEqual("2016-09-15T21:01:09Z", format_datetime(new_schedule.created_at)) - self.assertEqual("2016-09-15T21:01:09Z", format_datetime(new_schedule.updated_at)) - self.assertEqual(TSC.ScheduleItem.Type.Subscription, new_schedule.schedule_type) - self.assertEqual("2016-09-16T11:45:00Z", format_datetime(new_schedule.next_run_at)) - self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Serial, new_schedule.execution_order) - self.assertEqual(time(4, 45), new_schedule.interval_item.start_time) - - def test_create_weekly(self) -> None: - with open(CREATE_WEEKLY_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) - weekly_interval = TSC.WeeklyInterval( - time(9, 15), TSC.IntervalItem.Day.Monday, TSC.IntervalItem.Day.Wednesday, TSC.IntervalItem.Day.Friday - ) - new_schedule = TSC.ScheduleItem( - "weekly-schedule-1", - 80, - TSC.ScheduleItem.Type.Extract, - TSC.ScheduleItem.ExecutionOrder.Parallel, - weekly_interval, - ) - new_schedule = self.server.schedules.create(new_schedule) - - self.assertEqual("1adff386-6be0-4958-9f81-a35e676932bf", new_schedule.id) - self.assertEqual("weekly-schedule-1", new_schedule.name) - self.assertEqual("Active", new_schedule.state) - self.assertEqual(80, new_schedule.priority) - self.assertEqual("2016-09-15T21:12:50Z", format_datetime(new_schedule.created_at)) - self.assertEqual("2016-09-15T21:12:50Z", format_datetime(new_schedule.updated_at)) - self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) - self.assertEqual("2016-09-16T16:15:00Z", format_datetime(new_schedule.next_run_at)) - self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, new_schedule.execution_order) - self.assertEqual(time(9, 15), new_schedule.interval_item.start_time) - self.assertEqual(("Monday", "Wednesday", "Friday"), new_schedule.interval_item.interval) - self.assertEqual(2, len(new_schedule.warnings)) - self.assertEqual("warning 1", new_schedule.warnings[0]) - self.assertEqual("warning 2", new_schedule.warnings[1]) - - def test_create_monthly(self) -> None: - with open(CREATE_MONTHLY_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) - monthly_interval = TSC.MonthlyInterval(time(7), 12) - new_schedule = TSC.ScheduleItem( - "monthly-schedule-1", - 20, - TSC.ScheduleItem.Type.Extract, - TSC.ScheduleItem.ExecutionOrder.Serial, - monthly_interval, - ) - new_schedule = self.server.schedules.create(new_schedule) - - self.assertEqual("e06a7c75-5576-4f68-882d-8909d0219326", new_schedule.id) - self.assertEqual("monthly-schedule-1", new_schedule.name) - self.assertEqual("Active", new_schedule.state) - self.assertEqual(20, new_schedule.priority) - self.assertEqual("2016-09-15T21:16:56Z", format_datetime(new_schedule.created_at)) - self.assertEqual("2016-09-15T21:16:56Z", format_datetime(new_schedule.updated_at)) - self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) - self.assertEqual("2016-10-12T14:00:00Z", format_datetime(new_schedule.next_run_at)) - self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Serial, new_schedule.execution_order) - self.assertEqual(time(7), new_schedule.interval_item.start_time) - self.assertEqual(("12",), new_schedule.interval_item.interval) # type: ignore[union-attr] - - 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 + "/7bea1766-1543-4052-9753-9d224bc069b5", text=response_xml) - new_interval = TSC.WeeklyInterval(time(7), TSC.IntervalItem.Day.Monday, TSC.IntervalItem.Day.Friday) - single_schedule = TSC.ScheduleItem( - "weekly-schedule-1", - 90, - TSC.ScheduleItem.Type.Extract, - TSC.ScheduleItem.ExecutionOrder.Parallel, - new_interval, - ) - single_schedule._id = "7bea1766-1543-4052-9753-9d224bc069b5" - single_schedule.state = TSC.ScheduleItem.State.Suspended - single_schedule = self.server.schedules.update(single_schedule) - - self.assertEqual("7bea1766-1543-4052-9753-9d224bc069b5", single_schedule.id) - self.assertEqual("weekly-schedule-1", single_schedule.name) - self.assertEqual(90, single_schedule.priority) - self.assertEqual("2016-09-15T23:50:02Z", format_datetime(single_schedule.updated_at)) - self.assertEqual(TSC.ScheduleItem.Type.Extract, single_schedule.schedule_type) - self.assertEqual("2016-09-16T14:00:00Z", format_datetime(single_schedule.next_run_at)) - self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, single_schedule.execution_order) - self.assertEqual(time(7), single_schedule.interval_item.start_time) - self.assertEqual(("Monday", "Friday"), single_schedule.interval_item.interval) # type: ignore[union-attr] - self.assertEqual(TSC.ScheduleItem.State.Suspended, single_schedule.state) - - # Tests calling update with a schedule item returned from the server - def test_update_after_get(self) -> None: - with open(GET_XML, "rb") as f: - get_response_xml = f.read().decode("utf-8") - with open(UPDATE_XML, "rb") as f: - update_response_xml = f.read().decode("utf-8") - - # Get a schedule - with requests_mock.mock() as m: - m.get(self.baseurl, text=get_response_xml) - all_schedules, pagination_item = self.server.schedules.get() - schedule_item = all_schedules[0] - self.assertEqual(TSC.ScheduleItem.State.Active, schedule_item.state) - self.assertEqual("Weekday early mornings", schedule_item.name) - - # Update the schedule - with requests_mock.mock() as m: - m.put(self.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", text=update_response_xml) - schedule_item.state = TSC.ScheduleItem.State.Suspended - schedule_item.name = "newName" - schedule_item = self.server.schedules.update(schedule_item) - - self.assertEqual(TSC.ScheduleItem.State.Suspended, schedule_item.state) - self.assertEqual("weekly-schedule-1", schedule_item.name) - - def test_add_workbook(self) -> None: - self.server.version = "2.8" - baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" - - with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: - workbook_response = f.read().decode("utf-8") - with open(ADD_WORKBOOK_TO_SCHEDULE, "rb") as f: - add_workbook_response = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.server.workbooks.baseurl + "/bar", text=workbook_response) - m.put(baseurl + "/foo/workbooks", text=add_workbook_response) - workbook = self.server.workbooks.get_by_id("bar") - result = self.server.schedules.add_to_schedule("foo", workbook=workbook) - self.assertEqual(0, len(result), "Added properly") - - def test_add_workbook_with_warnings(self) -> None: - self.server.version = "2.8" - baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" - - with open(WORKBOOK_GET_BY_ID_XML, "rb") as f: - workbook_response = f.read().decode("utf-8") - with open(ADD_WORKBOOK_TO_SCHEDULE_WITH_WARNINGS, "rb") as f: - add_workbook_response = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.server.workbooks.baseurl + "/bar", text=workbook_response) - m.put(baseurl + "/foo/workbooks", text=add_workbook_response) - workbook = self.server.workbooks.get_by_id("bar") - result = self.server.schedules.add_to_schedule("foo", workbook=workbook) - self.assertEqual(1, len(result), "Not added properly") - self.assertEqual(2, len(result[0].warnings)) - - def test_add_datasource(self) -> None: - self.server.version = "2.8" - baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" - - with open(DATASOURCE_GET_BY_ID_XML, "rb") as f: - datasource_response = f.read().decode("utf-8") - with open(ADD_DATASOURCE_TO_SCHEDULE, "rb") as f: - add_datasource_response = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.server.datasources.baseurl + "/bar", text=datasource_response) - m.put(baseurl + "/foo/datasources", text=add_datasource_response) - datasource = self.server.datasources.get_by_id("bar") - result = self.server.schedules.add_to_schedule("foo", datasource=datasource) - self.assertEqual(0, len(result), "Added properly") - - def test_add_flow(self) -> None: - self.server.version = "3.3" - baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules" - - with open(FLOW_GET_BY_ID_XML, "rb") as f: - flow_response = f.read().decode("utf-8") - with open(ADD_FLOW_TO_SCHEDULE, "rb") as f: - add_flow_response = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.server.flows.baseurl + "/bar", text=flow_response) - m.put(baseurl + "/foo/flows", text=flow_response) - flow = self.server.flows.get_by_id("bar") - result = self.server.schedules.add_to_schedule("foo", flow=flow) - self.assertEqual(0, len(result), "Added properly") - - def test_get_extract_refresh_tasks(self) -> None: - self.server.version = "2.3" - - with open(GET_EXTRACT_TASKS_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/schedules/{schedule_id}/extracts" - m.get(baseurl, text=response_xml) - - extracts = self.server.schedules.get_extract_refresh_tasks(schedule_id) - - self.assertIsNotNone(extracts) - self.assertIsInstance(extracts[0], list) - self.assertEqual(2, len(extracts[0])) - self.assertEqual("task1", extracts[0][0].id) +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +GET_XML = TEST_ASSET_DIR / "schedule_get.xml" +GET_BY_ID_XML = TEST_ASSET_DIR / "schedule_get_by_id.xml" +GET_HOURLY_ID_XML = TEST_ASSET_DIR / "schedule_get_hourly_id.xml" +GET_DAILY_ID_XML = TEST_ASSET_DIR / "schedule_get_daily_id.xml" +GET_MONTHLY_ID_XML = TEST_ASSET_DIR / "schedule_get_monthly_id.xml" +GET_MONTHLY_ID_2_XML = TEST_ASSET_DIR / "schedule_get_monthly_id_2.xml" +GET_EMPTY_XML = TEST_ASSET_DIR / "schedule_get_empty.xml" +CREATE_HOURLY_XML = TEST_ASSET_DIR / "schedule_create_hourly.xml" +CREATE_DAILY_XML = TEST_ASSET_DIR / "schedule_create_daily.xml" +CREATE_WEEKLY_XML = TEST_ASSET_DIR / "schedule_create_weekly.xml" +CREATE_MONTHLY_XML = TEST_ASSET_DIR / "schedule_create_monthly.xml" +UPDATE_XML = TEST_ASSET_DIR / "schedule_update.xml" +ADD_WORKBOOK_TO_SCHEDULE = TEST_ASSET_DIR / "schedule_add_workbook.xml" +ADD_WORKBOOK_TO_SCHEDULE_WITH_WARNINGS = TEST_ASSET_DIR / "schedule_add_workbook_with_warnings.xml" +ADD_DATASOURCE_TO_SCHEDULE = TEST_ASSET_DIR / "schedule_add_datasource.xml" +ADD_FLOW_TO_SCHEDULE = TEST_ASSET_DIR / "schedule_add_flow.xml" +GET_EXTRACT_TASKS_XML = TEST_ASSET_DIR / "schedule_get_extract_refresh_tasks.xml" +BATCH_UPDATE_STATE = TEST_ASSET_DIR / "schedules_batch_update_state.xml" + +WORKBOOK_GET_BY_ID_XML = TEST_ASSET_DIR / "workbook_get_by_id.xml" +DATASOURCE_GET_BY_ID_XML = TEST_ASSET_DIR / "datasource_get_by_id.xml" +FLOW_GET_BY_ID_XML = TEST_ASSET_DIR / "flow_get_by_id.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: TSC.Server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.schedules.baseurl, text=response_xml) + all_schedules, pagination_item = server.schedules.get() + + extract = all_schedules[0] + subscription = all_schedules[1] + flow = all_schedules[2] + system = all_schedules[3] + + assert 2 == pagination_item.total_available + assert "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" == extract.id + assert "Weekday early mornings" == extract.name + assert "Active" == extract.state + assert 50 == extract.priority + assert "2016-07-06T20:19:00Z" == format_datetime(extract.created_at) + assert "2016-09-13T11:00:32Z" == format_datetime(extract.updated_at) + assert "Extract" == extract.schedule_type + assert "2016-09-14T11:00:00Z" == format_datetime(extract.next_run_at) + + assert "bcb79d07-6e47-472f-8a65-d7f51f40c36c" == subscription.id + assert "Saturday night" == subscription.name + assert "Active" == subscription.state + assert 80 == subscription.priority + assert "2016-07-07T20:19:00Z" == format_datetime(subscription.created_at) + assert "2016-09-12T16:39:38Z" == format_datetime(subscription.updated_at) + assert "Subscription" == subscription.schedule_type + assert "2016-09-18T06:00:00Z" == format_datetime(subscription.next_run_at) + + assert "f456e8f2-aeb2-4a8e-b823-00b6f08640f0" == flow.id + assert "First of the month 1:00AM" == flow.name + assert "Active" == flow.state + assert 50 == flow.priority + assert "2019-02-19T18:52:19Z" == format_datetime(flow.created_at) + assert "2019-02-19T18:55:51Z" == format_datetime(flow.updated_at) + assert "Flow" == flow.schedule_type + assert "2019-03-01T09:00:00Z" == format_datetime(flow.next_run_at) + + assert "3cfa4713-ce7c-4fa7-aa2e-f752bfc8dd04" == system.id + assert "First of the month 2:00AM" == system.name + assert "Active" == system.state + assert 30 == system.priority + assert "2019-02-19T18:52:19Z" == format_datetime(system.created_at) + assert "2019-02-19T18:55:51Z" == format_datetime(system.updated_at) + assert "System" == system.schedule_type + assert "2019-03-01T09:00:00Z" == format_datetime(system.next_run_at) + + +def test_get_empty(server: TSC.Server) -> None: + response_xml = GET_EMPTY_XML.read_text() + with requests_mock.mock() as m: + m.get(server.schedules.baseurl, text=response_xml) + all_schedules, pagination_item = server.schedules.get() + + assert 0 == pagination_item.total_available + assert [] == all_schedules + + +def test_get_by_id(server: TSC.Server) -> None: + server.version = "3.8" + response_xml = GET_BY_ID_XML.read_text() + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = f"{server.baseurl}/schedules/{schedule_id}" + m.get(baseurl, text=response_xml) + schedule = server.schedules.get_by_id(schedule_id) + assert schedule is not None + assert schedule_id == schedule.id + assert "Weekday early mornings" == schedule.name + assert "Active" == schedule.state + + +def test_get_hourly_by_id(server: TSC.Server) -> None: + server.version = "3.8" + response_xml = GET_HOURLY_ID_XML.read_text() + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = f"{server.baseurl}/schedules/{schedule_id}" + m.get(baseurl, text=response_xml) + schedule = server.schedules.get_by_id(schedule_id) + assert schedule is not None + assert schedule_id == schedule.id + assert "Hourly schedule" == schedule.name + assert "Active" == schedule.state + assert ("Monday", 0.5) == schedule.interval_item.interval + + +def test_get_daily_by_id(server: TSC.Server) -> None: + server.version = "3.8" + response_xml = GET_DAILY_ID_XML.read_text() + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = f"{server.baseurl}/schedules/{schedule_id}" + m.get(baseurl, text=response_xml) + schedule = server.schedules.get_by_id(schedule_id) + assert schedule is not None + assert schedule_id == schedule.id + assert "Daily schedule" == schedule.name + assert "Active" == schedule.state + assert ("Monday", 2.0) == schedule.interval_item.interval + + +def test_get_monthly_by_id(server: TSC.Server) -> None: + server.version = "3.8" + response_xml = GET_MONTHLY_ID_XML.read_text() + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = f"{server.baseurl}/schedules/{schedule_id}" + m.get(baseurl, text=response_xml) + schedule = server.schedules.get_by_id(schedule_id) + assert schedule is not None + assert schedule_id == schedule.id + assert "Monthly multiple days" == schedule.name + assert "Active" == schedule.state + assert ("1", "2") == schedule.interval_item.interval + + +def test_get_monthly_by_id_2(server: TSC.Server) -> None: + server.version = "3.15" + response_xml = GET_MONTHLY_ID_2_XML.read_text() + with requests_mock.mock() as m: + schedule_id = "8c5caf33-6223-4724-83c3-ccdc1e730a07" + baseurl = f"{server.baseurl}/schedules/{schedule_id}" + m.get(baseurl, text=response_xml) + schedule = server.schedules.get_by_id(schedule_id) + assert schedule is not None + assert schedule_id == schedule.id + assert "Monthly First Monday!" == schedule.name + assert "Active" == schedule.state + assert ("Monday", "First") == schedule.interval_item.interval + + +def test_delete(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.delete(server.schedules.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", status_code=204) + server.schedules.delete("c9cff7f9-309c-4361-99ff-d4ba8c9f5467") + + +def test_create_hourly(server: TSC.Server) -> None: + response_xml = CREATE_HOURLY_XML.read_text() + with requests_mock.mock() as m: + m.post(server.schedules.baseurl, text=response_xml) + hourly_interval = TSC.HourlyInterval(start_time=time(2, 30), end_time=time(23, 0), interval_value=2) + new_schedule = TSC.ScheduleItem( + "hourly-schedule-1", + 50, + TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Parallel, + hourly_interval, + ) + new_schedule = server.schedules.create(new_schedule) + + assert "5f42be25-8a43-47ba-971a-63f2d4e7029c" == new_schedule.id + assert "hourly-schedule-1" == new_schedule.name + assert "Active" == new_schedule.state + assert 50 == new_schedule.priority + assert "2016-09-15T20:47:33Z" == format_datetime(new_schedule.created_at) + assert "2016-09-15T20:47:33Z" == format_datetime(new_schedule.updated_at) + assert TSC.ScheduleItem.Type.Extract == new_schedule.schedule_type + assert "2016-09-16T01:30:00Z" == format_datetime(new_schedule.next_run_at) + assert TSC.ScheduleItem.ExecutionOrder.Parallel == new_schedule.execution_order + assert time(2, 30) == new_schedule.interval_item.start_time + assert time(23) == new_schedule.interval_item.end_time # type: ignore[union-attr] + assert ("8",) == new_schedule.interval_item.interval # type: ignore[union-attr] + + +def test_create_daily(server: TSC.Server) -> None: + response_xml = CREATE_DAILY_XML.read_text() + with requests_mock.mock() as m: + m.post(server.schedules.baseurl, text=response_xml) + daily_interval = TSC.DailyInterval(time(4, 50)) + new_schedule = TSC.ScheduleItem( + "daily-schedule-1", + 90, + TSC.ScheduleItem.Type.Subscription, + TSC.ScheduleItem.ExecutionOrder.Serial, + daily_interval, + ) + new_schedule = server.schedules.create(new_schedule) + + assert "907cae38-72fd-417c-892a-95540c4664cd" == new_schedule.id + assert "daily-schedule-1" == new_schedule.name + assert "Active" == new_schedule.state + assert 90 == new_schedule.priority + assert "2016-09-15T21:01:09Z" == format_datetime(new_schedule.created_at) + assert "2016-09-15T21:01:09Z" == format_datetime(new_schedule.updated_at) + assert TSC.ScheduleItem.Type.Subscription == new_schedule.schedule_type + assert "2016-09-16T11:45:00Z" == format_datetime(new_schedule.next_run_at) + assert TSC.ScheduleItem.ExecutionOrder.Serial == new_schedule.execution_order + assert time(4, 45) == new_schedule.interval_item.start_time + + +def test_create_weekly(server: TSC.Server) -> None: + response_xml = CREATE_WEEKLY_XML.read_text() + with requests_mock.mock() as m: + m.post(server.schedules.baseurl, text=response_xml) + weekly_interval = TSC.WeeklyInterval( + time(9, 15), TSC.IntervalItem.Day.Monday, TSC.IntervalItem.Day.Wednesday, TSC.IntervalItem.Day.Friday + ) + new_schedule = TSC.ScheduleItem( + "weekly-schedule-1", + 80, + TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Parallel, + weekly_interval, + ) + new_schedule = server.schedules.create(new_schedule) + + assert "1adff386-6be0-4958-9f81-a35e676932bf" == new_schedule.id + assert "weekly-schedule-1" == new_schedule.name + assert "Active" == new_schedule.state + assert 80 == new_schedule.priority + assert "2016-09-15T21:12:50Z" == format_datetime(new_schedule.created_at) + assert "2016-09-15T21:12:50Z" == format_datetime(new_schedule.updated_at) + assert TSC.ScheduleItem.Type.Extract == new_schedule.schedule_type + assert "2016-09-16T16:15:00Z" == format_datetime(new_schedule.next_run_at) + assert TSC.ScheduleItem.ExecutionOrder.Parallel == new_schedule.execution_order + assert time(9, 15) == new_schedule.interval_item.start_time + assert ("Monday", "Wednesday", "Friday") == new_schedule.interval_item.interval + assert 2 == len(new_schedule.warnings) + assert "warning 1" == new_schedule.warnings[0] + assert "warning 2" == new_schedule.warnings[1] + + +def test_create_monthly(server: TSC.Server) -> None: + response_xml = CREATE_MONTHLY_XML.read_text() + with requests_mock.mock() as m: + m.post(server.schedules.baseurl, text=response_xml) + monthly_interval = TSC.MonthlyInterval(time(7), 12) + new_schedule = TSC.ScheduleItem( + "monthly-schedule-1", + 20, + TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Serial, + monthly_interval, + ) + new_schedule = server.schedules.create(new_schedule) + + assert "e06a7c75-5576-4f68-882d-8909d0219326" == new_schedule.id + assert "monthly-schedule-1" == new_schedule.name + assert "Active" == new_schedule.state + assert 20 == new_schedule.priority + assert "2016-09-15T21:16:56Z" == format_datetime(new_schedule.created_at) + assert "2016-09-15T21:16:56Z" == format_datetime(new_schedule.updated_at) + assert TSC.ScheduleItem.Type.Extract == new_schedule.schedule_type + assert "2016-10-12T14:00:00Z" == format_datetime(new_schedule.next_run_at) + assert TSC.ScheduleItem.ExecutionOrder.Serial == new_schedule.execution_order + assert time(7) == new_schedule.interval_item.start_time + assert ("12",) == new_schedule.interval_item.interval # type: ignore[union-attr] + + +def test_update(server: TSC.Server) -> None: + response_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.put(server.schedules.baseurl + "/7bea1766-1543-4052-9753-9d224bc069b5", text=response_xml) + new_interval = TSC.WeeklyInterval(time(7), TSC.IntervalItem.Day.Monday, TSC.IntervalItem.Day.Friday) + single_schedule = TSC.ScheduleItem( + "weekly-schedule-1", + 90, + TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Parallel, + new_interval, + ) + single_schedule._id = "7bea1766-1543-4052-9753-9d224bc069b5" + single_schedule.state = TSC.ScheduleItem.State.Suspended + single_schedule = server.schedules.update(single_schedule) + + assert "7bea1766-1543-4052-9753-9d224bc069b5" == single_schedule.id + assert "weekly-schedule-1" == single_schedule.name + assert 90 == single_schedule.priority + assert "2016-09-15T23:50:02Z" == format_datetime(single_schedule.updated_at) + assert TSC.ScheduleItem.Type.Extract == single_schedule.schedule_type + assert "2016-09-16T14:00:00Z" == format_datetime(single_schedule.next_run_at) + assert TSC.ScheduleItem.ExecutionOrder.Parallel == single_schedule.execution_order + assert time(7) == single_schedule.interval_item.start_time + assert ("Monday", "Friday") == single_schedule.interval_item.interval # type: ignore[union-attr] + assert TSC.ScheduleItem.State.Suspended == single_schedule.state + + +# Tests calling update with a schedule item returned from the server +def test_update_after_get(server: TSC.Server) -> None: + get_response_xml = GET_XML.read_text() + update_response_xml = UPDATE_XML.read_text() + + # Get a schedule + with requests_mock.mock() as m: + m.get(server.schedules.baseurl, text=get_response_xml) + all_schedules, pagination_item = server.schedules.get() + schedule_item = all_schedules[0] + assert TSC.ScheduleItem.State.Active == schedule_item.state + assert "Weekday early mornings" == schedule_item.name + + # Update the schedule + with requests_mock.mock() as m: + m.put(server.schedules.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", text=update_response_xml) + schedule_item.state = TSC.ScheduleItem.State.Suspended + schedule_item.name = "newName" + schedule_item = server.schedules.update(schedule_item) + + assert TSC.ScheduleItem.State.Suspended == schedule_item.state + assert "weekly-schedule-1" == schedule_item.name + + +def test_add_workbook(server: TSC.Server) -> None: + server.version = "2.8" + baseurl = f"{server.baseurl}/sites/{server.site_id}/schedules" + + workbook_response = WORKBOOK_GET_BY_ID_XML.read_text() + add_workbook_response = ADD_WORKBOOK_TO_SCHEDULE.read_text() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl + "/bar", text=workbook_response) + m.put(baseurl + "/foo/workbooks", text=add_workbook_response) + workbook = server.workbooks.get_by_id("bar") + result = server.schedules.add_to_schedule("foo", workbook=workbook) + assert 0 == len(result), "Added properly" + + +def test_add_workbook_with_warnings(server: TSC.Server) -> None: + server.version = "2.8" + baseurl = f"{server.baseurl}/sites/{server.site_id}/schedules" + + workbook_response = WORKBOOK_GET_BY_ID_XML.read_text() + add_workbook_response = ADD_WORKBOOK_TO_SCHEDULE_WITH_WARNINGS.read_text() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl + "/bar", text=workbook_response) + m.put(baseurl + "/foo/workbooks", text=add_workbook_response) + workbook = server.workbooks.get_by_id("bar") + result = server.schedules.add_to_schedule("foo", workbook=workbook) + assert 1 == len(result), "Not added properly" + assert 2 == len(result[0].warnings) + + +def test_add_datasource(server: TSC.Server) -> None: + server.version = "2.8" + baseurl = f"{server.baseurl}/sites/{server.site_id}/schedules" + + datasource_response = DATASOURCE_GET_BY_ID_XML.read_text() + add_datasource_response = ADD_DATASOURCE_TO_SCHEDULE.read_text() + with requests_mock.mock() as m: + m.get(server.datasources.baseurl + "/bar", text=datasource_response) + m.put(baseurl + "/foo/datasources", text=add_datasource_response) + datasource = server.datasources.get_by_id("bar") + result = server.schedules.add_to_schedule("foo", datasource=datasource) + assert 0 == len(result), "Added properly" + + +def test_add_flow(server: TSC.Server) -> None: + server.version = "3.3" + baseurl = f"{server.baseurl}/sites/{server.site_id}/schedules" + + flow_response = FLOW_GET_BY_ID_XML.read_text() + add_flow_response = ADD_FLOW_TO_SCHEDULE.read_text() + with requests_mock.mock() as m: + m.get(server.flows.baseurl + "/bar", text=flow_response) + m.put(baseurl + "/foo/flows", text=flow_response) + flow = server.flows.get_by_id("bar") + result = server.schedules.add_to_schedule("foo", flow=flow) + assert 0 == len(result), "Added properly" + + +def test_get_extract_refresh_tasks(server: TSC.Server) -> None: + server.version = "2.3" + + response_xml = GET_EXTRACT_TASKS_XML.read_text() + with requests_mock.mock() as m: + schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + baseurl = f"{server.baseurl}/sites/{server.site_id}/schedules/{schedule_id}/extracts" + m.get(baseurl, text=response_xml) + + extracts = server.schedules.get_extract_refresh_tasks(schedule_id) + + assert extracts is not None + assert isinstance(extracts[0], list) + assert 2 == len(extracts[0]) + assert "task1" == extracts[0][0].id From 661b53d85472bc6cfaf2589c9fbc3cd14da759e6 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:21:54 -0600 Subject: [PATCH 68/98] chore: pytestify server info (#1698) * fix: black ci errors * chore: pytestify server info --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_server_info.py | 143 ++++++++++++++++++++------------------- 1 file changed, 75 insertions(+), 68 deletions(-) diff --git a/test/test_server_info.py b/test/test_server_info.py index fa1472c9a..af911508f 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -1,75 +1,82 @@ -import os.path +from pathlib import Path import unittest +import pytest import requests_mock import tableauserverclient as TSC from tableauserverclient.server.endpoint.exceptions import NonXMLResponseError -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") - -SERVER_INFO_GET_XML = os.path.join(TEST_ASSET_DIR, "server_info_get.xml") -SERVER_INFO_25_XML = os.path.join(TEST_ASSET_DIR, "server_info_25.xml") -SERVER_INFO_404 = os.path.join(TEST_ASSET_DIR, "server_info_404.xml") -SERVER_INFO_AUTH_INFO_XML = os.path.join(TEST_ASSET_DIR, "server_info_auth_info.xml") -SERVER_INFO_WRONG_SITE = os.path.join(TEST_ASSET_DIR, "server_info_wrong_site.html") - - -class ServerInfoTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server("http://test", False) - self.baseurl = self.server.server_info.baseurl - self.server.version = "2.4" - - def test_server_info_get(self): - with open(SERVER_INFO_GET_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.server.server_info.baseurl, text=response_xml) - actual = self.server.server_info.get() - - self.assertEqual("10.1.0", actual.product_version) - self.assertEqual("10100.16.1024.2100", actual.build_number) - self.assertEqual("3.10", actual.rest_api_version) - - def test_server_info_use_highest_version_downgrades(self): - with open(SERVER_INFO_AUTH_INFO_XML, "rb") as f: - # This is the auth.xml endpoint present back to 9.0 Servers - auth_response_xml = f.read().decode("utf-8") - with open(SERVER_INFO_404, "rb") as f: - # 10.1 serverInfo response - si_response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - # Return a 404 for serverInfo so we can pretend this is an old Server - m.get(self.server.server_address + "/api/2.4/serverInfo", text=si_response_xml, status_code=404) - m.get(self.server.server_address + "/auth?format=xml", text=auth_response_xml) - self.server.use_server_version() - # does server-version[9.2] lookup in PRODUCT_TO_REST_VERSION - self.assertEqual(self.server.version, "2.2") - - def test_server_info_use_highest_version_upgrades(self): - with open(SERVER_INFO_GET_XML, "rb") as f: - si_response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.server.server_address + "/api/2.8/serverInfo", text=si_response_xml) - # Pretend we're old - self.server.version = "2.8" - self.server.use_server_version() - # Did we upgrade to 3.10? - self.assertEqual(self.server.version, "3.10") - - def test_server_use_server_version_flag(self): - with open(SERVER_INFO_25_XML, "rb") as f: - si_response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get("http://test/api/2.4/serverInfo", text=si_response_xml) - server = TSC.Server("http://test", use_server_version=True) - self.assertEqual(server.version, "2.5") - - def test_server_wrong_site(self): - with open(SERVER_INFO_WRONG_SITE, "rb") as f: - response = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.server.server_info.baseurl, text=response, status_code=404) - with self.assertRaises(NonXMLResponseError): - self.server.server_info.get() +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +SERVER_INFO_GET_XML = TEST_ASSET_DIR / "server_info_get.xml" +SERVER_INFO_25_XML = TEST_ASSET_DIR / "server_info_25.xml" +SERVER_INFO_404 = TEST_ASSET_DIR / "server_info_404.xml" +SERVER_INFO_AUTH_INFO_XML = TEST_ASSET_DIR / "server_info_auth_info.xml" +SERVER_INFO_WRONG_SITE = TEST_ASSET_DIR / "server_info_wrong_site.html" + + +@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" + server.version = "2.4" + + return server + + +def test_server_info_get(server: TSC.Server) -> None: + response_xml = SERVER_INFO_GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.server_info.baseurl, text=response_xml) + actual = server.server_info.get() + + assert actual is not None + assert "10.1.0" == actual.product_version + assert "10100.16.1024.2100" == actual.build_number + assert "3.10" == actual.rest_api_version + + +def test_server_info_use_highest_version_downgrades(server: TSC.Server) -> None: + # This is the auth.xml endpoint present back to 9.0 Servers + auth_response_xml = SERVER_INFO_AUTH_INFO_XML.read_text() + # 10.1 serverInfo response + si_response_xml = SERVER_INFO_404.read_text() + with requests_mock.mock() as m: + # Return a 404 for serverInfo so we can pretend this is an old Server + m.get(server.server_address + "/api/2.4/serverInfo", text=si_response_xml, status_code=404) + m.get(server.server_address + "/auth?format=xml", text=auth_response_xml) + server.use_server_version() + # does server-version[9.2] lookup in PRODUCT_TO_REST_VERSION + assert server.version == "2.2" + + +def test_server_info_use_highest_version_upgrades(server: TSC.Server) -> None: + si_response_xml = SERVER_INFO_GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.server_address + "/api/2.8/serverInfo", text=si_response_xml) + # Pretend we're old + server.version = "2.8" + server.use_server_version() + # Did we upgrade to 3.10? + assert server.version == "3.10" + + +def test_server_use_server_version_flag(server: TSC.Server) -> None: + si_response_xml = SERVER_INFO_25_XML.read_text() + with requests_mock.mock() as m: + m.get("http://test/api/2.4/serverInfo", text=si_response_xml) + server = TSC.Server("http://test", use_server_version=True) + assert server.version == "2.5" + + +def test_server_wrong_site(server: TSC.Server) -> None: + response = SERVER_INFO_WRONG_SITE.read_text() + with requests_mock.mock() as m: + m.get(server.server_info.baseurl, text=response, status_code=404) + with pytest.raises(NonXMLResponseError): + server.server_info.get() From 4daa34ef214506ad02b2eaed1b78139dd5a8a634 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:22:16 -0600 Subject: [PATCH 69/98] chore: pytestify views (#1707) * fix: black ci errors * chore: pytestify views * chore: pytestify view acceleration --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_view.py | 1012 ++++++++++++++++---------------- test/test_view_acceleration.py | 229 ++++---- 2 files changed, 623 insertions(+), 618 deletions(-) diff --git a/test/test_view.py b/test/test_view.py index ee6d518de..e032ed569 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -1,6 +1,6 @@ -import os -import unittest +from pathlib import Path +import pytest import requests_mock import tableauserverclient as TSC @@ -8,511 +8,515 @@ from tableauserverclient.datetime_helpers import format_datetime, parse_datetime from tableauserverclient.server.endpoint.exceptions import UnsupportedAttributeError -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") - -ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "view_add_tags.xml") -GET_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml") -GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "view_get_all_fields.xml") -GET_XML_ID = os.path.join(TEST_ASSET_DIR, "view_get_id.xml") -GET_XML_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_usage.xml") -GET_XML_ID_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_id_usage.xml") -POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png") -POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") -POPULATE_CSV = os.path.join(TEST_ASSET_DIR, "populate_csv.csv") -POPULATE_EXCEL = os.path.join(TEST_ASSET_DIR, "populate_excel.xlsx") -POPULATE_PERMISSIONS_XML = os.path.join(TEST_ASSET_DIR, "view_populate_permissions.xml") -UPDATE_PERMISSIONS = os.path.join(TEST_ASSET_DIR, "view_update_permissions.xml") -UPDATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_update.xml") - - -class ViewTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server("http://test", False) - self.server.version = "3.2" - - # Fake sign in - self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - - self.baseurl = self.server.views.baseurl - self.siteurl = self.server.views.siteurl - - 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_views, pagination_item = self.server.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.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", all_views[0].project_id) - self.assertEqual({"tag1", "tag2"}, all_views[0].tags) - self.assertIsNone(all_views[0].created_at) - self.assertIsNone(all_views[0].updated_at) - self.assertIsNone(all_views[0].sheet_type) - - self.assertEqual("fd252f73-593c-4c4e-8584-c032b8022adc", all_views[1].id) - self.assertEqual("Overview", all_views[1].name) - self.assertEqual("Superstore/sheets/Overview", all_views[1].content_url) - 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("5b534f74-3226-11e8-b47a-cb2e00f738a3", all_views[1].project_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.assertEqual("story", all_views[1].sheet_type) - - 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 = self.server.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) - self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook_id) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner_id) - self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.project_id) - self.assertEqual({"tag1", "tag2"}, view.tags) - self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) - self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) - self.assertEqual("story", view.sheet_type) - - def test_get_by_id_usage(self) -> None: - with open(GET_XML_ID_USAGE, "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?includeUsageStatistics=true", text=response_xml) - view = self.server.views.get_by_id("d79634e1-6063-4ec9-95ff-50acbf609ff5", usage=True) - - self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", view.id) - self.assertEqual("ENDANGERED SAFARI", view.name) - self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", view.content_url) - self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook_id) - self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner_id) - self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.project_id) - self.assertEqual({"tag1", "tag2"}, view.tags) - self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) - self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) - self.assertEqual("story", view.sheet_type) - self.assertEqual(7, view.total_views) - - def test_get_by_id_missing_id(self) -> None: - self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.get_by_id, None) - - def test_get_with_usage(self) -> None: - with open(GET_XML_USAGE, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "?includeUsageStatistics=true", text=response_xml) - all_views, pagination_item = self.server.views.get(usage=True) - - self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", all_views[0].id) - self.assertEqual(7, all_views[0].total_views) - self.assertIsNone(all_views[0].created_at) - self.assertIsNone(all_views[0].updated_at) - self.assertIsNone(all_views[0].sheet_type) - - self.assertEqual("fd252f73-593c-4c4e-8584-c032b8022adc", all_views[1].id) - self.assertEqual(13, all_views[1].total_views) - 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.assertEqual("story", all_views[1].sheet_type) - - def test_get_with_usage_and_filter(self) -> None: - with open(GET_XML_USAGE, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "?includeUsageStatistics=true&filter=name:in:[foo,bar]", text=response_xml) - options = TSC.RequestOptions() - options.filter.add( - TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, ["foo", "bar"]) - ) - all_views, pagination_item = self.server.views.get(req_options=options, usage=True) - - self.assertEqual("ENDANGERED SAFARI", all_views[0].name) - self.assertEqual(7, all_views[0].total_views) - self.assertEqual("Overview", all_views[1].name) - self.assertEqual(13, all_views[1].total_views) - - def test_get_before_signin(self) -> None: - self.server._auth_token = None - self.assertRaises(TSC.NotSignedInError, self.server.views.get) - - 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.siteurl + "/workbooks/3cc6cd06-89ce-4fdc-b935-5294135d6d42/" - "views/d79634e1-6063-4ec9-95ff-50acbf609ff5/previewImage", - content=response, - ) - single_view = TSC.ViewItem() - single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - single_view._workbook_id = "3cc6cd06-89ce-4fdc-b935-5294135d6d42" - self.server.views.populate_preview_image(single_view) - self.assertEqual(response, single_view.preview_image) - - def test_populate_preview_image_missing_id(self) -> None: +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +ADD_TAGS_XML = TEST_ASSET_DIR / "view_add_tags.xml" +GET_XML = TEST_ASSET_DIR / "view_get.xml" +GET_XML_ALL_FIELDS = TEST_ASSET_DIR / "view_get_all_fields.xml" +GET_XML_ID = TEST_ASSET_DIR / "view_get_id.xml" +GET_XML_USAGE = TEST_ASSET_DIR / "view_get_usage.xml" +GET_XML_ID_USAGE = TEST_ASSET_DIR / "view_get_id_usage.xml" +POPULATE_PREVIEW_IMAGE = TEST_ASSET_DIR / "Sample View Image.png" +POPULATE_PDF = TEST_ASSET_DIR / "populate_pdf.pdf" +POPULATE_CSV = TEST_ASSET_DIR / "populate_csv.csv" +POPULATE_EXCEL = TEST_ASSET_DIR / "populate_excel.xlsx" +POPULATE_PERMISSIONS_XML = TEST_ASSET_DIR / "view_populate_permissions.xml" +UPDATE_PERMISSIONS = TEST_ASSET_DIR / "view_update_permissions.xml" +UPDATE_XML = TEST_ASSET_DIR / "workbook_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" + server.version = "3.2" + + return server + + +def test_get(server: TSC.Server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.views.baseurl, text=response_xml) + all_views, pagination_item = server.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 "5241e88d-d384-4fd7-9c2f-648b5247efc5" == all_views[0].project_id + assert {"tag1", "tag2"} == all_views[0].tags + assert all_views[0].created_at is None + assert all_views[0].updated_at is None + assert all_views[0].sheet_type is None + + assert "fd252f73-593c-4c4e-8584-c032b8022adc" == all_views[1].id + assert "Overview" == all_views[1].name + assert "Superstore/sheets/Overview" == all_views[1].content_url + assert "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" == all_views[1].workbook_id + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == all_views[1].owner_id + assert "5b534f74-3226-11e8-b47a-cb2e00f738a3" == all_views[1].project_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 "story" == all_views[1].sheet_type + + +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.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5", text=response_xml) + view = server.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 + assert "3cc6cd06-89ce-4fdc-b935-5294135d6d42" == view.workbook_id + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == view.owner_id + assert "5241e88d-d384-4fd7-9c2f-648b5247efc5" == view.project_id + assert {"tag1", "tag2"} == view.tags + assert "2002-05-30T09:00:00Z" == format_datetime(view.created_at) + assert "2002-06-05T08:00:59Z" == format_datetime(view.updated_at) + assert "story" == view.sheet_type + + +def test_get_by_id_usage(server: TSC.Server) -> None: + response_xml = GET_XML_ID_USAGE.read_text() + with requests_mock.mock() as m: + m.get( + server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5?includeUsageStatistics=true", + text=response_xml, + ) + view = server.views.get_by_id("d79634e1-6063-4ec9-95ff-50acbf609ff5", usage=True) + + assert "d79634e1-6063-4ec9-95ff-50acbf609ff5" == view.id + assert "ENDANGERED SAFARI" == view.name + assert "SafariSample/sheets/ENDANGEREDSAFARI" == view.content_url + assert "3cc6cd06-89ce-4fdc-b935-5294135d6d42" == view.workbook_id + assert "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" == view.owner_id + assert "5241e88d-d384-4fd7-9c2f-648b5247efc5" == view.project_id + assert {"tag1", "tag2"} == view.tags + assert "2002-05-30T09:00:00Z" == format_datetime(view.created_at) + assert "2002-06-05T08:00:59Z" == format_datetime(view.updated_at) + assert "story" == view.sheet_type + assert 7 == view.total_views + + +def test_get_by_id_missing_id(server: TSC.Server) -> None: + with pytest.raises(TSC.MissingRequiredFieldError): + server.views.get_by_id(None) + + +def test_get_with_usage(server: TSC.Server) -> None: + response_xml = GET_XML_USAGE.read_text() + with requests_mock.mock() as m: + m.get(server.views.baseurl + "?includeUsageStatistics=true", text=response_xml) + all_views, pagination_item = server.views.get(usage=True) + + assert "d79634e1-6063-4ec9-95ff-50acbf609ff5" == all_views[0].id + assert 7 == all_views[0].total_views + assert all_views[0].created_at is None + assert all_views[0].updated_at is None + assert all_views[0].sheet_type is None + + assert "fd252f73-593c-4c4e-8584-c032b8022adc" == all_views[1].id + assert 13 == all_views[1].total_views + 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 "story" == all_views[1].sheet_type + + +def test_get_with_usage_and_filter(server: TSC.Server) -> None: + response_xml = GET_XML_USAGE.read_text() + with requests_mock.mock() as m: + m.get(server.views.baseurl + "?includeUsageStatistics=true&filter=name:in:[foo,bar]", text=response_xml) + options = TSC.RequestOptions() + options.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, ["foo", "bar"])) + all_views, pagination_item = server.views.get(req_options=options, usage=True) + + assert "ENDANGERED SAFARI" == all_views[0].name + assert 7 == all_views[0].total_views + assert "Overview" == all_views[1].name + assert 13 == all_views[1].total_views + + +def test_get_before_signin(server: TSC.Server) -> None: + server._auth_token = None + with pytest.raises(TSC.NotSignedInError): + server.views.get() + + +def test_populate_preview_image(server: TSC.Server) -> None: + response = POPULATE_PREVIEW_IMAGE.read_bytes() + with requests_mock.mock() as m: + m.get( + server.views.siteurl + "/workbooks/3cc6cd06-89ce-4fdc-b935-5294135d6d42/" + "views/d79634e1-6063-4ec9-95ff-50acbf609ff5/previewImage", + content=response, + ) single_view = TSC.ViewItem() single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.populate_preview_image, single_view) - - single_view._id = None single_view._workbook_id = "3cc6cd06-89ce-4fdc-b935-5294135d6d42" - self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.populate_preview_image, single_view) - - 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.ViewItem() - single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - self.server.views.populate_image(single_view) - self.assertEqual(response, single_view.image) - - def test_populate_image_unsupported(self) -> None: - self.server.version = "3.8" - 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?vizWidth=1920&vizHeight=1080", - content=response, - ) - single_view = TSC.ViewItem() - single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - - req_option = TSC.ImageRequestOptions(viz_width=1920, viz_height=1080) - - with self.assertRaises(UnsupportedAttributeError): - self.server.views.populate_image(single_view, req_option) - - def test_populate_image_viz_dimensions(self) -> None: - self.server.version = "3.23" - self.baseurl = self.server.views.baseurl - 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?vizWidth=1920&vizHeight=1080", - content=response, - ) - single_view = TSC.ViewItem() - single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - - req_option = TSC.ImageRequestOptions(viz_width=1920, viz_height=1080) - - self.server.views.populate_image(single_view, req_option) - self.assertEqual(response, single_view.image) - - history = m.request_history - - 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.ViewItem() - single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage=10) - self.server.views.populate_image(single_view, req_option) - self.assertEqual(response, single_view.image) - - def test_populate_pdf(self) -> None: - with open(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, - ) - single_view = TSC.ViewItem() - single_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.views.populate_pdf(single_view, req_option) - self.assertEqual(response, single_view.pdf) - - def test_populate_csv(self) -> None: - with open(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) - single_view = TSC.ViewItem() - single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - request_option = TSC.CSVRequestOptions(maxage=1) - self.server.views.populate_csv(single_view, request_option) - - csv_file = b"".join(single_view.csv) - self.assertEqual(response, csv_file) - - def test_populate_csv_default_maxage(self) -> None: - with open(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) - single_view = TSC.ViewItem() - single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - self.server.views.populate_csv(single_view) - - csv_file = b"".join(single_view.csv) - self.assertEqual(response, csv_file) - - def test_populate_image_missing_id(self) -> None: + server.views.populate_preview_image(single_view) + assert response == single_view.preview_image + + +def test_populate_preview_image_missing_id(server: TSC.Server) -> None: + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + with pytest.raises(TSC.MissingRequiredFieldError): + server.views.populate_preview_image(single_view) + + single_view._id = None + single_view._workbook_id = "3cc6cd06-89ce-4fdc-b935-5294135d6d42" + with pytest.raises(TSC.MissingRequiredFieldError): + server.views.populate_preview_image(single_view) + + +def test_populate_image(server: TSC.Server) -> None: + response = POPULATE_PREVIEW_IMAGE.read_bytes() + with requests_mock.mock() as m: + m.get(server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image", content=response) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + server.views.populate_image(single_view) + assert response == single_view.image + + +def test_populate_image_unsupported(server: TSC.Server) -> None: + server.version = "3.8" + response = POPULATE_PREVIEW_IMAGE.read_bytes() + with requests_mock.mock() as m: + m.get( + server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?vizWidth=1920&vizHeight=1080", + content=response, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.ImageRequestOptions(viz_width=1920, viz_height=1080) + + with pytest.raises(UnsupportedAttributeError): + server.views.populate_image(single_view, req_option) + + +def test_populate_image_viz_dimensions(server: TSC.Server) -> None: + server.version = "3.23" + response = POPULATE_PREVIEW_IMAGE.read_bytes() + with requests_mock.mock() as m: + m.get( + server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?vizWidth=1920&vizHeight=1080", + content=response, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.ImageRequestOptions(viz_width=1920, viz_height=1080) + + server.views.populate_image(single_view, req_option) + assert response == single_view.image + + history = m.request_history + + +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.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?resolution=high&maxAge=10", + content=response, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + req_option = TSC.ImageRequestOptions(imageresolution=TSC.ImageRequestOptions.Resolution.High, maxage=10) + server.views.populate_image(single_view, req_option) + assert response == single_view.image + + +def test_populate_pdf(server: TSC.Server) -> None: + response = POPULATE_PDF.read_bytes() + with requests_mock.mock() as m: + m.get( + server.views.baseurl + + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait&maxAge=5", + content=response, + ) + single_view = TSC.ViewItem() + single_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.views.populate_pdf(single_view, req_option) + assert response == single_view.pdf + + +def test_populate_csv(server: TSC.Server) -> None: + response = POPULATE_CSV.read_bytes() + with requests_mock.mock() as m: + m.get(server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data?maxAge=1", content=response) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + request_option = TSC.CSVRequestOptions(maxage=1) + server.views.populate_csv(single_view, request_option) + + csv_file = b"".join(single_view.csv) + assert response == csv_file + + +def test_populate_csv_default_maxage(server: TSC.Server) -> None: + response = POPULATE_CSV.read_bytes() + with requests_mock.mock() as m: + m.get(server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data", content=response) single_view = TSC.ViewItem() - single_view._id = None - self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.populate_image, single_view) - - 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 + "/e490bec4-2652-4fda-8c4e-f087db6fa328/permissions", text=response_xml) - single_view = TSC.ViewItem() - single_view._id = "e490bec4-2652-4fda-8c4e-f087db6fa328" - - self.server.views.populate_permissions(single_view) - permissions = single_view.permissions - - self.assertEqual(permissions[0].grantee.tag_name, "group") - self.assertEqual(permissions[0].grantee.id, "c8f2773a-c83a-11e8-8c8f-33e6d787b506") - self.assertDictEqual( - permissions[0].capabilities, - { - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, - }, - ) - - def test_add_permissions(self) -> None: - with open(UPDATE_PERMISSIONS, "rb") as f: - response_xml = f.read().decode("utf-8") + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + server.views.populate_csv(single_view) + + csv_file = b"".join(single_view.csv) + assert response == csv_file + + +def test_populate_image_missing_id(server: TSC.Server) -> None: + single_view = TSC.ViewItem() + single_view._id = None + with pytest.raises(TSC.MissingRequiredFieldError): + server.views.populate_image(single_view) + +def test_populate_permissions(server: TSC.Server) -> None: + response_xml = POPULATE_PERMISSIONS_XML.read_text() + with requests_mock.mock() as m: + m.get(server.views.baseurl + "/e490bec4-2652-4fda-8c4e-f087db6fa328/permissions", text=response_xml) single_view = TSC.ViewItem() - single_view._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(self.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) - permissions = self.server.views.update_permissions(single_view, 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}) - - 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_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 + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags", text=add_tags_xml) - m.delete(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags/b", status_code=204) - m.delete(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags/d", status_code=204) - m.put(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5", text=update_xml) - single_view = TSC.ViewItem() - single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - single_view._initial_tags.update(["a", "b", "c", "d"]) - single_view.tags.update(["a", "c", "e"]) - updated_view = self.server.views.update(single_view) - - self.assertEqual(single_view.tags, updated_view.tags) - self.assertEqual(single_view._initial_tags, updated_view._initial_tags) - - def test_populate_excel(self) -> None: - self.server.version = "3.8" - self.baseurl = self.server.views.baseurl - with open(POPULATE_EXCEL, "rb") as f: - response = f.read() - with requests_mock.mock() as m: - m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/crosstab/excel?maxAge=1", content=response) - single_view = TSC.ViewItem() - single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - request_option = TSC.ExcelRequestOptions(maxage=1) - self.server.views.populate_excel(single_view, request_option) - - excel_file = b"".join(single_view.excel) - self.assertEqual(response, excel_file) - - def test_filter_excel(self) -> None: - self.server.version = "3.8" - self.baseurl = self.server.views.baseurl - with open(POPULATE_EXCEL, "rb") as f: - response = f.read() - with requests_mock.mock() as m: - m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/crosstab/excel?maxAge=1", content=response) - single_view = TSC.ViewItem() - single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - request_option = TSC.ExcelRequestOptions(maxage=1) - request_option.vf("stuff", "1") - self.server.views.populate_excel(single_view, request_option) - - excel_file = b"".join(single_view.excel) - self.assertEqual(response, excel_file) - - def test_pdf_height(self) -> None: - self.server.version = "3.8" - self.baseurl = self.server.views.baseurl - with open(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, - ) - single_view = TSC.ViewItem() - single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" - - req_option = TSC.PDFRequestOptions( - viz_height=1080, - viz_width=1920, - ) - - self.server.views.populate_pdf(single_view, req_option) - self.assertEqual(response, single_view.pdf) - - def test_pdf_errors(self) -> None: - req_option = TSC.PDFRequestOptions(viz_height=1080) - with self.assertRaises(ValueError): - req_option.get_query_params() - req_option = TSC.PDFRequestOptions(viz_width=1920) - with self.assertRaises(ValueError): - req_option.get_query_params() - - def test_view_get_all_fields(self) -> None: - self.server.version = "3.21" - self.baseurl = self.server.views.baseurl - with open(GET_XML_ALL_FIELDS) as f: - response_xml = f.read() - - ro = TSC.RequestOptions() - ro.all_fields = True - - with requests_mock.mock() as m: - m.get(f"{self.baseurl}?fields=_all_", text=response_xml) - views, _ = self.server.views.get(req_options=ro) - - assert views[0].id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534" - assert views[0].name == "Overview" - assert views[0].content_url == "Superstore/sheets/Overview" - assert views[0].created_at == parse_datetime("2024-02-14T04:42:09Z") - assert views[0].updated_at == parse_datetime("2024-02-14T04:42:09Z") - assert views[0].sheet_type == "dashboard" - assert views[0].favorites_total == 0 - assert views[0].view_url_name == "Overview" - assert isinstance(views[0].workbook, TSC.WorkbookItem) - assert views[0].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" - assert views[0].workbook.name == "Superstore" - assert views[0].workbook.content_url == "Superstore" - assert views[0].workbook.show_tabs - assert views[0].workbook.size == 2 - assert views[0].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") - assert views[0].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") - assert views[0].workbook.sheet_count == 9 - assert not views[0].workbook.has_extracts - assert isinstance(views[0].owner, TSC.UserItem) - assert views[0].owner.email == "bob@example.com" - assert views[0].owner.fullname == "Bob" - assert views[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" - assert views[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") - assert views[0].owner.name == "bob@example.com" - assert views[0].owner.site_role == "SiteAdministratorCreator" - assert isinstance(views[0].project, TSC.ProjectItem) - assert views[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" - assert views[0].project.name == "Samples" - assert views[0].project.description == "This project includes automatically uploaded samples." - assert views[0].total_views == 0 - assert isinstance(views[0].location, TSC.LocationItem) - assert views[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" - assert views[0].location.type == "Project" - assert views[1].id == "2a3fd19d-9129-413d-9ff7-9dfc36bf7f7e" - assert views[1].name == "Product" - assert views[1].content_url == "Superstore/sheets/Product" - assert views[1].created_at == parse_datetime("2024-02-14T04:42:09Z") - assert views[1].updated_at == parse_datetime("2024-02-14T04:42:09Z") - assert views[1].sheet_type == "dashboard" - assert views[1].favorites_total == 0 - assert views[1].view_url_name == "Product" - assert isinstance(views[1].workbook, TSC.WorkbookItem) - assert views[1].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" - assert views[1].workbook.name == "Superstore" - assert views[1].workbook.content_url == "Superstore" - assert views[1].workbook.show_tabs - assert views[1].workbook.size == 2 - assert views[1].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") - assert views[1].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") - assert views[1].workbook.sheet_count == 9 - assert not views[1].workbook.has_extracts - assert isinstance(views[1].owner, TSC.UserItem) - assert views[1].owner.email == "bob@example.com" - assert views[1].owner.fullname == "Bob" - assert views[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" - assert views[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") - assert views[1].owner.name == "bob@example.com" - assert views[1].owner.site_role == "SiteAdministratorCreator" - assert isinstance(views[1].project, TSC.ProjectItem) - assert views[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" - assert views[1].project.name == "Samples" - assert views[1].project.description == "This project includes automatically uploaded samples." - assert views[1].total_views == 0 - assert isinstance(views[1].location, TSC.LocationItem) - assert views[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" - assert views[1].location.type == "Project" - assert views[2].id == "459eda9a-85e4-46bf-a2f2-62936bd2e99a" - assert views[2].name == "Customers" - assert views[2].content_url == "Superstore/sheets/Customers" - assert views[2].created_at == parse_datetime("2024-02-14T04:42:09Z") - assert views[2].updated_at == parse_datetime("2024-02-14T04:42:09Z") - assert views[2].sheet_type == "dashboard" - assert views[2].favorites_total == 0 - assert views[2].view_url_name == "Customers" - assert isinstance(views[2].workbook, TSC.WorkbookItem) - assert views[2].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" - assert views[2].workbook.name == "Superstore" - assert views[2].workbook.content_url == "Superstore" - assert views[2].workbook.show_tabs - assert views[2].workbook.size == 2 - assert views[2].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") - assert views[2].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") - assert views[2].workbook.sheet_count == 9 - assert not views[2].workbook.has_extracts - assert isinstance(views[2].owner, TSC.UserItem) - assert views[2].owner.email == "bob@example.com" - assert views[2].owner.fullname == "Bob" - assert views[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" - assert views[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") - assert views[2].owner.name == "bob@example.com" - assert views[2].owner.site_role == "SiteAdministratorCreator" - assert isinstance(views[2].project, TSC.ProjectItem) - assert views[2].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" - assert views[2].project.name == "Samples" - assert views[2].project.description == "This project includes automatically uploaded samples." - assert views[2].total_views == 0 - assert isinstance(views[2].location, TSC.LocationItem) - assert views[2].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" - assert views[2].location.type == "Project" + single_view._id = "e490bec4-2652-4fda-8c4e-f087db6fa328" + + server.views.populate_permissions(single_view) + permissions = single_view.permissions + + assert permissions[0].grantee.tag_name == "group" + assert permissions[0].grantee.id == "c8f2773a-c83a-11e8-8c8f-33e6d787b506" + assert permissions[0].capabilities == { + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.AddComment: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportImage: TSC.Permission.Mode.Allow, + } + + +def test_add_permissions(server: TSC.Server) -> None: + response_xml = UPDATE_PERMISSIONS.read_text() + + single_view = TSC.ViewItem() + single_view._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.views.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) + permissions = server.views.update_permissions(single_view, 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_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.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags", text=add_tags_xml) + m.delete(server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags/b", status_code=204) + m.delete(server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/tags/d", status_code=204) + m.put(server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5", text=update_xml) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + single_view._initial_tags.update(["a", "b", "c", "d"]) + single_view.tags.update(["a", "c", "e"]) + updated_view = server.views.update(single_view) + + assert single_view.tags == updated_view.tags + assert single_view._initial_tags == updated_view._initial_tags + + +def test_populate_excel(server: TSC.Server) -> None: + server.version = "3.8" + response = POPULATE_EXCEL.read_bytes() + with requests_mock.mock() as m: + m.get(server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/crosstab/excel?maxAge=1", content=response) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + request_option = TSC.ExcelRequestOptions(maxage=1) + server.views.populate_excel(single_view, request_option) + + excel_file = b"".join(single_view.excel) + assert response == excel_file + + +def test_filter_excel(server: TSC.Server) -> None: + server.version = "3.8" + response = POPULATE_EXCEL.read_bytes() + with requests_mock.mock() as m: + m.get(server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/crosstab/excel?maxAge=1", content=response) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + request_option = TSC.ExcelRequestOptions(maxage=1) + request_option.vf("stuff", "1") + server.views.populate_excel(single_view, request_option) + + excel_file = b"".join(single_view.excel) + assert response == excel_file + + +def test_pdf_height(server: TSC.Server) -> None: + server.version = "3.8" + response = POPULATE_PDF.read_bytes() + with requests_mock.mock() as m: + m.get( + server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?vizHeight=1080&vizWidth=1920", + content=response, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.PDFRequestOptions( + viz_height=1080, + viz_width=1920, + ) + + server.views.populate_pdf(single_view, req_option) + assert response == single_view.pdf + + +def test_pdf_errors(server: TSC.Server) -> None: + req_option = TSC.PDFRequestOptions(viz_height=1080) + with pytest.raises(ValueError): + req_option.get_query_params() + req_option = TSC.PDFRequestOptions(viz_width=1920) + with pytest.raises(ValueError): + req_option.get_query_params() + + +def test_view_get_all_fields(server: TSC.Server) -> None: + server.version = "3.21" + response_xml = GET_XML_ALL_FIELDS.read_text() + + ro = TSC.RequestOptions() + ro.all_fields = True + + with requests_mock.mock() as m: + m.get(f"{server.views.baseurl}?fields=_all_", text=response_xml) + views, _ = server.views.get(req_options=ro) + + assert views[0].id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534" + assert views[0].name == "Overview" + assert views[0].content_url == "Superstore/sheets/Overview" + assert views[0].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].sheet_type == "dashboard" + assert views[0].favorites_total == 0 + assert views[0].view_url_name == "Overview" + assert isinstance(views[0].workbook, TSC.WorkbookItem) + assert views[0].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[0].workbook.name == "Superstore" + assert views[0].workbook.content_url == "Superstore" + assert views[0].workbook.show_tabs + assert views[0].workbook.size == 2 + assert views[0].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[0].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[0].workbook.sheet_count == 9 + assert not views[0].workbook.has_extracts + assert isinstance(views[0].owner, TSC.UserItem) + assert views[0].owner.email == "bob@example.com" + assert views[0].owner.fullname == "Bob" + assert views[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[0].owner.name == "bob@example.com" + assert views[0].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[0].project, TSC.ProjectItem) + assert views[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[0].project.name == "Samples" + assert views[0].project.description == "This project includes automatically uploaded samples." + assert views[0].total_views == 0 + assert isinstance(views[0].location, TSC.LocationItem) + assert views[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[0].location.type == "Project" + assert views[1].id == "2a3fd19d-9129-413d-9ff7-9dfc36bf7f7e" + assert views[1].name == "Product" + assert views[1].content_url == "Superstore/sheets/Product" + assert views[1].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].sheet_type == "dashboard" + assert views[1].favorites_total == 0 + assert views[1].view_url_name == "Product" + assert isinstance(views[1].workbook, TSC.WorkbookItem) + assert views[1].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[1].workbook.name == "Superstore" + assert views[1].workbook.content_url == "Superstore" + assert views[1].workbook.show_tabs + assert views[1].workbook.size == 2 + assert views[1].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[1].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[1].workbook.sheet_count == 9 + assert not views[1].workbook.has_extracts + assert isinstance(views[1].owner, TSC.UserItem) + assert views[1].owner.email == "bob@example.com" + assert views[1].owner.fullname == "Bob" + assert views[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[1].owner.name == "bob@example.com" + assert views[1].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[1].project, TSC.ProjectItem) + assert views[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[1].project.name == "Samples" + assert views[1].project.description == "This project includes automatically uploaded samples." + assert views[1].total_views == 0 + assert isinstance(views[1].location, TSC.LocationItem) + assert views[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[1].location.type == "Project" + assert views[2].id == "459eda9a-85e4-46bf-a2f2-62936bd2e99a" + assert views[2].name == "Customers" + assert views[2].content_url == "Superstore/sheets/Customers" + assert views[2].created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].updated_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].sheet_type == "dashboard" + assert views[2].favorites_total == 0 + assert views[2].view_url_name == "Customers" + assert isinstance(views[2].workbook, TSC.WorkbookItem) + assert views[2].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df" + assert views[2].workbook.name == "Superstore" + assert views[2].workbook.content_url == "Superstore" + assert views[2].workbook.show_tabs + assert views[2].workbook.size == 2 + assert views[2].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z") + assert views[2].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z") + assert views[2].workbook.sheet_count == 9 + assert not views[2].workbook.has_extracts + assert isinstance(views[2].owner, TSC.UserItem) + assert views[2].owner.email == "bob@example.com" + assert views[2].owner.fullname == "Bob" + assert views[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert views[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z") + assert views[2].owner.name == "bob@example.com" + assert views[2].owner.site_role == "SiteAdministratorCreator" + assert isinstance(views[2].project, TSC.ProjectItem) + assert views[2].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[2].project.name == "Samples" + assert views[2].project.description == "This project includes automatically uploaded samples." + assert views[2].total_views == 0 + assert isinstance(views[2].location, TSC.LocationItem) + assert views[2].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" + assert views[2].location.type == "Project" diff --git a/test/test_view_acceleration.py b/test/test_view_acceleration.py index 766831b0a..cbd1dc194 100644 --- a/test/test_view_acceleration.py +++ b/test/test_view_acceleration.py @@ -1,119 +1,120 @@ -import os +from pathlib import Path import requests_mock -import unittest + +import pytest import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") - -GET_BY_ID_ACCELERATION_STATUS_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id_acceleration_status.xml") -POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_views.xml") -UPDATE_VIEWS_ACCELERATION_STATUS_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_views_acceleration_status.xml") -UPDATE_WORKBOOK_ACCELERATION_STATUS_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_acceleration_status.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_by_id(self) -> None: - with open(GET_BY_ID_ACCELERATION_STATUS_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) - self.assertEqual(True, single_workbook.views[0].data_acceleration_config["acceleration_enabled"]) - self.assertEqual("Enabled", single_workbook.views[0].data_acceleration_config["acceleration_status"]) - self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff9", single_workbook.views[1].id) - self.assertEqual("ENDANGERED SAFARI 2", single_workbook.views[1].name) - self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI2", single_workbook.views[1].content_url) - self.assertEqual(False, single_workbook.views[1].data_acceleration_config["acceleration_enabled"]) - self.assertEqual("Suspended", single_workbook.views[1].data_acceleration_config["acceleration_status"]) - - def test_update_workbook_acceleration(self) -> None: - with open(UPDATE_WORKBOOK_ACCELERATION_STATUS_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_acceleration_config = { - "acceleration_enabled": True, - "accelerate_now": False, - "last_updated_at": None, - "acceleration_status": None, - } - # update with parameter includeViewAccelerationStatus=True - single_workbook = self.server.workbooks.update(single_workbook, True) - - self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) - self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", single_workbook.project_id) - self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) - self.assertEqual(True, single_workbook.views[0].data_acceleration_config["acceleration_enabled"]) - self.assertEqual("Pending", single_workbook.views[0].data_acceleration_config["acceleration_status"]) - self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff9", single_workbook.views[1].id) - self.assertEqual("ENDANGERED SAFARI 2", single_workbook.views[1].name) - self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI2", single_workbook.views[1].content_url) - self.assertEqual(True, single_workbook.views[1].data_acceleration_config["acceleration_enabled"]) - self.assertEqual("Pending", single_workbook.views[1].data_acceleration_config["acceleration_status"]) - - def test_update_views_acceleration(self) -> None: - with open(POPULATE_VIEWS_XML, "rb") as f: - views_xml = f.read().decode("utf-8") - with open(UPDATE_VIEWS_ACCELERATION_STATUS_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=views_xml) - 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_acceleration_config = { - "acceleration_enabled": False, - "accelerate_now": False, - "last_updated_at": None, - "acceleration_status": None, - } - self.server.workbooks.populate_views(single_workbook) - single_workbook.views = [single_workbook.views[1], single_workbook.views[2]] - # update with parameter includeViewAccelerationStatus=True - single_workbook = self.server.workbooks.update(single_workbook, True) - - 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(False, views_list[0].data_acceleration_config["acceleration_enabled"]) - self.assertEqual("Disabled", views_list[0].data_acceleration_config["acceleration_status"]) - - self.assertEqual("2c1ab9d7-8d64-4cc6-b495-52e40c60c330", views_list[1].id) - self.assertEqual("Country ranks", views_list[1].name) - self.assertEqual(True, views_list[1].data_acceleration_config["acceleration_enabled"]) - self.assertEqual("Pending", views_list[1].data_acceleration_config["acceleration_status"]) - - self.assertEqual("0599c28c-6d82-457e-a453-e52c1bdb00f5", views_list[2].id) - self.assertEqual("Interest rates", views_list[2].name) - self.assertEqual(True, views_list[2].data_acceleration_config["acceleration_enabled"]) - self.assertEqual("Pending", views_list[2].data_acceleration_config["acceleration_status"]) +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +GET_BY_ID_ACCELERATION_STATUS_XML = TEST_ASSET_DIR / "workbook_get_by_id_acceleration_status.xml" +POPULATE_VIEWS_XML = TEST_ASSET_DIR / "workbook_populate_views.xml" +UPDATE_VIEWS_ACCELERATION_STATUS_XML = TEST_ASSET_DIR / "workbook_update_views_acceleration_status.xml" +UPDATE_WORKBOOK_ACCELERATION_STATUS_XML = TEST_ASSET_DIR / "workbook_update_acceleration_status.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_by_id(server: TSC.Server) -> None: + response_xml = GET_BY_ID_ACCELERATION_STATUS_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 single_workbook.show_tabs is False + 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 + assert single_workbook.views[0].data_acceleration_config["acceleration_enabled"] + assert "Enabled" == single_workbook.views[0].data_acceleration_config["acceleration_status"] + assert "d79634e1-6063-4ec9-95ff-50acbf609ff9" == single_workbook.views[1].id + assert "ENDANGERED SAFARI 2" == single_workbook.views[1].name + assert "SafariSample/sheets/ENDANGEREDSAFARI2" == single_workbook.views[1].content_url + assert single_workbook.views[1].data_acceleration_config["acceleration_enabled"] is False + assert "Suspended" == single_workbook.views[1].data_acceleration_config["acceleration_status"] + + +def test_update_workbook_acceleration(server: TSC.Server) -> None: + response_xml = UPDATE_WORKBOOK_ACCELERATION_STATUS_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_acceleration_config = { + "acceleration_enabled": True, + "accelerate_now": False, + "last_updated_at": None, + "acceleration_status": None, + } + # update with parameter includeViewAccelerationStatus=True + single_workbook = server.workbooks.update(single_workbook, True) + + assert "1f951daf-4061-451a-9df1-69a8062664f2" == single_workbook.id + assert "1d0304cd-3796-429f-b815-7258370b9b74" == single_workbook.project_id + assert "SafariSample/sheets/ENDANGEREDSAFARI" == single_workbook.views[0].content_url + assert single_workbook.views[0].data_acceleration_config["acceleration_enabled"] + assert "Pending" == single_workbook.views[0].data_acceleration_config["acceleration_status"] + assert "d79634e1-6063-4ec9-95ff-50acbf609ff9" == single_workbook.views[1].id + assert "ENDANGERED SAFARI 2" == single_workbook.views[1].name + assert "SafariSample/sheets/ENDANGEREDSAFARI2" == single_workbook.views[1].content_url + assert single_workbook.views[1].data_acceleration_config["acceleration_enabled"] + assert "Pending" == single_workbook.views[1].data_acceleration_config["acceleration_status"] + + +def test_update_views_acceleration(server: TSC.Server) -> None: + views_xml = POPULATE_VIEWS_XML.read_text() + response_xml = UPDATE_VIEWS_ACCELERATION_STATUS_XML.read_text() + with requests_mock.mock() as m: + m.get(server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/views", text=views_xml) + 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_acceleration_config = { + "acceleration_enabled": False, + "accelerate_now": False, + "last_updated_at": None, + "acceleration_status": None, + } + server.workbooks.populate_views(single_workbook) + single_workbook.views = [single_workbook.views[1], single_workbook.views[2]] + # update with parameter includeViewAccelerationStatus=True + single_workbook = server.workbooks.update(single_workbook, True) + + views_list = single_workbook.views + assert "097dbe13-de89-445f-b2c3-02f28bd010c1" == views_list[0].id + assert "GDP per capita" == views_list[0].name + assert views_list[0].data_acceleration_config["acceleration_enabled"] is False + assert "Disabled" == views_list[0].data_acceleration_config["acceleration_status"] + + assert "2c1ab9d7-8d64-4cc6-b495-52e40c60c330" == views_list[1].id + assert "Country ranks" == views_list[1].name + assert views_list[1].data_acceleration_config["acceleration_enabled"] + assert "Pending" == views_list[1].data_acceleration_config["acceleration_status"] + + assert "0599c28c-6d82-457e-a453-e52c1bdb00f5" == views_list[2].id + assert "Interest rates" == views_list[2].name + assert views_list[2].data_acceleration_config["acceleration_enabled"] + assert "Pending" == views_list[2].data_acceleration_config["acceleration_status"] From e2f02bc7b218838dabdadf1e010bf97a096d7c8f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:22:24 -0600 Subject: [PATCH 70/98] chore: pytestify task (#1706) * fix: black ci errors * chore: pytestify task --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_task.py | 352 ++++++++++++++++++++++++---------------------- 1 file changed, 181 insertions(+), 171 deletions(-) diff --git a/test/test_task.py b/test/test_task.py index 2d724b879..fb99d58e4 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -1,8 +1,7 @@ -import os -import unittest from datetime import time from pathlib import Path +import pytest import requests_mock import tableauserverclient as TSC @@ -11,179 +10,190 @@ TEST_ASSET_DIR = Path(__file__).parent / "assets" -GET_XML_NO_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_no_workbook_or_datasource.xml") -GET_XML_WITH_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook.xml") -GET_XML_WITH_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_datasource.xml") -GET_XML_WITH_WORKBOOK_AND_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook_and_datasource.xml") -GET_XML_DATAACCELERATION_TASK = os.path.join(TEST_ASSET_DIR, "tasks_with_dataacceleration_task.xml") -GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml") -GET_XML_CREATE_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_extract_task.xml") +GET_XML_NO_WORKBOOK = TEST_ASSET_DIR / "tasks_no_workbook_or_datasource.xml" +GET_XML_WITH_WORKBOOK = TEST_ASSET_DIR / "tasks_with_workbook.xml" +GET_XML_WITH_DATASOURCE = TEST_ASSET_DIR / "tasks_with_datasource.xml" +GET_XML_WITH_WORKBOOK_AND_DATASOURCE = TEST_ASSET_DIR / "tasks_with_workbook_and_datasource.xml" +GET_XML_DATAACCELERATION_TASK = TEST_ASSET_DIR / "tasks_with_dataacceleration_task.xml" +GET_XML_RUN_NOW_RESPONSE = TEST_ASSET_DIR / "tasks_run_now_response.xml" +GET_XML_CREATE_TASK_RESPONSE = TEST_ASSET_DIR / "tasks_create_extract_task.xml" GET_XML_WITHOUT_SCHEDULE = TEST_ASSET_DIR / "tasks_without_schedule.xml" GET_XML_WITH_INTERVAL = TEST_ASSET_DIR / "tasks_with_interval.xml" -class TaskTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server("http://test", False) - self.server.version = "3.19" - - # Fake Signin - self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - - # default task type is extractRefreshes - self.baseurl = "{}/{}".format(self.server.tasks.baseurl, "extractRefreshes") - - def test_get_tasks_with_no_workbook(self): - with open(GET_XML_NO_WORKBOOK, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl, text=response_xml) - all_tasks, pagination_item = self.server.tasks.get() - - task = all_tasks[0] - self.assertEqual(None, task.target) - - def test_get_tasks_with_workbook(self): - with open(GET_XML_WITH_WORKBOOK, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl, text=response_xml) - all_tasks, pagination_item = self.server.tasks.get() - - task = all_tasks[0] - self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) - self.assertEqual("workbook", task.target.type) - - def test_get_tasks_with_datasource(self): - with open(GET_XML_WITH_DATASOURCE, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl, text=response_xml) - all_tasks, pagination_item = self.server.tasks.get() - - task = all_tasks[0] - self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) - self.assertEqual("datasource", task.target.type) - - def test_get_tasks_with_workbook_and_datasource(self): - with open(GET_XML_WITH_WORKBOOK_AND_DATASOURCE, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl, text=response_xml) - all_tasks, pagination_item = self.server.tasks.get() - - self.assertEqual("workbook", all_tasks[0].target.type) - self.assertEqual("datasource", all_tasks[1].target.type) - self.assertEqual("workbook", all_tasks[2].target.type) - - def test_get_task_with_schedule(self): - with open(GET_XML_WITH_WORKBOOK, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl, text=response_xml) - all_tasks, pagination_item = self.server.tasks.get() - - task = all_tasks[0] - self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) - self.assertEqual("workbook", task.target.type) - self.assertEqual("b60b4efd-a6f7-4599-beb3-cb677e7abac1", task.schedule_id) - - def test_get_task_without_schedule(self): - with requests_mock.mock() as m: - m.get(self.baseurl, text=GET_XML_WITHOUT_SCHEDULE.read_text()) - all_tasks, pagination_item = self.server.tasks.get() - - task = all_tasks[0] - self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) - self.assertEqual("datasource", task.target.type) - - def test_get_task_with_interval(self): - with requests_mock.mock() as m: - m.get(self.baseurl, text=GET_XML_WITH_INTERVAL.read_text()) - all_tasks, pagination_item = self.server.tasks.get() - - task = all_tasks[0] - self.assertEqual("e4de0575-fcc7-4232-5659-be09bb8e7654", task.target.id) - self.assertEqual("datasource", task.target.type) - - def test_delete(self): - with requests_mock.mock() as m: - m.delete(self.baseurl + "/c7a9327e-1cda-4504-b026-ddb43b976d1d", status_code=204) - self.server.tasks.delete("c7a9327e-1cda-4504-b026-ddb43b976d1d") - - def test_delete_missing_id(self): - self.assertRaises(ValueError, self.server.tasks.delete, "") - - def test_get_materializeviews_tasks(self): - with open(GET_XML_DATAACCELERATION_TASK, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(f"{self.server.tasks.baseurl}/{TaskItem.Type.DataAcceleration}", text=response_xml) - all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.DataAcceleration) - - task = all_tasks[0] - self.assertEqual("a462c148-fc40-4670-a8e4-39b7f0c58c7f", task.target.id) - self.assertEqual("workbook", task.target.type) - self.assertEqual("b22190b4-6ac2-4eed-9563-4afc03444413", task.schedule_id) - self.assertEqual(parse_datetime("2019-12-09T22:30:00Z"), task.schedule_item.next_run_at) - self.assertEqual(parse_datetime("2019-12-09T20:45:04Z"), task.last_run_at) - self.assertEqual(TSC.TaskItem.Type.DataAcceleration, task.task_type) - - def test_delete_data_acceleration(self): - with requests_mock.mock() as m: - m.delete( - "{}/{}/{}".format( - self.server.tasks.baseurl, TaskItem.Type.DataAcceleration, "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" - ), - status_code=204, - ) - self.server.tasks.delete("c9cff7f9-309c-4361-99ff-d4ba8c9f5467", TaskItem.Type.DataAcceleration) - - def test_get_by_id(self): - with open(GET_XML_WITH_WORKBOOK, "rb") as f: - response_xml = f.read().decode("utf-8") - task_id = "f84901ac-72ad-4f9b-a87e-7a3500402ad6" - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{task_id}", text=response_xml) - task = self.server.tasks.get_by_id(task_id) - - self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) - self.assertEqual("workbook", task.target.type) - self.assertEqual("b60b4efd-a6f7-4599-beb3-cb677e7abac1", task.schedule_id) - self.assertEqual(TSC.TaskItem.Type.ExtractRefresh, task.task_type) - - def test_run_now(self): - task_id = "f84901ac-72ad-4f9b-a87e-7a3500402ad6" - task = TaskItem(task_id, TaskItem.Type.ExtractRefresh, 100) - with open(GET_XML_RUN_NOW_RESPONSE, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(f"{self.baseurl}/{task_id}/runNow", text=response_xml) - job_response_content = self.server.tasks.run(task).decode("utf-8") - - self.assertTrue("7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" in job_response_content) - self.assertTrue("RefreshExtract" in job_response_content) - - def test_create_extract_task(self): - monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) - monthly_schedule = TSC.ScheduleItem( - None, - None, - None, - None, - monthly_interval, - ) - target_item = TSC.Target("workbook_id", "workbook") +@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" + server.version = "3.19" + + return server + + +@pytest.fixture(scope="function") +def baseurl(server: TSC.Server) -> str: + return f"{server.tasks.baseurl}/extractRefreshes" + + +def test_get_tasks_with_no_workbook(server: TSC.Server, baseurl: str) -> None: + response_xml = GET_XML_NO_WORKBOOK.read_text() + with requests_mock.mock() as m: + m.get(baseurl, text=response_xml) + all_tasks, pagination_item = server.tasks.get() + + task = all_tasks[0] + assert task.target is None + + +def test_get_tasks_with_workbook(server: TSC.Server, baseurl: str) -> None: + response_xml = GET_XML_WITH_WORKBOOK.read_text() + with requests_mock.mock() as m: + m.get(baseurl, text=response_xml) + all_tasks, pagination_item = server.tasks.get() + + task = all_tasks[0] + assert "c7a9327e-1cda-4504-b026-ddb43b976d1d" == task.target.id + assert "workbook" == task.target.type + + +def test_get_tasks_with_datasource(server: TSC.Server, baseurl: str) -> None: + response_xml = GET_XML_WITH_DATASOURCE.read_text() + with requests_mock.mock() as m: + m.get(baseurl, text=response_xml) + all_tasks, pagination_item = server.tasks.get() + + task = all_tasks[0] + assert "c7a9327e-1cda-4504-b026-ddb43b976d1d" == task.target.id + assert "datasource" == task.target.type + + +def test_get_tasks_with_workbook_and_datasource(server: TSC.Server, baseurl: str) -> None: + response_xml = GET_XML_WITH_WORKBOOK_AND_DATASOURCE.read_text() + with requests_mock.mock() as m: + m.get(baseurl, text=response_xml) + all_tasks, pagination_item = server.tasks.get() + + assert "workbook" == all_tasks[0].target.type + assert "datasource" == all_tasks[1].target.type + assert "workbook" == all_tasks[2].target.type - task = TaskItem(None, "FullRefresh", None, schedule_item=monthly_schedule, target=target_item) - with open(GET_XML_CREATE_TASK_RESPONSE, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(f"{self.baseurl}", text=response_xml) - create_response_content = self.server.tasks.create(task).decode("utf-8") +def test_get_task_with_schedule(server: TSC.Server, baseurl: str) -> None: + response_xml = GET_XML_WITH_WORKBOOK.read_text() + with requests_mock.mock() as m: + m.get(baseurl, text=response_xml) + all_tasks, pagination_item = server.tasks.get() - self.assertTrue("task_id" in create_response_content) - self.assertTrue("workbook_id" in create_response_content) - self.assertTrue("FullRefresh" in create_response_content) + task = all_tasks[0] + assert "c7a9327e-1cda-4504-b026-ddb43b976d1d" == task.target.id + assert "workbook" == task.target.type + assert "b60b4efd-a6f7-4599-beb3-cb677e7abac1" == task.schedule_id + + +def test_get_task_without_schedule(server: TSC.Server, baseurl: str) -> None: + with requests_mock.mock() as m: + m.get(baseurl, text=GET_XML_WITHOUT_SCHEDULE.read_text()) + all_tasks, pagination_item = server.tasks.get() + + task = all_tasks[0] + assert "c7a9327e-1cda-4504-b026-ddb43b976d1d" == task.target.id + assert "datasource" == task.target.type + + +def test_get_task_with_interval(server: TSC.Server, baseurl: str) -> None: + with requests_mock.mock() as m: + m.get(baseurl, text=GET_XML_WITH_INTERVAL.read_text()) + all_tasks, pagination_item = server.tasks.get() + + task = all_tasks[0] + assert "e4de0575-fcc7-4232-5659-be09bb8e7654" == task.target.id + assert "datasource" == task.target.type + + +def test_delete(server: TSC.Server, baseurl: str) -> None: + with requests_mock.mock() as m: + m.delete(baseurl + "/c7a9327e-1cda-4504-b026-ddb43b976d1d", status_code=204) + server.tasks.delete("c7a9327e-1cda-4504-b026-ddb43b976d1d") + + +def test_delete_missing_id(server: TSC.Server, baseurl: str) -> None: + with pytest.raises(ValueError): + server.tasks.delete("") + + +def test_get_materializeviews_tasks(server: TSC.Server, baseurl: str) -> None: + response_xml = GET_XML_DATAACCELERATION_TASK.read_text() + with requests_mock.mock() as m: + m.get(f"{server.tasks.baseurl}/{TaskItem.Type.DataAcceleration}", text=response_xml) + all_tasks, pagination_item = server.tasks.get(task_type=TaskItem.Type.DataAcceleration) + + task = all_tasks[0] + assert "a462c148-fc40-4670-a8e4-39b7f0c58c7f" == task.target.id + assert "workbook" == task.target.type + assert "b22190b4-6ac2-4eed-9563-4afc03444413" == task.schedule_id + assert parse_datetime("2019-12-09T22:30:00Z") == task.schedule_item.next_run_at + assert parse_datetime("2019-12-09T20:45:04Z") == task.last_run_at + assert TSC.TaskItem.Type.DataAcceleration == task.task_type + + +def test_delete_data_acceleration(server: TSC.Server, baseurl: str) -> None: + with requests_mock.mock() as m: + m.delete( + "{}/{}/{}".format( + server.tasks.baseurl, TaskItem.Type.DataAcceleration, "c9cff7f9-309c-4361-99ff-d4ba8c9f5467" + ), + status_code=204, + ) + server.tasks.delete("c9cff7f9-309c-4361-99ff-d4ba8c9f5467", TaskItem.Type.DataAcceleration) + + +def test_get_by_id(server: TSC.Server, baseurl: str) -> None: + response_xml = GET_XML_WITH_WORKBOOK.read_text() + task_id = "f84901ac-72ad-4f9b-a87e-7a3500402ad6" + with requests_mock.mock() as m: + m.get(f"{baseurl}/{task_id}", text=response_xml) + task = server.tasks.get_by_id(task_id) + + assert "c7a9327e-1cda-4504-b026-ddb43b976d1d" == task.target.id + assert "workbook" == task.target.type + assert "b60b4efd-a6f7-4599-beb3-cb677e7abac1" == task.schedule_id + assert TSC.TaskItem.Type.ExtractRefresh == task.task_type + + +def test_run_now(server: TSC.Server, baseurl: str) -> None: + task_id = "f84901ac-72ad-4f9b-a87e-7a3500402ad6" + task = TaskItem(task_id, TaskItem.Type.ExtractRefresh, 100) + response_xml = GET_XML_RUN_NOW_RESPONSE.read_text() + with requests_mock.mock() as m: + m.post(f"{baseurl}/{task_id}/runNow", text=response_xml) + job_response_content = server.tasks.run(task).decode("utf-8") + + assert "7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" in job_response_content + assert "RefreshExtract" in job_response_content + + +def test_create_extract_task(server: TSC.Server, baseurl: str) -> None: + monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) + monthly_schedule = TSC.ScheduleItem( + None, # type: ignore[arg-type] + None, # type: ignore[arg-type] + None, # type: ignore[arg-type] + None, # type: ignore[arg-type] + monthly_interval, + ) + target_item = TSC.Target("workbook_id", "workbook") + + task = TaskItem(None, "FullRefresh", None, schedule_item=monthly_schedule, target=target_item) # type: ignore[arg-type] + + response_xml = GET_XML_CREATE_TASK_RESPONSE.read_text() + with requests_mock.mock() as m: + m.post(f"{baseurl}", text=response_xml) + create_response_content = server.tasks.create(task).decode("utf-8") + + assert "task_id" in create_response_content + assert "workbook_id" in create_response_content + assert "FullRefresh" in create_response_content From bb127e83b5ec33c8a10cd5e4e3513676cc6d62c4 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:22:36 -0600 Subject: [PATCH 71/98] chore: pytestify auth model (#1705) * fix: black ci errors * chore: pytestify auth model --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_tableauauth_model.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/test/test_tableauauth_model.py b/test/test_tableauauth_model.py index 195bcf0a9..17db52770 100644 --- a/test/test_tableauauth_model.py +++ b/test/test_tableauauth_model.py @@ -1,12 +1,8 @@ -import unittest +import pytest import tableauserverclient as TSC -class TableauAuthModelTests(unittest.TestCase): - def setUp(self): - self.auth = TSC.TableauAuth("user", "password", site_id="site1", user_id_to_impersonate="admin") - - def test_username_password_required(self): - with self.assertRaises(TypeError): - TSC.TableauAuth() +def test_username_password_required(): + with pytest.raises(TypeError): + TSC.TableauAuth() From ade2ca0dead32c996d1cd47c92492b201ed3b4ad Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:22:49 -0600 Subject: [PATCH 72/98] chore: pytestify table (#1703) * fix: black ci errors * chore: pytestify table --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_table.py | 116 ++++++++++++++++++++++++--------------------- 1 file changed, 61 insertions(+), 55 deletions(-) diff --git a/test/test_table.py b/test/test_table.py index 8c6c71f76..2f3c3c8d6 100644 --- a/test/test_table.py +++ b/test/test_table.py @@ -1,59 +1,65 @@ -import unittest +from pathlib import Path +import pytest import requests_mock import tableauserverclient as TSC -from ._utils import read_xml_asset - -GET_XML = "table_get.xml" -UPDATE_XML = "table_update.xml" - - -class TableTests(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.tables.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_tables, pagination_item = self.server.tables.get() - - self.assertEqual(4, pagination_item.total_available) - self.assertEqual("10224773-ecee-42ac-b822-d786b0b8e4d9", all_tables[0].id) - self.assertEqual("dim_Product", all_tables[0].name) - - self.assertEqual("53c77bc1-fb41-4342-a75a-f68ac0656d0d", all_tables[1].id) - self.assertEqual("customer", all_tables[1].name) - self.assertEqual("dbo", all_tables[1].schema) - self.assertEqual("9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0", all_tables[1].contact_id) - self.assertEqual(False, all_tables[1].certified) - - def test_update(self): - response_xml = read_xml_asset(UPDATE_XML) - with requests_mock.mock() as m: - m.put(self.baseurl + "/10224773-ecee-42ac-b822-d786b0b8e4d9", text=response_xml) - single_table = TSC.TableItem("test") - single_table._id = "10224773-ecee-42ac-b822-d786b0b8e4d9" - - single_table.contact_id = "8e1a8235-c9ee-4d61-ae82-2ffacceed8e0" - single_table.certified = True - single_table.certification_note = "Test" - single_table = self.server.tables.update(single_table) - - self.assertEqual("10224773-ecee-42ac-b822-d786b0b8e4d9", single_table.id) - self.assertEqual("8e1a8235-c9ee-4d61-ae82-2ffacceed8e0", single_table.contact_id) - self.assertEqual(True, single_table.certified) - self.assertEqual("Test", single_table.certification_note) - - def test_delete(self): - with requests_mock.mock() as m: - m.delete(self.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5", status_code=204) - self.server.tables.delete("0448d2ed-590d-4fa0-b272-a2a8a24555b5") + +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +GET_XML = TEST_ASSET_DIR / "table_get.xml" +UPDATE_XML = TEST_ASSET_DIR / "table_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" + server.version = "3.5" + + return server + + +def test_get(server: TSC.Server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.tables.baseurl, text=response_xml) + all_tables, pagination_item = server.tables.get() + + assert 4 == pagination_item.total_available + assert "10224773-ecee-42ac-b822-d786b0b8e4d9" == all_tables[0].id + assert "dim_Product" == all_tables[0].name + + assert "53c77bc1-fb41-4342-a75a-f68ac0656d0d" == all_tables[1].id + assert "customer" == all_tables[1].name + assert "dbo" == all_tables[1].schema + assert "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0" == all_tables[1].contact_id + assert False == all_tables[1].certified + + +def test_update(server: TSC.Server) -> None: + response_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.put(server.tables.baseurl + "/10224773-ecee-42ac-b822-d786b0b8e4d9", text=response_xml) + single_table = TSC.TableItem("test") + single_table._id = "10224773-ecee-42ac-b822-d786b0b8e4d9" + + single_table.contact_id = "8e1a8235-c9ee-4d61-ae82-2ffacceed8e0" + single_table.certified = True + single_table.certification_note = "Test" + single_table = server.tables.update(single_table) + + assert "10224773-ecee-42ac-b822-d786b0b8e4d9" == single_table.id + assert "8e1a8235-c9ee-4d61-ae82-2ffacceed8e0" == single_table.contact_id + assert True == single_table.certified + assert "Test" == single_table.certification_note + + +def test_delete(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.delete(server.tables.baseurl + "/0448d2ed-590d-4fa0-b272-a2a8a24555b5", status_code=204) + server.tables.delete("0448d2ed-590d-4fa0-b272-a2a8a24555b5") From fcc8c8e43ffb3e211a4d7879a5405ad07efdab69 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:22:58 -0600 Subject: [PATCH 73/98] chore: pytestify subscriptions (#1702) * fix: black ci errors * chore: pytestify subscriptions --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_subscription.py | 192 +++++++++++++++++++------------------- 1 file changed, 97 insertions(+), 95 deletions(-) diff --git a/test/test_subscription.py b/test/test_subscription.py index 45dcb0a1c..7c78cc57d 100644 --- a/test/test_subscription.py +++ b/test/test_subscription.py @@ -1,100 +1,102 @@ -import os -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") - -CREATE_XML = os.path.join(TEST_ASSET_DIR, "subscription_create.xml") -GET_XML = os.path.join(TEST_ASSET_DIR, "subscription_get.xml") -GET_XML_BY_ID = os.path.join(TEST_ASSET_DIR, "subscription_get_by_id.xml") - - -class SubscriptionTests(unittest.TestCase): - def setUp(self) -> None: - self.server = TSC.Server("http://test", False) - self.server.version = "2.6" - - # Fake Signin - self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - - self.baseurl = self.server.subscriptions.baseurl - - def test_get_subscriptions(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_subscriptions, pagination_item = self.server.subscriptions.get() - - self.assertEqual(2, pagination_item.total_available) - subscription = all_subscriptions[0] - self.assertEqual("382e9a6e-0c08-4a95-b6c1-c14df7bac3e4", subscription.id) - self.assertEqual("NOT FOUND!", subscription.message) - self.assertTrue(subscription.attach_image) - self.assertFalse(subscription.attach_pdf) - self.assertFalse(subscription.suspended) - self.assertFalse(subscription.send_if_view_empty) - self.assertIsNone(subscription.page_orientation) - self.assertIsNone(subscription.page_size_option) - self.assertEqual("Not Found Alert", subscription.subject) - self.assertEqual("cdd716ca-5818-470e-8bec-086885dbadee", subscription.target.id) - self.assertEqual("View", subscription.target.type) - self.assertEqual("c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e", subscription.user_id) - self.assertEqual("7617c389-cdca-4940-a66e-69956fcebf3e", subscription.schedule_id) - - subscription = all_subscriptions[1] - self.assertEqual("23cb7630-afc8-4c8e-b6cd-83ae0322ec66", subscription.id) - self.assertEqual("overview", subscription.message) - self.assertFalse(subscription.attach_image) - self.assertTrue(subscription.attach_pdf) - self.assertTrue(subscription.suspended) - self.assertTrue(subscription.send_if_view_empty) - self.assertEqual("PORTRAIT", subscription.page_orientation) - self.assertEqual("A5", subscription.page_size_option) - self.assertEqual("Last 7 Days", subscription.subject) - self.assertEqual("2e6b4e8f-22dd-4061-8f75-bf33703da7e5", subscription.target.id) - self.assertEqual("Workbook", subscription.target.type) - self.assertEqual("c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e", subscription.user_id) - self.assertEqual("3407cd38-7b39-4983-86a6-67a1506a5e3f", subscription.schedule_id) - - def test_get_subscription_by_id(self) -> None: - with open(GET_XML_BY_ID, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/382e9a6e-0c08-4a95-b6c1-c14df7bac3e4", text=response_xml) - subscription = self.server.subscriptions.get_by_id("382e9a6e-0c08-4a95-b6c1-c14df7bac3e4") - - self.assertEqual("382e9a6e-0c08-4a95-b6c1-c14df7bac3e4", subscription.id) - self.assertEqual("View", subscription.target.type) - self.assertEqual("cdd716ca-5818-470e-8bec-086885dbadee", subscription.target.id) - self.assertEqual("c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e", subscription.user_id) - self.assertEqual("Not Found Alert", subscription.subject) - self.assertEqual("7617c389-cdca-4940-a66e-69956fcebf3e", subscription.schedule_id) - - def test_create_subscription(self) -> None: - with open(CREATE_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) - - target_item = TSC.Target("960e61f2-1838-40b2-bba2-340c9492f943", "workbook") - new_subscription = TSC.SubscriptionItem( - "subject", "4906c453-d5ec-4972-9ff4-789b629bdfa2", "8d30c8de-0a5f-4bee-b266-c621b4f3eed0", target_item - ) - new_subscription = self.server.subscriptions.create(new_subscription) - - self.assertEqual("78e9318d-2d29-4d67-b60f-3f2f5fd89ecc", new_subscription.id) - self.assertEqual("sub_name", new_subscription.subject) - self.assertEqual("960e61f2-1838-40b2-bba2-340c9492f943", new_subscription.target.id) - self.assertEqual("Workbook", new_subscription.target.type) - self.assertEqual("4906c453-d5ec-4972-9ff4-789b629bdfa2", new_subscription.schedule_id) - self.assertEqual("8d30c8de-0a5f-4bee-b266-c621b4f3eed0", new_subscription.user_id) - - def test_delete_subscription(self) -> None: - with requests_mock.mock() as m: - m.delete(self.baseurl + "/78e9318d-2d29-4d67-b60f-3f2f5fd89ecc", status_code=204) - self.server.subscriptions.delete("78e9318d-2d29-4d67-b60f-3f2f5fd89ecc") +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +CREATE_XML = TEST_ASSET_DIR / "subscription_create.xml" +GET_XML = TEST_ASSET_DIR / "subscription_get.xml" +GET_XML_BY_ID = TEST_ASSET_DIR / "subscription_get_by_id.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" + server.version = "2.6" + + return server + + +def test_get_subscriptions(server: TSC.Server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.subscriptions.baseurl, text=response_xml) + all_subscriptions, pagination_item = server.subscriptions.get() + + assert 2 == pagination_item.total_available + subscription = all_subscriptions[0] + assert "382e9a6e-0c08-4a95-b6c1-c14df7bac3e4" == subscription.id + assert "NOT FOUND!" == subscription.message + assert subscription.attach_image is True + assert subscription.attach_pdf is False + assert subscription.suspended is False + assert subscription.send_if_view_empty is False + assert subscription.page_orientation is None + assert subscription.page_size_option is None + assert "Not Found Alert" == subscription.subject + assert "cdd716ca-5818-470e-8bec-086885dbadee" == subscription.target.id + assert "View" == subscription.target.type + assert "c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e" == subscription.user_id + assert "7617c389-cdca-4940-a66e-69956fcebf3e" == subscription.schedule_id + + subscription = all_subscriptions[1] + assert "23cb7630-afc8-4c8e-b6cd-83ae0322ec66" == subscription.id + assert "overview" == subscription.message + assert subscription.attach_image is False + assert subscription.attach_pdf is True + assert subscription.suspended is True + assert subscription.send_if_view_empty is True + assert "PORTRAIT" == subscription.page_orientation + assert "A5" == subscription.page_size_option + assert "Last 7 Days" == subscription.subject + assert "2e6b4e8f-22dd-4061-8f75-bf33703da7e5" == subscription.target.id + assert "Workbook" == subscription.target.type + assert "c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e" == subscription.user_id + assert "3407cd38-7b39-4983-86a6-67a1506a5e3f" == subscription.schedule_id + + +def test_get_subscription_by_id(server: TSC.Server) -> None: + response_xml = GET_XML_BY_ID.read_text() + with requests_mock.mock() as m: + m.get(server.subscriptions.baseurl + "/382e9a6e-0c08-4a95-b6c1-c14df7bac3e4", text=response_xml) + subscription = server.subscriptions.get_by_id("382e9a6e-0c08-4a95-b6c1-c14df7bac3e4") + + assert "382e9a6e-0c08-4a95-b6c1-c14df7bac3e4" == subscription.id + assert "View" == subscription.target.type + assert "cdd716ca-5818-470e-8bec-086885dbadee" == subscription.target.id + assert "c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e" == subscription.user_id + assert "Not Found Alert" == subscription.subject + assert "7617c389-cdca-4940-a66e-69956fcebf3e" == subscription.schedule_id + + +def test_create_subscription(server: TSC.Server) -> None: + response_xml = CREATE_XML.read_text() + with requests_mock.mock() as m: + m.post(server.subscriptions.baseurl, text=response_xml) + + target_item = TSC.Target("960e61f2-1838-40b2-bba2-340c9492f943", "workbook") + new_subscription = TSC.SubscriptionItem( + "subject", "4906c453-d5ec-4972-9ff4-789b629bdfa2", "8d30c8de-0a5f-4bee-b266-c621b4f3eed0", target_item + ) + new_subscription = server.subscriptions.create(new_subscription) + + assert "78e9318d-2d29-4d67-b60f-3f2f5fd89ecc" == new_subscription.id + assert "sub_name" == new_subscription.subject + assert "960e61f2-1838-40b2-bba2-340c9492f943" == new_subscription.target.id + assert "Workbook" == new_subscription.target.type + assert "4906c453-d5ec-4972-9ff4-789b629bdfa2" == new_subscription.schedule_id + assert "8d30c8de-0a5f-4bee-b266-c621b4f3eed0" == new_subscription.user_id + + +def test_delete_subscription(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.delete(server.subscriptions.baseurl + "/78e9318d-2d29-4d67-b60f-3f2f5fd89ecc", status_code=204) + server.subscriptions.delete("78e9318d-2d29-4d67-b60f-3f2f5fd89ecc") From 29bf79a83f7c5c844312e7384a57bceb50ff75fe Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:23:09 -0600 Subject: [PATCH 74/98] chore: pytestify sort (#1701) * fix: black ci errors * chore: pytestify sort --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_sort.py | 65 ++++++++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/test/test_sort.py b/test/test_sort.py index 8eebef6f4..18c403540 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -1,6 +1,8 @@ import re import unittest +from urllib.parse import parse_qs +import pytest import requests_mock import tableauserverclient as TSC @@ -15,7 +17,8 @@ def setUp(self): self.baseurl = self.server.workbooks.baseurl def test_empty_filter(self): - self.assertRaises(TypeError, TSC.Filter, "") + with pytest.raises(TypeError): + TSC.Filter("") def test_filter_equals(self): with requests_mock.mock() as m: @@ -25,17 +28,18 @@ def test_filter_equals(self): opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "Superstore")) resp = self.server.workbooks.get_request(url, request_object=opts) - - self.assertTrue(re.search("pagenumber=13", resp.request.query)) - self.assertTrue(re.search("pagesize=13", resp.request.query)) - self.assertTrue(re.search("filter=name%3aeq%3asuperstore", resp.request.query)) + query = parse_qs(resp.request.query) + assert "pagenumber" in query + assert query["pagenumber"] == ["13"] + assert "pagesize" in query + assert query["pagesize"] == ["13"] + assert "filter" in query + assert query["filter"] == ["name:eq:superstore"] def test_filter_equals_list(self): - with self.assertRaises(ValueError) as cm: + with pytest.raises(ValueError, match="Filter values can only be a list if the operator is 'in'.") as cm: TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.Equals, ["foo", "bar"]) - self.assertEqual("Filter values can only be a list if the operator is 'in'.", str(cm.exception)), - def test_filter_in(self): with requests_mock.mock() as m: m.get(requests_mock.ANY) @@ -47,9 +51,13 @@ def test_filter_in(self): ) resp = self.server.workbooks.get_request(url, request_object=opts) - self.assertTrue(re.search("pagenumber=13", resp.request.query)) - self.assertTrue(re.search("pagesize=13", resp.request.query)) - self.assertTrue(re.search("filter=tags%3ain%3a%5bstocks%2cmarket%5d", resp.request.query)) + query = parse_qs(resp.request.query) + assert "pagenumber" in query + assert query["pagenumber"] == ["13"] + assert "pagesize" in query + assert query["pagesize"] == ["13"] + assert "filter" in query + assert query["filter"] == ["tags:in:[stocks,market]"] def test_sort_asc(self): with requests_mock.mock() as m: @@ -59,10 +67,13 @@ def test_sort_asc(self): opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Asc)) resp = self.server.workbooks.get_request(url, request_object=opts) - - self.assertTrue(re.search("pagenumber=13", resp.request.query)) - self.assertTrue(re.search("pagesize=13", resp.request.query)) - self.assertTrue(re.search("sort=name%3aasc", resp.request.query)) + query = parse_qs(resp.request.query) + assert "pagenumber" in query + assert query["pagenumber"] == ["13"] + assert "pagesize" in query + assert query["pagesize"] == ["13"] + assert "sort" in query + assert query["sort"] == ["name:asc"] def test_filter_combo(self): with requests_mock.mock() as m: @@ -84,20 +95,10 @@ def test_filter_combo(self): resp = self.server.workbooks.get_request(url, request_object=opts) - expected = ( - "pagenumber=13&pagesize=13&filter=lastlogin%3agte%3a" - "2017-01-15t00%3a00%3a00%3a00z%2csiterole%3aeq%3apublisher" - ) - - self.assertTrue(re.search("pagenumber=13", resp.request.query)) - self.assertTrue(re.search("pagesize=13", resp.request.query)) - self.assertTrue( - re.search( - "filter=lastlogin%3agte%3a2017-01-15t00%3a00%3a00%3a00z%2csiterole%3aeq%3apublisher", - resp.request.query, - ) - ) - - -if __name__ == "__main__": - unittest.main() + query = parse_qs(resp.request.query) + assert "pagenumber" in query + assert query["pagenumber"] == ["13"] + assert "pagesize" in query + assert query["pagesize"] == ["13"] + assert "filter" in query + assert query["filter"] == ["lastlogin:gte:2017-01-15t00:00:00:00z,siterole:eq:publisher"] From 54c48b0c097c974942a474f220feeaffe51852ac Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:23:32 -0600 Subject: [PATCH 75/98] chore: pytestify wb_model (#1710) * fix: black ci errors * chore: pytestify wb_model --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_workbook_model.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/test/test_workbook_model.py b/test/test_workbook_model.py index fc6423564..3d6f31a7a 100644 --- a/test/test_workbook_model.py +++ b/test/test_workbook_model.py @@ -1,13 +1,12 @@ -import unittest +import pytest import tableauserverclient as TSC -class WorkbookModelTests(unittest.TestCase): - def test_invalid_show_tabs(self): - workbook = TSC.WorkbookItem("10") - with self.assertRaises(ValueError): - workbook.show_tabs = "Hello" +def test_invalid_show_tabs(): + workbook = TSC.WorkbookItem("10") + with pytest.raises(ValueError): + workbook.show_tabs = "Hello" - with self.assertRaises(ValueError): - workbook.show_tabs = None + with pytest.raises(ValueError): + workbook.show_tabs = None From 1021903b2d7298c3900b99fa4c76d6c47f392cab Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:23:41 -0600 Subject: [PATCH 76/98] chore: pytestify webhooks (#1709) * fix: black ci errors * chore: pytestify webhooks --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_webhook.py | 161 ++++++++++++++++++++++--------------------- 1 file changed, 83 insertions(+), 78 deletions(-) diff --git a/test/test_webhook.py b/test/test_webhook.py index 5f26266b2..e0217e93f 100644 --- a/test/test_webhook.py +++ b/test/test_webhook.py @@ -1,84 +1,89 @@ -import os -import unittest +from pathlib import Path +import pytest import requests_mock import tableauserverclient as TSC from tableauserverclient.server import RequestFactory from tableauserverclient.models import WebhookItem -from ._utils import asset - -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") - -GET_XML = asset("webhook_get.xml") -CREATE_XML = asset("webhook_create.xml") -CREATE_REQUEST_XML = asset("webhook_create_request.xml") - - -class WebhookTests(unittest.TestCase): - def setUp(self) -> None: - self.server = TSC.Server("http://test", False) - self.server.version = "3.6" - - # Fake signin - self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - - self.baseurl = self.server.webhooks.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) - webhooks, _ = self.server.webhooks.get() - self.assertEqual(len(webhooks), 1) - webhook = webhooks[0] - - self.assertEqual(webhook.url, "url") - self.assertEqual(webhook.event, "datasource-created") - self.assertEqual(webhook.owner_id, "webhook_owner_luid") - self.assertEqual(webhook.name, "webhook-name") - self.assertEqual(webhook.id, "webhook-id") - - def test_get_before_signin(self) -> None: - self.server._auth_token = None - self.assertRaises(TSC.NotSignedInError, self.server.webhooks.get) - - def test_delete(self) -> None: - with requests_mock.mock() as m: - m.delete(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) - self.server.webhooks.delete("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - - def test_delete_missing_id(self) -> None: - self.assertRaises(ValueError, self.server.webhooks.delete, "") - - def test_test(self) -> None: - with requests_mock.mock() as m: - m.get(self.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760/test", status_code=200) - self.server.webhooks.test("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") - - def test_create(self) -> None: - with open(CREATE_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) - webhook_model = TSC.WebhookItem() - webhook_model.name = "Test Webhook" - webhook_model.url = "https://ifttt.com/maker-url" - webhook_model.event = "datasource-created" - - new_webhook = self.server.webhooks.create(webhook_model) - - self.assertNotEqual(new_webhook.id, None) - - def test_request_factory(self): - with open(CREATE_REQUEST_XML, "rb") as f: - webhook_request_expected = f.read().decode("utf-8") - - webhook_item = WebhookItem() - webhook_item._set_values("webhook-id", "webhook-name", "url", "api-event-name", None) - webhook_request_actual = "{}\n".format(RequestFactory.Webhook.create_req(webhook_item).decode("utf-8")) - self.maxDiff = None - # windows does /r/n for linebreaks, remove the extra char if it is there - self.assertEqual(webhook_request_expected.replace("\r", ""), webhook_request_actual) + +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +GET_XML = TEST_ASSET_DIR / "webhook_get.xml" +CREATE_XML = TEST_ASSET_DIR / "webhook_create.xml" +CREATE_REQUEST_XML = TEST_ASSET_DIR / "webhook_create_request.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" + server.version = "3.6" + + return server + + +def test_get(server: TSC.Server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.webhooks.baseurl, text=response_xml) + webhooks, _ = server.webhooks.get() + assert len(webhooks) == 1 + webhook = webhooks[0] + + assert webhook.url == "url" + assert webhook.event == "datasource-created" + assert webhook.owner_id == "webhook_owner_luid" + assert webhook.name == "webhook-name" + assert webhook.id == "webhook-id" + + +def test_get_before_signin(server: TSC.Server) -> None: + server._auth_token = None + with pytest.raises(TSC.NotSignedInError): + server.webhooks.get() + + +def test_delete(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.delete(server.webhooks.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", status_code=204) + server.webhooks.delete("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + + +def test_delete_missing_id(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.webhooks.delete("") + + +def test_test(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(server.webhooks.baseurl + "/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760/test", status_code=200) + server.webhooks.test("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760") + + +def test_create(server: TSC.Server) -> None: + response_xml = CREATE_XML.read_text() + with requests_mock.mock() as m: + m.post(server.webhooks.baseurl, text=response_xml) + webhook_model = TSC.WebhookItem() + webhook_model.name = "Test Webhook" + webhook_model.url = "https://ifttt.com/maker-url" + webhook_model.event = "datasource-created" + + new_webhook = server.webhooks.create(webhook_model) + + assert new_webhook.id is not None + + +def test_request_factory(): + webhook_request_expected = CREATE_REQUEST_XML.read_text() + + webhook_item = WebhookItem() + webhook_item._set_values("webhook-id", "webhook-name", "url", "api-event-name", None) + webhook_request_actual = "{}\n".format(RequestFactory.Webhook.create_req(webhook_item).decode("utf-8")) + # windows does /r/n for linebreaks, remove the extra char if it is there + assert webhook_request_expected.replace("\r", "") == webhook_request_actual From a4f0c8a989fa7839b1047453cd813fc38a9d24e9 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:23:51 -0600 Subject: [PATCH 77/98] chore: pytestify virtual connections (#1708) * fix: black ci errors * chore: pytestify virtuall connections --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_virtual_connection.py | 471 +++++++++++++++++--------------- 1 file changed, 246 insertions(+), 225 deletions(-) diff --git a/test/test_virtual_connection.py b/test/test_virtual_connection.py index 5d9a2d1bc..210f605c8 100644 --- a/test/test_virtual_connection.py +++ b/test/test_virtual_connection.py @@ -1,6 +1,5 @@ import json from pathlib import Path -import unittest import pytest import requests_mock @@ -22,227 +21,249 @@ ADD_PERMISSIONS = ASSET_DIR / "virtual_connection_add_permissions.xml" -class TestVirtualConnections(unittest.TestCase): - def setUp(self) -> None: - self.server = TSC.Server("http://test") - - self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.server.version = "3.23" - - self.baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/virtualConnections" - return super().setUp() - - def test_from_xml(self): - items = VirtualConnectionItem.from_response(VIRTUAL_CONNECTION_GET_XML.read_bytes(), self.server.namespace) - - assert len(items) == 1 - virtual_connection = items[0] - assert virtual_connection.created_at == parse_datetime("2024-05-30T09:00:00Z") - assert not virtual_connection.has_extracts - assert virtual_connection.id == "8fd7cc02-bb55-4d15-b8b1-9650239efe79" - assert virtual_connection.is_certified - assert virtual_connection.name == "vconn" - assert virtual_connection.updated_at == parse_datetime("2024-06-18T09:00:00Z") - assert virtual_connection.webpage_url == "https://test/#/site/site-name/virtualconnections/3" - - def test_virtual_connection_get(self): - with requests_mock.mock() as m: - m.get(self.baseurl, text=VIRTUAL_CONNECTION_GET_XML.read_text()) - items, pagination_item = self.server.virtual_connections.get() - - assert len(items) == 1 - assert pagination_item.total_available == 1 - assert items[0].name == "vconn" - - def test_virtual_connection_populate_connections(self): - for i, populate_connections_xml in enumerate( - (VIRTUAL_CONNECTION_POPULATE_CONNECTIONS, VIRTUAL_CONNECTION_POPULATE_CONNECTIONS2) - ): - with self.subTest(i): - vconn = VirtualConnectionItem("vconn") - vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{vconn.id}/connections", text=populate_connections_xml.read_text()) - vc_out = self.server.virtual_connections.populate_connections(vconn) - connection_list = list(vconn.connections) - - assert vc_out is vconn - assert vc_out._connections is not None - - assert len(connection_list) == 1 - connection = connection_list[0] - assert connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" - assert connection.connection_type == "postgres" - assert connection.server_address == "localhost" - assert connection.server_port == "5432" - assert connection.username == "pgadmin" - - def test_virtual_connection_update_connection_db_connection(self): - vconn = VirtualConnectionItem("vconn") - vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" - connection = TSC.ConnectionItem() - connection._id = "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" - connection.server_address = "localhost" - connection.server_port = "5432" - connection.username = "pgadmin" - connection.password = "password" - with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{vconn.id}/connections/{connection.id}/modify", text=VC_DB_CONN_UPDATE.read_text()) - updated_connection = self.server.virtual_connections.update_connection_db_connection(vconn, connection) - - assert updated_connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" - assert updated_connection.server_address == "localhost" - assert updated_connection.server_port == "5432" - assert updated_connection.username == "pgadmin" - assert updated_connection.password is None - - def test_virtual_connection_get_by_id(self): - vconn = VirtualConnectionItem("vconn") - vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{vconn.id}", text=VIRTUAL_CONNECTION_DOWNLOAD.read_text()) - vconn = self.server.virtual_connections.get_by_id(vconn) - - assert vconn.content - assert vconn.created_at is None - assert vconn.id is None - assert "policyCollection" in vconn.content - assert "revision" in vconn.content - - def test_virtual_connection_update(self): - vconn = VirtualConnectionItem("vconn") - vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" - vconn.is_certified = True - vconn.certification_note = "demo certification note" - vconn.project_id = "5286d663-8668-4ac2-8c8d-91af7d585f6b" - vconn.owner_id = "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0" - with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{vconn.id}", text=VIRTUAL_CONNECTION_UPDATE.read_text()) - vconn = self.server.virtual_connections.update(vconn) - - assert not vconn.has_extracts - assert vconn.id is None - assert vconn.is_certified - assert vconn.name == "testv1" - assert vconn.certification_note == "demo certification note" - assert vconn.project_id == "5286d663-8668-4ac2-8c8d-91af7d585f6b" - assert vconn.owner_id == "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0" - - def test_virtual_connection_get_revisions(self): - vconn = VirtualConnectionItem("vconn") - vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{vconn.id}/revisions", text=VIRTUAL_CONNECTION_REVISIONS.read_text()) - revisions, pagination_item = self.server.virtual_connections.get_revisions(vconn) - - assert len(revisions) == 3 - assert pagination_item.total_available == 3 - assert revisions[0].resource_id == vconn.id - assert revisions[0].resource_name == vconn.name - assert revisions[0].created_at == parse_datetime("2016-07-26T20:34:56Z") - assert revisions[0].revision_number == "1" - assert not revisions[0].current - assert not revisions[0].deleted - assert revisions[0].user_name == "Cassie" - assert revisions[0].user_id == "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" - assert revisions[1].resource_id == vconn.id - assert revisions[1].resource_name == vconn.name - assert revisions[1].created_at == parse_datetime("2016-07-27T20:34:56Z") - assert revisions[1].revision_number == "2" - assert not revisions[1].current - assert not revisions[1].deleted - assert revisions[2].resource_id == vconn.id - assert revisions[2].resource_name == vconn.name - assert revisions[2].created_at == parse_datetime("2016-07-28T20:34:56Z") - assert revisions[2].revision_number == "3" - assert revisions[2].current - assert not revisions[2].deleted - assert revisions[2].user_name == "Cassie" - assert revisions[2].user_id == "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" - - def test_virtual_connection_download_revision(self): - vconn = VirtualConnectionItem("vconn") - vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{vconn.id}/revisions/1", text=VIRTUAL_CONNECTION_DOWNLOAD.read_text()) - content = self.server.virtual_connections.download_revision(vconn, 1) - - assert content - assert "policyCollection" in content - data = json.loads(content) - assert "policyCollection" in data - assert "revision" in data - - def test_virtual_connection_delete(self): - vconn = VirtualConnectionItem("vconn") - vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" - with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{vconn.id}") - self.server.virtual_connections.delete(vconn) - self.server.virtual_connections.delete(vconn.id) - - assert m.call_count == 2 - - def test_virtual_connection_publish(self): - vconn = VirtualConnectionItem("vconn") - vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" - vconn.project_id = "9836791c-9468-40f0-b7f3-d10b9562a046" - vconn.owner_id = "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" - with requests_mock.mock() as m: - m.post(f"{self.baseurl}?overwrite=false&publishAsDraft=false", text=VIRTUAL_CONNECTION_PUBLISH.read_text()) - vconn = self.server.virtual_connections.publish( - vconn, '{"test": 0}', mode="CreateNew", publish_as_draft=False - ) - - assert vconn.name == "vconn_test" - assert vconn.owner_id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" - assert vconn.project_id == "9836791c-9468-40f0-b7f3-d10b9562a046" - assert vconn.content - assert "policyCollection" in vconn.content - assert "revision" in vconn.content - - def test_virtual_connection_publish_draft_overwrite(self): - vconn = VirtualConnectionItem("vconn") - vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" - vconn.project_id = "9836791c-9468-40f0-b7f3-d10b9562a046" - vconn.owner_id = "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" - with requests_mock.mock() as m: - m.post(f"{self.baseurl}?overwrite=true&publishAsDraft=true", text=VIRTUAL_CONNECTION_PUBLISH.read_text()) - vconn = self.server.virtual_connections.publish( - vconn, '{"test": 0}', mode="Overwrite", publish_as_draft=True - ) - - assert vconn.name == "vconn_test" - assert vconn.owner_id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" - assert vconn.project_id == "9836791c-9468-40f0-b7f3-d10b9562a046" - assert vconn.content - assert "policyCollection" in vconn.content - assert "revision" in vconn.content - - def test_add_permissions(self) -> None: - with open(ADD_PERMISSIONS, "rb") as f: - response_xml = f.read().decode("utf-8") - - single_virtual_connection = TSC.VirtualConnectionItem("test") - single_virtual_connection._id = "21778de4-b7b9-44bc-a599-1506a2639ace" - - bob = TSC.UserItem.as_reference("7c37ee24-c4b1-42b6-a154-eaeab7ee330a") - group_of_people = TSC.GroupItem.as_reference("5e5e1978-71fa-11e4-87dd-7382f5c437af") - - new_permissions = [ - TSC.PermissionsRule(bob, {"Write": "Allow"}), - TSC.PermissionsRule(group_of_people, {"Read": "Deny"}), - ] - - with requests_mock.mock() as m: - m.put(self.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) - permissions = self.server.virtual_connections.add_permissions(single_virtual_connection, 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}) - - 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}) +@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" + server.version = "3.23" + + return server + + +def test_from_xml(server: TSC.Server) -> None: + items = VirtualConnectionItem.from_response(VIRTUAL_CONNECTION_GET_XML.read_bytes(), server.namespace) + + assert len(items) == 1 + virtual_connection = items[0] + assert virtual_connection.created_at == parse_datetime("2024-05-30T09:00:00Z") + assert not virtual_connection.has_extracts + assert virtual_connection.id == "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + assert virtual_connection.is_certified + assert virtual_connection.name == "vconn" + assert virtual_connection.updated_at == parse_datetime("2024-06-18T09:00:00Z") + assert virtual_connection.webpage_url == "https://test/#/site/site-name/virtualconnections/3" + + +def test_virtual_connection_get(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(server.virtual_connections.baseurl, text=VIRTUAL_CONNECTION_GET_XML.read_text()) + items, pagination_item = server.virtual_connections.get() + + assert len(items) == 1 + assert pagination_item.total_available == 1 + assert items[0].name == "vconn" + + +@pytest.mark.parametrize( + "populate_connections_xml", [VIRTUAL_CONNECTION_POPULATE_CONNECTIONS, VIRTUAL_CONNECTION_POPULATE_CONNECTIONS2] +) +def test_virtual_connection_populate_connections(server: TSC.Server, populate_connections_xml: Path) -> None: + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get(f"{server.virtual_connections.baseurl}/{vconn.id}/connections", text=populate_connections_xml.read_text()) + vc_out = server.virtual_connections.populate_connections(vconn) + connection_list = list(vconn.connections) + + assert vc_out is vconn + assert vc_out._connections is not None + + assert len(connection_list) == 1 + connection = connection_list[0] + assert connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" + assert connection.connection_type == "postgres" + assert connection.server_address == "localhost" + assert connection.server_port == "5432" + assert connection.username == "pgadmin" + + +def test_virtual_connection_update_connection_db_connection(server: TSC.Server) -> None: + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + connection = TSC.ConnectionItem() + connection._id = "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" + connection.server_address = "localhost" + connection.server_port = "5432" + connection.username = "pgadmin" + connection.password = "password" + with requests_mock.mock() as m: + m.put( + f"{server.virtual_connections.baseurl}/{vconn.id}/connections/{connection.id}/modify", + text=VC_DB_CONN_UPDATE.read_text(), + ) + updated_connection = server.virtual_connections.update_connection_db_connection(vconn, connection) + + assert updated_connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" + assert updated_connection.server_address == "localhost" + assert updated_connection.server_port == "5432" + assert updated_connection.username == "pgadmin" + assert updated_connection.password is None + + +def test_virtual_connection_get_by_id(server: TSC.Server) -> None: + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get(f"{server.virtual_connections.baseurl}/{vconn.id}", text=VIRTUAL_CONNECTION_DOWNLOAD.read_text()) + vconn = server.virtual_connections.get_by_id(vconn) + + assert vconn.content + assert vconn.created_at is None + assert vconn.id is None + assert "policyCollection" in vconn.content + assert "revision" in vconn.content + + +def test_virtual_connection_update(server: TSC.Server) -> None: + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + vconn.is_certified = True + vconn.certification_note = "demo certification note" + vconn.project_id = "5286d663-8668-4ac2-8c8d-91af7d585f6b" + vconn.owner_id = "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0" + with requests_mock.mock() as m: + m.put(f"{server.virtual_connections.baseurl}/{vconn.id}", text=VIRTUAL_CONNECTION_UPDATE.read_text()) + vconn = server.virtual_connections.update(vconn) + + assert not vconn.has_extracts + assert vconn.id is None + assert vconn.is_certified + assert vconn.name == "testv1" + assert vconn.certification_note == "demo certification note" + assert vconn.project_id == "5286d663-8668-4ac2-8c8d-91af7d585f6b" + assert vconn.owner_id == "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0" + + +def test_virtual_connection_get_revisions(server: TSC.Server) -> None: + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get( + f"{server.virtual_connections.baseurl}/{vconn.id}/revisions", text=VIRTUAL_CONNECTION_REVISIONS.read_text() + ) + revisions, pagination_item = server.virtual_connections.get_revisions(vconn) + + assert len(revisions) == 3 + assert pagination_item.total_available == 3 + assert revisions[0].resource_id == vconn.id + assert revisions[0].resource_name == vconn.name + assert revisions[0].created_at == parse_datetime("2016-07-26T20:34:56Z") + assert revisions[0].revision_number == "1" + assert not revisions[0].current + assert not revisions[0].deleted + assert revisions[0].user_name == "Cassie" + assert revisions[0].user_id == "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" + assert revisions[1].resource_id == vconn.id + assert revisions[1].resource_name == vconn.name + assert revisions[1].created_at == parse_datetime("2016-07-27T20:34:56Z") + assert revisions[1].revision_number == "2" + assert not revisions[1].current + assert not revisions[1].deleted + assert revisions[2].resource_id == vconn.id + assert revisions[2].resource_name == vconn.name + assert revisions[2].created_at == parse_datetime("2016-07-28T20:34:56Z") + assert revisions[2].revision_number == "3" + assert revisions[2].current + assert not revisions[2].deleted + assert revisions[2].user_name == "Cassie" + assert revisions[2].user_id == "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" + + +def test_virtual_connection_download_revision(server: TSC.Server) -> None: + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get( + f"{server.virtual_connections.baseurl}/{vconn.id}/revisions/1", text=VIRTUAL_CONNECTION_DOWNLOAD.read_text() + ) + content = server.virtual_connections.download_revision(vconn, 1) + + assert content + assert "policyCollection" in content + data = json.loads(content) + assert "policyCollection" in data + assert "revision" in data + + +def test_virtual_connection_delete(server: TSC.Server) -> None: + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.delete(f"{server.virtual_connections.baseurl}/{vconn.id}") + server.virtual_connections.delete(vconn) + server.virtual_connections.delete(vconn.id) + + assert m.call_count == 2 + + +def test_virtual_connection_publish(server: TSC.Server) -> None: + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + vconn.project_id = "9836791c-9468-40f0-b7f3-d10b9562a046" + vconn.owner_id = "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + with requests_mock.mock() as m: + m.post( + f"{server.virtual_connections.baseurl}?overwrite=false&publishAsDraft=false", + text=VIRTUAL_CONNECTION_PUBLISH.read_text(), + ) + vconn = server.virtual_connections.publish(vconn, '{"test": 0}', mode="CreateNew", publish_as_draft=False) + + assert vconn.name == "vconn_test" + assert vconn.owner_id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert vconn.project_id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert vconn.content + assert "policyCollection" in vconn.content + assert "revision" in vconn.content + + +def test_virtual_connection_publish_draft_overwrite(server: TSC.Server) -> None: + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + vconn.project_id = "9836791c-9468-40f0-b7f3-d10b9562a046" + vconn.owner_id = "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + with requests_mock.mock() as m: + m.post( + f"{server.virtual_connections.baseurl}?overwrite=true&publishAsDraft=true", + text=VIRTUAL_CONNECTION_PUBLISH.read_text(), + ) + vconn = server.virtual_connections.publish(vconn, '{"test": 0}', mode="Overwrite", publish_as_draft=True) + + assert vconn.name == "vconn_test" + assert vconn.owner_id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert vconn.project_id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert vconn.content + assert "policyCollection" in vconn.content + assert "revision" in vconn.content + + +def test_add_permissions(server: TSC.Server) -> None: + response_xml = ADD_PERMISSIONS.read_text() + + single_virtual_connection = TSC.VirtualConnectionItem("test") + single_virtual_connection._id = "21778de4-b7b9-44bc-a599-1506a2639ace" + + bob = TSC.UserItem.as_reference("7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + group_of_people = TSC.GroupItem.as_reference("5e5e1978-71fa-11e4-87dd-7382f5c437af") + + new_permissions = [ + TSC.PermissionsRule(bob, {"Write": "Allow"}), + TSC.PermissionsRule(group_of_people, {"Read": "Deny"}), + ] + + with requests_mock.mock() as m: + m.put( + server.virtual_connections.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml + ) + permissions = server.virtual_connections.add_permissions(single_virtual_connection, 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} From 108b1bc4a91ca5222225c964d63ae3c2682e903d Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:24:22 -0600 Subject: [PATCH 78/98] chore: pytestify ssl_config (#1722) * fix: black ci errors * chore: pytestify ssl_config --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> From bc11d2e7ae9255b1cc99a89a58ea9b68a819095a Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:24:35 -0600 Subject: [PATCH 79/98] chore: pytestify filesys helpers (#1721) * fix: black ci errors * chore: pytestify filesys helpers --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_filesys_helpers.py | 200 +++++++++++++++++++---------------- 1 file changed, 107 insertions(+), 93 deletions(-) diff --git a/test/test_filesys_helpers.py b/test/test_filesys_helpers.py index 0f3234d5d..aa31ae98a 100644 --- a/test/test_filesys_helpers.py +++ b/test/test_filesys_helpers.py @@ -1,99 +1,113 @@ import os -import unittest +from pathlib import Path from io import BytesIO from xml.etree import ElementTree as ET from zipfile import ZipFile +import pytest + from tableauserverclient.filesys_helpers import get_file_object_size, get_file_type -from ._utils import asset, TEST_ASSET_DIR - - -class FilesysTests(unittest.TestCase): - def test_get_file_size_returns_correct_size(self): - target_size = 1000 # bytes - - with BytesIO() as f: - f.seek(target_size - 1) - f.write(b"\0") - file_size = get_file_object_size(f) - - self.assertEqual(file_size, target_size) - - def test_get_file_size_returns_zero_for_empty_file(self): - with BytesIO() as f: - file_size = get_file_object_size(f) - - self.assertEqual(file_size, 0) - - def test_get_file_size_coincides_with_built_in_method(self): - asset_path = asset("SampleWB.twbx") - target_size = os.path.getsize(asset_path) - with open(asset_path, "rb") as f: - file_size = get_file_object_size(f) - - self.assertEqual(file_size, target_size) - - def test_get_file_type_identifies_a_zip_file(self): - with BytesIO() as file_object: - with ZipFile(file_object, "w") as zf: - with BytesIO() as stream: - stream.write(b"This is a zip file") - zf.writestr("dummy_file", stream.getbuffer()) - file_object.seek(0) - file_type = get_file_type(file_object) - - self.assertEqual(file_type, "zip") - - def test_get_file_type_identifies_tdsx_as_zip_file(self): - with open(asset("World Indicators.tdsx"), "rb") as file_object: - file_type = get_file_type(file_object) - self.assertEqual(file_type, "zip") - - def test_get_file_type_identifies_twbx_as_zip_file(self): - with open(asset("SampleWB.twbx"), "rb") as file_object: - file_type = get_file_type(file_object) - self.assertEqual(file_type, "zip") - - def test_get_file_type_identifies_xml_file(self): - root = ET.Element("root") - child = ET.SubElement(root, "child") - child.text = "This is a child element" - etree = ET.ElementTree(root) - - with BytesIO() as file_object: - etree.write(file_object, encoding="utf-8", xml_declaration=True) - - file_object.seek(0) - file_type = get_file_type(file_object) - - self.assertEqual(file_type, "xml") - - def test_get_file_type_identifies_tds_as_xml_file(self): - with open(asset("World Indicators.tds"), "rb") as file_object: - file_type = get_file_type(file_object) - self.assertEqual(file_type, "xml") - - def test_get_file_type_identifies_twb_as_xml_file(self): - with open(asset("RESTAPISample.twb"), "rb") as file_object: - file_type = get_file_type(file_object) - self.assertEqual(file_type, "xml") - - def test_get_file_type_identifies_hyper_file(self): - with open(asset("World Indicators.hyper"), "rb") as file_object: - file_type = get_file_type(file_object) - self.assertEqual(file_type, "hyper") - - def test_get_file_type_identifies_tde_file(self): - asset_path = os.path.join(TEST_ASSET_DIR, "Data", "Tableau Samples", "World Indicators.tde") - with open(asset_path, "rb") as file_object: - file_type = get_file_type(file_object) - self.assertEqual(file_type, "tde") - - def test_get_file_type_handles_unknown_file_type(self): - # Create a dummy png file - with BytesIO() as file_object: - png_signature = bytes.fromhex("89504E470D0A1A0A") - file_object.write(png_signature) - file_object.seek(0) - - self.assertRaises(ValueError, get_file_type, file_object) + +TEST_ASSET_DIR = Path(__file__).parent / "assets" + + +def test_get_file_size_returns_correct_size() -> None: + target_size = 1000 # bytes + + with BytesIO() as f: + f.seek(target_size - 1) + f.write(b"\0") + file_size = get_file_object_size(f) + + assert file_size == target_size + + +def test_get_file_size_returns_zero_for_empty_file() -> None: + with BytesIO() as f: + file_size = get_file_object_size(f) + + assert file_size == 0 + + +def test_get_file_size_coincides_with_built_in_method() -> None: + asset_path = TEST_ASSET_DIR / "SampleWB.twbx" + target_size = os.path.getsize(asset_path) + with open(asset_path, "rb") as f: + file_size = get_file_object_size(f) + + assert file_size == target_size + + +def test_get_file_type_identifies_a_zip_file() -> None: + with BytesIO() as file_object: + with ZipFile(file_object, "w") as zf: + with BytesIO() as stream: + stream.write(b"This is a zip file") + zf.writestr("dummy_file", stream.getbuffer()) + file_object.seek(0) + file_type = get_file_type(file_object) + + assert file_type == "zip" + + +def test_get_file_type_identifies_tdsx_as_zip_file() -> None: + with open(TEST_ASSET_DIR / "World Indicators.tdsx", "rb") as file_object: + file_type = get_file_type(file_object) + assert file_type == "zip" + + +def test_get_file_type_identifies_twbx_as_zip_file() -> None: + with open(TEST_ASSET_DIR / "SampleWB.twbx", "rb") as file_object: + file_type = get_file_type(file_object) + assert file_type == "zip" + + +def test_get_file_type_identifies_xml_file() -> None: + root = ET.Element("root") + child = ET.SubElement(root, "child") + child.text = "This is a child element" + etree = ET.ElementTree(root) + + with BytesIO() as file_object: + etree.write(file_object, encoding="utf-8", xml_declaration=True) + + file_object.seek(0) + file_type = get_file_type(file_object) + + assert file_type == "xml" + + +def test_get_file_type_identifies_tds_as_xml_file() -> None: + with open(TEST_ASSET_DIR / "World Indicators.tds", "rb") as file_object: + file_type = get_file_type(file_object) + assert file_type == "xml" + + +def test_get_file_type_identifies_twb_as_xml_file() -> None: + with open(TEST_ASSET_DIR / "RESTAPISample.twb", "rb") as file_object: + file_type = get_file_type(file_object) + assert file_type == "xml" + + +def test_get_file_type_identifies_hyper_file() -> None: + with open(TEST_ASSET_DIR / "World Indicators.hyper", "rb") as file_object: + file_type = get_file_type(file_object) + assert file_type == "hyper" + + +def test_get_file_type_identifies_tde_file() -> None: + asset_path = TEST_ASSET_DIR / "Data" / "Tableau Samples" / "World Indicators.tde" + with open(asset_path, "rb") as file_object: + file_type = get_file_type(file_object) + assert file_type == "tde" + + +def test_get_file_type_handles_unknown_file_type() -> None: + # Create a dummy png file + with BytesIO() as file_object: + png_signature = bytes.fromhex("89504E470D0A1A0A") + file_object.write(png_signature) + file_object.seek(0) + + with pytest.raises(ValueError): + get_file_type(file_object) From 912b4f17f190969ff4be2487e7047d6638f067db Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:24:50 -0600 Subject: [PATCH 80/98] chore: pytestify metrics (#1720) * fix: black ci errors * chore: pytestify metrics --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_metrics.py | 192 ++++++++++++++++++++++--------------------- 1 file changed, 99 insertions(+), 93 deletions(-) diff --git a/test/test_metrics.py b/test/test_metrics.py index 7628abb1a..fdb21f8f0 100644 --- a/test/test_metrics.py +++ b/test/test_metrics.py @@ -1,7 +1,8 @@ -import unittest import requests_mock from pathlib import Path +import pytest + import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime @@ -11,95 +12,100 @@ METRICS_UPDATE = assets / "metrics_update.xml" -class TestMetrics(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.9" - - self.baseurl = self.server.metrics.baseurl - - def test_metrics_get(self) -> None: - with requests_mock.mock() as m: - m.get(self.baseurl, text=METRICS_GET.read_text()) - all_metrics, pagination_item = self.server.metrics.get() - - self.assertEqual(len(all_metrics), 2) - self.assertEqual(pagination_item.total_available, 27) - self.assertEqual(all_metrics[0].id, "6561daa3-20e8-407f-ba09-709b178c0b4a") - self.assertEqual(all_metrics[0].name, "Example metric") - self.assertEqual(all_metrics[0].description, "Description of my metric.") - self.assertEqual(all_metrics[0].webpage_url, "https://test/#/site/site-name/metrics/3") - self.assertEqual(format_datetime(all_metrics[0].created_at), "2020-01-02T01:02:03Z") - self.assertEqual(format_datetime(all_metrics[0].updated_at), "2020-01-02T01:02:03Z") - self.assertEqual(all_metrics[0].suspended, True) - self.assertEqual(all_metrics[0].project_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") - self.assertEqual(all_metrics[0].project_name, "Default") - self.assertEqual(all_metrics[0].owner_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") - self.assertEqual(all_metrics[0].view_id, "29dae0cd-1862-4a20-a638-e2c2dfa682d4") - self.assertEqual(len(all_metrics[0].tags), 0) - - self.assertEqual(all_metrics[1].id, "721760d9-0aa4-4029-87ae-371c956cea07") - self.assertEqual(all_metrics[1].name, "Another Example metric") - self.assertEqual(all_metrics[1].description, "Description of another metric.") - self.assertEqual(all_metrics[1].webpage_url, "https://test/#/site/site-name/metrics/4") - self.assertEqual(format_datetime(all_metrics[1].created_at), "2020-01-03T01:02:03Z") - self.assertEqual(format_datetime(all_metrics[1].updated_at), "2020-01-04T01:02:03Z") - self.assertEqual(all_metrics[1].suspended, False) - self.assertEqual(all_metrics[1].project_id, "486e0de0-2258-45bd-99cf-b62013e19f4e") - self.assertEqual(all_metrics[1].project_name, "Assets") - self.assertEqual(all_metrics[1].owner_id, "1bbbc2b9-847d-443c-9a1f-dbcf112b8814") - self.assertEqual(all_metrics[1].view_id, "7dbfdb63-a6ca-4723-93ee-4fefc71992d3") - self.assertEqual(len(all_metrics[1].tags), 2) - self.assertIn("Test", all_metrics[1].tags) - self.assertIn("Asset", all_metrics[1].tags) - - def test_metrics_get_by_id(self) -> None: - luid = "6561daa3-20e8-407f-ba09-709b178c0b4a" - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{luid}", text=METRICS_GET_BY_ID.read_text()) - metric = self.server.metrics.get_by_id(luid) - - self.assertEqual(metric.id, "6561daa3-20e8-407f-ba09-709b178c0b4a") - self.assertEqual(metric.name, "Example metric") - self.assertEqual(metric.description, "Description of my metric.") - self.assertEqual(metric.webpage_url, "https://test/#/site/site-name/metrics/3") - self.assertEqual(format_datetime(metric.created_at), "2020-01-02T01:02:03Z") - self.assertEqual(format_datetime(metric.updated_at), "2020-01-02T01:02:03Z") - self.assertEqual(metric.suspended, True) - self.assertEqual(metric.project_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") - self.assertEqual(metric.project_name, "Default") - self.assertEqual(metric.owner_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") - self.assertEqual(metric.view_id, "29dae0cd-1862-4a20-a638-e2c2dfa682d4") - self.assertEqual(len(metric.tags), 0) - - def test_metrics_delete(self) -> None: - luid = "6561daa3-20e8-407f-ba09-709b178c0b4a" - with requests_mock.mock() as m: - m.delete(f"{self.baseurl}/{luid}") - self.server.metrics.delete(luid) - - def test_metrics_update(self) -> None: - luid = "6561daa3-20e8-407f-ba09-709b178c0b4a" - metric = TSC.MetricItem() - metric._id = luid - - with requests_mock.mock() as m: - m.put(f"{self.baseurl}/{luid}", text=METRICS_UPDATE.read_text()) - metric = self.server.metrics.update(metric) - - self.assertEqual(metric.id, "6561daa3-20e8-407f-ba09-709b178c0b4a") - self.assertEqual(metric.name, "Example metric") - self.assertEqual(metric.description, "Description of my metric.") - self.assertEqual(metric.webpage_url, "https://test/#/site/site-name/metrics/3") - self.assertEqual(format_datetime(metric.created_at), "2020-01-02T01:02:03Z") - self.assertEqual(format_datetime(metric.updated_at), "2020-01-02T01:02:03Z") - self.assertEqual(metric.suspended, True) - self.assertEqual(metric.project_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") - self.assertEqual(metric.project_name, "Default") - self.assertEqual(metric.owner_id, "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33") - self.assertEqual(metric.view_id, "29dae0cd-1862-4a20-a638-e2c2dfa682d4") - self.assertEqual(len(metric.tags), 0) +@pytest.fixture(scope="function") +def server() -> TSC.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" + server.version = "3.9" + + return server + + +def test_metrics_get(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(server.metrics.baseurl, text=METRICS_GET.read_text()) + all_metrics, pagination_item = server.metrics.get() + + assert len(all_metrics) == 2 + assert pagination_item.total_available == 27 + assert all_metrics[0].id == "6561daa3-20e8-407f-ba09-709b178c0b4a" + assert all_metrics[0].name == "Example metric" + assert all_metrics[0].description == "Description of my metric." + assert all_metrics[0].webpage_url == "https://test/#/site/site-name/metrics/3" + assert format_datetime(all_metrics[0].created_at) == "2020-01-02T01:02:03Z" + assert format_datetime(all_metrics[0].updated_at) == "2020-01-02T01:02:03Z" + assert all_metrics[0].suspended + assert all_metrics[0].project_id == "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33" + assert all_metrics[0].project_name == "Default" + assert all_metrics[0].owner_id == "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33" + assert all_metrics[0].view_id == "29dae0cd-1862-4a20-a638-e2c2dfa682d4" + assert len(all_metrics[0].tags) == 0 + + assert all_metrics[1].id == "721760d9-0aa4-4029-87ae-371c956cea07" + assert all_metrics[1].name == "Another Example metric" + assert all_metrics[1].description == "Description of another metric." + assert all_metrics[1].webpage_url == "https://test/#/site/site-name/metrics/4" + assert format_datetime(all_metrics[1].created_at) == "2020-01-03T01:02:03Z" + assert format_datetime(all_metrics[1].updated_at) == "2020-01-04T01:02:03Z" + assert all_metrics[1].suspended is False + assert all_metrics[1].project_id == "486e0de0-2258-45bd-99cf-b62013e19f4e" + assert all_metrics[1].project_name == "Assets" + assert all_metrics[1].owner_id == "1bbbc2b9-847d-443c-9a1f-dbcf112b8814" + assert all_metrics[1].view_id == "7dbfdb63-a6ca-4723-93ee-4fefc71992d3" + assert len(all_metrics[1].tags) == 2 + assert "Test" in all_metrics[1].tags + assert "Asset" in all_metrics[1].tags + + +def test_metrics_get_by_id(server: TSC.Server) -> None: + luid = "6561daa3-20e8-407f-ba09-709b178c0b4a" + with requests_mock.mock() as m: + m.get(f"{server.metrics.baseurl}/{luid}", text=METRICS_GET_BY_ID.read_text()) + metric = server.metrics.get_by_id(luid) + + assert metric.id == "6561daa3-20e8-407f-ba09-709b178c0b4a" + assert metric.name == "Example metric" + assert metric.description == "Description of my metric." + assert metric.webpage_url == "https://test/#/site/site-name/metrics/3" + assert format_datetime(metric.created_at) == "2020-01-02T01:02:03Z" + assert format_datetime(metric.updated_at) == "2020-01-02T01:02:03Z" + assert metric.suspended + assert metric.project_id == "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33" + assert metric.project_name == "Default" + assert metric.owner_id == "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33" + assert metric.view_id == "29dae0cd-1862-4a20-a638-e2c2dfa682d4" + assert len(metric.tags) == 0 + + +def test_metrics_delete(server: TSC.Server) -> None: + luid = "6561daa3-20e8-407f-ba09-709b178c0b4a" + with requests_mock.mock() as m: + m.delete(f"{server.metrics.baseurl}/{luid}") + server.metrics.delete(luid) + + +def test_metrics_update(server: TSC.Server) -> None: + luid = "6561daa3-20e8-407f-ba09-709b178c0b4a" + metric = TSC.MetricItem() + metric._id = luid + + with requests_mock.mock() as m: + m.put(f"{server.metrics.baseurl}/{luid}", text=METRICS_UPDATE.read_text()) + metric = server.metrics.update(metric) + + assert metric.id == "6561daa3-20e8-407f-ba09-709b178c0b4a" + assert metric.name == "Example metric" + assert metric.description == "Description of my metric." + assert metric.webpage_url == "https://test/#/site/site-name/metrics/3" + assert format_datetime(metric.created_at) == "2020-01-02T01:02:03Z" + assert format_datetime(metric.updated_at) == "2020-01-02T01:02:03Z" + assert metric.suspended + assert metric.project_id == "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33" + assert metric.project_name == "Default" + assert metric.owner_id == "32e79edb-6cfd-47dc-ad79-e8ec2fbb1d33" + assert metric.view_id == "29dae0cd-1862-4a20-a638-e2c2dfa682d4" + assert len(metric.tags) == 0 From eaa1602d81b5d98759492025fa077b566bd82d26 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:24:59 -0600 Subject: [PATCH 81/98] chore: pytestify http_requests (#1719) * fix: black ci errors * chore: pytestify http_requests --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/http/test_http_requests.py | 189 +++++++++++++++++--------------- 1 file changed, 101 insertions(+), 88 deletions(-) diff --git a/test/http/test_http_requests.py b/test/http/test_http_requests.py index ce845502d..62d6d7d3c 100644 --- a/test/http/test_http_requests.py +++ b/test/http/test_http_requests.py @@ -1,3 +1,4 @@ +import pytest import tableauserverclient as TSC import unittest import requests @@ -27,91 +28,103 @@ def __init__(self, status_code): return MockResponse(200) -class ServerTests(unittest.TestCase): - def test_init_server_model_empty_throws(self): - with self.assertRaises(TypeError): - server = TSC.Server() - - def test_init_server_model_no_protocol_defaults_htt(self): - server = TSC.Server("fake-url") - - def test_init_server_model_valid_server_name_works(self): - server = TSC.Server("http://fake-url") - - def test_init_server_model_valid_https_server_name_works(self): - # by default, it will just set the version to 2.3 - server = TSC.Server("https://fake-url") - - def test_init_server_model_bad_server_name_not_version_check(self): - server = TSC.Server("fake-url", use_server_version=False) - - @mock.patch("requests.sessions.Session.get", side_effect=mocked_requests_get) - def test_init_server_model_bad_server_name_do_version_check(self, mock_get): - server = TSC.Server("fake-url", use_server_version=True) - - def test_init_server_model_bad_server_name_not_version_check_random_options(self): - # with self.assertRaises(MissingSchema): - server = TSC.Server("fake-url", use_server_version=False, http_options={"foo": 1}) - - def test_init_server_model_bad_server_name_not_version_check_real_options(self): - # with self.assertRaises(ValueError): - server = TSC.Server("fake-url", use_server_version=False, http_options={"verify": False}) - - def test_http_options_skip_ssl_works(self): - http_options = {"verify": False} - server = TSC.Server("http://fake-url") - server.add_http_options(http_options) - - def test_http_options_multiple_options_works(self): - http_options = {"verify": False, "birdname": "Parrot"} - server = TSC.Server("http://fake-url") - server.add_http_options(http_options) - - # ValueError: dictionary update sequence element #0 has length 1; 2 is required - def test_http_options_multiple_dicts_fails(self): - http_options_1 = {"verify": False} - http_options_2 = {"birdname": "Parrot"} - server = TSC.Server("http://fake-url") - with self.assertRaises(ValueError): - server.add_http_options([http_options_1, http_options_2]) - - # TypeError: cannot convert dictionary update sequence element #0 to a sequence - def test_http_options_not_sequence_fails(self): - server = TSC.Server("http://fake-url") - with self.assertRaises(ValueError): - server.add_http_options({1, 2, 3}) - - def test_validate_connection_http(self): - url = "http://cookies.com" - server = TSC.Server(url) - server.validate_connection_settings() - self.assertEqual(url, server.server_address) - - def test_validate_connection_https(self): - url = "https://cookies.com" - server = TSC.Server(url) - server.validate_connection_settings() - self.assertEqual(url, server.server_address) - - def test_validate_connection_no_protocol(self): - url = "cookies.com" - fixed_url = "http://cookies.com" - server = TSC.Server(url) - server.validate_connection_settings() - self.assertEqual(fixed_url, server.server_address) - - -class SessionTests(unittest.TestCase): - test_header = {"x-test": "true"} - - @staticmethod - def session_factory(): - session = requests.session() - session.headers.update(SessionTests.test_header) - return session - - def test_session_factory_adds_headers(self): - test_request_bin = "http://capture-this-with-mock.com" - with requests_mock.mock() as m: - m.get(url="http://capture-this-with-mock.com/api/2.4/serverInfo", request_headers=SessionTests.test_header) - server = TSC.Server(test_request_bin, use_server_version=True, session_factory=SessionTests.session_factory) +def test_init_server_model_empty_throws(): + with pytest.raises(TypeError): + server = TSC.Server() + + +def test_init_server_model_no_protocol_defaults_htt(): + server = TSC.Server("fake-url") + + +def test_init_server_model_valid_server_name_works(): + server = TSC.Server("http://fake-url") + + +def test_init_server_model_valid_https_server_name_works(): + # by default, it will just set the version to 2.3 + server = TSC.Server("https://fake-url") + + +def test_init_server_model_bad_server_name_not_version_check(): + server = TSC.Server("fake-url", use_server_version=False) + + +@mock.patch("requests.sessions.Session.get", side_effect=mocked_requests_get) +def test_init_server_model_bad_server_name_do_version_check(mock_get): + server = TSC.Server("fake-url", use_server_version=True) + + +def test_init_server_model_bad_server_name_not_version_check_random_options(): + server = TSC.Server("fake-url", use_server_version=False, http_options={"foo": 1}) + + +def test_init_server_model_bad_server_name_not_version_check_real_options(): + server = TSC.Server("fake-url", use_server_version=False, http_options={"verify": False}) + + +def test_http_options_skip_ssl_works(): + http_options = {"verify": False} + server = TSC.Server("http://fake-url") + server.add_http_options(http_options) + + +def test_http_options_multiple_options_works(): + http_options = {"verify": False, "birdname": "Parrot"} + server = TSC.Server("http://fake-url") + server.add_http_options(http_options) + + +# ValueError: dictionary update sequence element #0 has length 1; 2 is required +def test_http_options_multiple_dicts_fails(): + http_options_1 = {"verify": False} + http_options_2 = {"birdname": "Parrot"} + server = TSC.Server("http://fake-url") + with pytest.raises(ValueError): + server.add_http_options([http_options_1, http_options_2]) + + +# TypeError: cannot convert dictionary update sequence element #0 to a sequence +def test_http_options_not_sequence_fails(): + server = TSC.Server("http://fake-url") + with pytest.raises(ValueError): + server.add_http_options({1, 2, 3}) + + +def test_validate_connection_http(): + url = "http://cookies.com" + server = TSC.Server(url) + server.validate_connection_settings() + assert url == server.server_address + + +def test_validate_connection_https(): + url = "https://cookies.com" + server = TSC.Server(url) + server.validate_connection_settings() + assert url == server.server_address + + +def test_validate_connection_no_protocol(): + url = "cookies.com" + fixed_url = "http://cookies.com" + server = TSC.Server(url) + server.validate_connection_settings() + assert fixed_url == server.server_address + + +test_header = {"x-test": "true"} + + +@pytest.fixture +def session_factory() -> requests.Session: + session = requests.session() + session.headers.update(test_header) + return session + + +def test_session_factory_adds_headers(session_factory): + test_request_bin = "http://capture-this-with-mock.com" + with requests_mock.mock() as m: + m.get(url="http://capture-this-with-mock.com/api/2.4/serverInfo", request_headers=test_header) + server = TSC.Server(test_request_bin, use_server_version=True, session_factory=lambda: session_factory) From e51f43cb31182dc35efc896b91239f2f9f69fa38 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:26:10 -0600 Subject: [PATCH 82/98] chore: pytestify test_site (#1699) * fix: black ci errors * chore: pytestify test_site * chore: pytestify test_site_model --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_site.py | 573 ++++++++++++++++++++-------------------- test/test_site_model.py | 104 ++++---- 2 files changed, 349 insertions(+), 328 deletions(-) diff --git a/test/test_site.py b/test/test_site.py index 034e7c840..e976bc1d2 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -1,6 +1,5 @@ from itertools import product -import os.path -import unittest +from pathlib import Path from defusedxml import ElementTree as ET import pytest @@ -11,286 +10,300 @@ from . import _utils -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") - -GET_XML = os.path.join(TEST_ASSET_DIR, "site_get.xml") -GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "site_get_by_id.xml") -GET_BY_NAME_XML = os.path.join(TEST_ASSET_DIR, "site_get_by_name.xml") -UPDATE_XML = os.path.join(TEST_ASSET_DIR, "site_update.xml") -CREATE_XML = os.path.join(TEST_ASSET_DIR, "site_create.xml") -SITE_AUTH_CONFIG_XML = os.path.join(TEST_ASSET_DIR, "site_auth_configurations.xml") - - -class SiteTests(unittest.TestCase): - def setUp(self) -> None: - self.server = TSC.Server("http://test", False) - self.server.version = "3.10" - - # Fake signin - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.server._site_id = "0626857c-1def-4503-a7d8-7907c3ff9d9f" - self.baseurl = self.server.sites.baseurl - - # sites APIs can only be called on the site being logged in to - self.logged_in_site = self.server.site_id - - 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_sites, pagination_item = self.server.sites.get() - - self.assertEqual(2, pagination_item.total_available) - self.assertEqual("dad65087-b08b-4603-af4e-2887b8aafc67", all_sites[0].id) - self.assertEqual("Active", all_sites[0].state) - self.assertEqual("Default", all_sites[0].name) - self.assertEqual("ContentOnly", all_sites[0].admin_mode) - self.assertEqual(False, all_sites[0].revision_history_enabled) - self.assertEqual(True, all_sites[0].subscribe_others_enabled) - self.assertEqual(25, all_sites[0].revision_limit) - self.assertEqual(None, all_sites[0].num_users) - self.assertEqual(None, all_sites[0].storage) - self.assertEqual(True, all_sites[0].cataloging_enabled) - self.assertEqual(False, all_sites[0].editing_flows_enabled) - self.assertEqual(False, all_sites[0].scheduling_flows_enabled) - self.assertEqual(True, all_sites[0].allow_subscription_attachments) - self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", all_sites[1].id) - self.assertEqual("Active", all_sites[1].state) - self.assertEqual("Samples", all_sites[1].name) - self.assertEqual("ContentOnly", all_sites[1].admin_mode) - self.assertEqual(False, all_sites[1].revision_history_enabled) - self.assertEqual(True, all_sites[1].subscribe_others_enabled) - self.assertEqual(False, all_sites[1].guest_access_enabled) - self.assertEqual(True, all_sites[1].cache_warmup_enabled) - self.assertEqual(True, all_sites[1].commenting_enabled) - self.assertEqual(True, all_sites[1].cache_warmup_enabled) - self.assertEqual(False, all_sites[1].request_access_enabled) - self.assertEqual(True, all_sites[1].run_now_enabled) - self.assertEqual(1, all_sites[1].tier_explorer_capacity) - self.assertEqual(2, all_sites[1].tier_creator_capacity) - self.assertEqual(1, all_sites[1].tier_viewer_capacity) - self.assertEqual(False, all_sites[1].flows_enabled) - self.assertEqual(None, all_sites[1].data_acceleration_mode) - - def test_get_before_signin(self) -> None: - self.server._auth_token = None - self.assertRaises(TSC.NotSignedInError, self.server.sites.get) - - 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 + "/" + self.logged_in_site, text=response_xml) - single_site = self.server.sites.get_by_id(self.logged_in_site) - - self.assertEqual(self.logged_in_site, single_site.id) - self.assertEqual("Active", single_site.state) - self.assertEqual("Default", single_site.name) - self.assertEqual("ContentOnly", single_site.admin_mode) - self.assertEqual(False, single_site.revision_history_enabled) - self.assertEqual(True, single_site.subscribe_others_enabled) - self.assertEqual(False, single_site.disable_subscriptions) - self.assertEqual(False, single_site.data_alerts_enabled) - self.assertEqual(False, single_site.commenting_mentions_enabled) - self.assertEqual(True, single_site.catalog_obfuscation_enabled) - - def test_get_by_id_missing_id(self) -> None: - self.assertRaises(ValueError, self.server.sites.get_by_id, "") - - def test_get_by_name(self) -> None: - with open(GET_BY_NAME_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.get(self.baseurl + "/testsite?key=name", text=response_xml) - single_site = self.server.sites.get_by_name("testsite") - - self.assertEqual(self.logged_in_site, single_site.id) - self.assertEqual("Active", single_site.state) - self.assertEqual("testsite", single_site.name) - self.assertEqual("ContentOnly", single_site.admin_mode) - self.assertEqual(False, single_site.revision_history_enabled) - self.assertEqual(True, single_site.subscribe_others_enabled) - self.assertEqual(False, single_site.disable_subscriptions) - - def test_get_by_name_missing_name(self) -> None: - self.assertRaises(ValueError, self.server.sites.get_by_name, "") - - @pytest.mark.filterwarnings("ignore:Tiered license level is set") - @pytest.mark.filterwarnings("ignore:FlowsEnabled has been removed") - 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 + "/" + self.logged_in_site, text=response_xml) - single_site = TSC.SiteItem( - name="Tableau", - content_url="tableau", - admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, - user_quota=15, - storage_quota=1000, - disable_subscriptions=True, - revision_history_enabled=False, - data_acceleration_mode="disable", - flow_auto_save_enabled=True, - web_extraction_enabled=False, - metrics_content_type_enabled=True, - notify_site_admins_on_throttle=False, - authoring_enabled=True, - custom_subscription_email_enabled=True, - custom_subscription_email="test@test.com", - custom_subscription_footer_enabled=True, - custom_subscription_footer="example_footer", - ask_data_mode="EnabledByDefault", - named_sharing_enabled=False, - mobile_biometrics_enabled=True, - sheet_image_enabled=False, - derived_permissions_enabled=True, - user_visibility_mode="FULL", - use_default_time_zone=False, - time_zone="America/Los_Angeles", - auto_suspend_refresh_enabled=True, - auto_suspend_refresh_inactivity_window=55, - tier_creator_capacity=5, - tier_explorer_capacity=5, - tier_viewer_capacity=5, - ) - single_site._id = self.logged_in_site - self.server.sites.parent_srv = self.server - single_site = self.server.sites.update(single_site) - - self.assertEqual(self.logged_in_site, single_site.id) - self.assertEqual("tableau", single_site.content_url) - self.assertEqual("Suspended", single_site.state) - self.assertEqual("Tableau", single_site.name) - self.assertEqual("ContentAndUsers", single_site.admin_mode) - self.assertEqual(True, single_site.revision_history_enabled) - self.assertEqual(13, single_site.revision_limit) - self.assertEqual(True, single_site.disable_subscriptions) - self.assertEqual(None, single_site.user_quota) - self.assertEqual(5, single_site.tier_creator_capacity) - self.assertEqual(5, single_site.tier_explorer_capacity) - self.assertEqual(5, single_site.tier_viewer_capacity) - self.assertEqual("disable", single_site.data_acceleration_mode) - self.assertEqual(True, single_site.flows_enabled) - self.assertEqual(True, single_site.cataloging_enabled) - self.assertEqual(True, single_site.flow_auto_save_enabled) - self.assertEqual(False, single_site.web_extraction_enabled) - self.assertEqual(True, single_site.metrics_content_type_enabled) - self.assertEqual(False, single_site.notify_site_admins_on_throttle) - self.assertEqual(True, single_site.authoring_enabled) - self.assertEqual(True, single_site.custom_subscription_email_enabled) - self.assertEqual("test@test.com", single_site.custom_subscription_email) - self.assertEqual(True, single_site.custom_subscription_footer_enabled) - self.assertEqual("example_footer", single_site.custom_subscription_footer) - self.assertEqual("EnabledByDefault", single_site.ask_data_mode) - self.assertEqual(False, single_site.named_sharing_enabled) - self.assertEqual(True, single_site.mobile_biometrics_enabled) - self.assertEqual(False, single_site.sheet_image_enabled) - self.assertEqual(True, single_site.derived_permissions_enabled) - self.assertEqual("FULL", single_site.user_visibility_mode) - self.assertEqual(False, single_site.use_default_time_zone) - self.assertEqual("America/Los_Angeles", single_site.time_zone) - self.assertEqual(True, single_site.auto_suspend_refresh_enabled) - self.assertEqual(55, single_site.auto_suspend_refresh_inactivity_window) - - def test_update_missing_id(self) -> None: - single_site = TSC.SiteItem("test", "test") - self.assertRaises(TSC.MissingRequiredFieldError, self.server.sites.update, single_site) - - def test_null_site_quota(self) -> None: - test_site = TSC.SiteItem("testname", "testcontenturl", tier_explorer_capacity=1, user_quota=None) - assert test_site.tier_explorer_capacity == 1 - with self.assertRaises(ValueError): - test_site.user_quota = 1 - test_site.tier_explorer_capacity = None +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +GET_XML = TEST_ASSET_DIR / "site_get.xml" +GET_BY_ID_XML = TEST_ASSET_DIR / "site_get_by_id.xml" +GET_BY_NAME_XML = TEST_ASSET_DIR / "site_get_by_name.xml" +UPDATE_XML = TEST_ASSET_DIR / "site_update.xml" +CREATE_XML = TEST_ASSET_DIR / "site_create.xml" +SITE_AUTH_CONFIG_XML = TEST_ASSET_DIR / "site_auth_configurations.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 = "0626857c-1def-4503-a7d8-7907c3ff9d9f" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + server.version = "3.10" + + return server + + +def test_get(server: TSC.Server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(server.sites.baseurl, text=response_xml) + all_sites, pagination_item = server.sites.get() + + assert 2 == pagination_item.total_available + assert "dad65087-b08b-4603-af4e-2887b8aafc67" == all_sites[0].id + assert "Active" == all_sites[0].state + assert "Default" == all_sites[0].name + assert "ContentOnly" == all_sites[0].admin_mode + assert all_sites[0].revision_history_enabled is False + assert all_sites[0].subscribe_others_enabled is True + assert 25 == all_sites[0].revision_limit + assert None == all_sites[0].num_users + assert None == all_sites[0].storage + assert all_sites[0].cataloging_enabled is True + assert all_sites[0].editing_flows_enabled is False + assert all_sites[0].scheduling_flows_enabled is False + assert all_sites[0].allow_subscription_attachments is True + assert "6b7179ba-b82b-4f0f-91ed-812074ac5da6" == all_sites[1].id + assert "Active" == all_sites[1].state + assert "Samples" == all_sites[1].name + assert "ContentOnly" == all_sites[1].admin_mode + assert all_sites[1].revision_history_enabled is False + assert all_sites[1].subscribe_others_enabled is True + assert all_sites[1].guest_access_enabled is False + assert all_sites[1].cache_warmup_enabled is True + assert all_sites[1].commenting_enabled is True + assert all_sites[1].cache_warmup_enabled is True + assert all_sites[1].request_access_enabled is False + assert all_sites[1].run_now_enabled is True + assert 1 == all_sites[1].tier_explorer_capacity + assert 2 == all_sites[1].tier_creator_capacity + assert 1 == all_sites[1].tier_viewer_capacity + assert all_sites[1].flows_enabled is False + assert None == all_sites[1].data_acceleration_mode + + +def test_get_before_signin(server: TSC.Server) -> None: + server._auth_token = None + with pytest.raises(TSC.NotSignedInError): + server.sites.get() + + +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.sites.baseurl + "/" + server.site_id, text=response_xml) + single_site = server.sites.get_by_id(server.site_id) + + assert server.site_id == single_site.id + assert "Active" == single_site.state + assert "Default" == single_site.name + assert "ContentOnly" == single_site.admin_mode + assert single_site.revision_history_enabled is False + assert single_site.subscribe_others_enabled is True + assert single_site.disable_subscriptions is False + assert single_site.data_alerts_enabled is False + assert single_site.commenting_mentions_enabled is False + assert single_site.catalog_obfuscation_enabled is True + + +def test_get_by_id_missing_id(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.sites.get_by_id("") + + +def test_get_by_name(server: TSC.Server) -> None: + response_xml = GET_BY_NAME_XML.read_text() + with requests_mock.mock() as m: + m.get(server.sites.baseurl + "/testsite?key=name", text=response_xml) + single_site = server.sites.get_by_name("testsite") + + assert server.site_id == single_site.id + assert "Active" == single_site.state + assert "testsite" == single_site.name + assert "ContentOnly" == single_site.admin_mode + assert single_site.revision_history_enabled is False + assert single_site.subscribe_others_enabled is True + assert single_site.disable_subscriptions is False + + +def test_get_by_name_missing_name(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.sites.get_by_name("") + + +@pytest.mark.filterwarnings("ignore:Tiered license level is set") +@pytest.mark.filterwarnings("ignore:FlowsEnabled has been removed") +def test_update(server: TSC.Server) -> None: + response_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.put(server.sites.baseurl + "/" + server.site_id, text=response_xml) + single_site = TSC.SiteItem( + name="Tableau", + content_url="tableau", + admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, + user_quota=15, + storage_quota=1000, + disable_subscriptions=True, + revision_history_enabled=False, + data_acceleration_mode="disable", + flow_auto_save_enabled=True, + web_extraction_enabled=False, + metrics_content_type_enabled=True, + notify_site_admins_on_throttle=False, + authoring_enabled=True, + custom_subscription_email_enabled=True, + custom_subscription_email="test@test.com", + custom_subscription_footer_enabled=True, + custom_subscription_footer="example_footer", + ask_data_mode="EnabledByDefault", + named_sharing_enabled=False, + mobile_biometrics_enabled=True, + sheet_image_enabled=False, + derived_permissions_enabled=True, + user_visibility_mode="FULL", + use_default_time_zone=False, + time_zone="America/Los_Angeles", + auto_suspend_refresh_enabled=True, + auto_suspend_refresh_inactivity_window=55, + tier_creator_capacity=5, + tier_explorer_capacity=5, + tier_viewer_capacity=5, + ) + single_site._id = server.site_id + server.sites.parent_srv = server + single_site = server.sites.update(single_site) + + assert server.site_id == single_site.id + assert "tableau" == single_site.content_url + assert "Suspended" == single_site.state + assert "Tableau" == single_site.name + assert "ContentAndUsers" == single_site.admin_mode + assert single_site.revision_history_enabled is True + assert 13 == single_site.revision_limit + assert single_site.disable_subscriptions is True + assert None == single_site.user_quota + assert 5 == single_site.tier_creator_capacity + assert 5 == single_site.tier_explorer_capacity + assert 5 == single_site.tier_viewer_capacity + assert "disable" == single_site.data_acceleration_mode + assert single_site.flows_enabled is True + assert single_site.cataloging_enabled is True + assert single_site.flow_auto_save_enabled is True + assert single_site.web_extraction_enabled is False + assert single_site.metrics_content_type_enabled is True + assert single_site.notify_site_admins_on_throttle is False + assert single_site.authoring_enabled is True + assert single_site.custom_subscription_email_enabled is True + assert "test@test.com" == single_site.custom_subscription_email + assert single_site.custom_subscription_footer_enabled is True + assert "example_footer" == single_site.custom_subscription_footer + assert "EnabledByDefault" == single_site.ask_data_mode + assert single_site.named_sharing_enabled is False + assert single_site.mobile_biometrics_enabled is True + assert single_site.sheet_image_enabled is False + assert single_site.derived_permissions_enabled is True + assert "FULL" == single_site.user_visibility_mode + assert single_site.use_default_time_zone is False + assert "America/Los_Angeles" == single_site.time_zone + assert single_site.auto_suspend_refresh_enabled is True + assert 55 == single_site.auto_suspend_refresh_inactivity_window + + +def test_update_missing_id(server: TSC.Server) -> None: + single_site = TSC.SiteItem("test", "test") + with pytest.raises(TSC.MissingRequiredFieldError): + server.sites.update(single_site) + + +def test_null_site_quota(server: TSC.Server) -> None: + test_site = TSC.SiteItem("testname", "testcontenturl", tier_explorer_capacity=1, user_quota=None) + assert test_site.tier_explorer_capacity == 1 + with pytest.raises(ValueError): + test_site.user_quota = 1 + test_site.tier_explorer_capacity = None + test_site.user_quota = 1 + + +def test_replace_license_tiers_with_user_quota(server: TSC.Server) -> None: + test_site = TSC.SiteItem("testname", "testcontenturl", tier_explorer_capacity=1, user_quota=None) + assert test_site.tier_explorer_capacity == 1 + with pytest.raises(ValueError): test_site.user_quota = 1 + test_site.replace_license_tiers_with_user_quota(1) + assert 1 == test_site.user_quota + assert test_site.tier_explorer_capacity is None + - def test_replace_license_tiers_with_user_quota(self) -> None: - test_site = TSC.SiteItem("testname", "testcontenturl", tier_explorer_capacity=1, user_quota=None) - assert test_site.tier_explorer_capacity == 1 - with self.assertRaises(ValueError): - test_site.user_quota = 1 - test_site.replace_license_tiers_with_user_quota(1) - self.assertEqual(1, test_site.user_quota) - self.assertIsNone(test_site.tier_explorer_capacity) - - @pytest.mark.filterwarnings("ignore:FlowsEnabled has been removed") - def test_create(self) -> None: - with open(CREATE_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_site = TSC.SiteItem( - name="Tableau", - content_url="tableau", - admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, - user_quota=15, - storage_quota=1000, - disable_subscriptions=True, - ) - new_site = self.server.sites.create(new_site) - - new_site._tier_viewer_capacity = None - new_site._tier_creator_capacity = None - new_site._tier_explorer_capacity = None - self.assertEqual("0626857c-1def-4503-a7d8-7907c3ff9d9f", new_site.id) - self.assertEqual("tableau", new_site.content_url) - self.assertEqual("Tableau", new_site.name) - self.assertEqual("Active", new_site.state) - self.assertEqual("ContentAndUsers", new_site.admin_mode) - self.assertEqual(False, new_site.revision_history_enabled) - self.assertEqual(True, new_site.subscribe_others_enabled) - self.assertEqual(True, new_site.disable_subscriptions) - self.assertEqual(15, new_site.user_quota) - - def test_delete(self) -> None: - with requests_mock.mock() as m: - m.delete(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f", status_code=204) - self.server.sites.delete("0626857c-1def-4503-a7d8-7907c3ff9d9f") - - def test_delete_missing_id(self) -> None: - self.assertRaises(ValueError, self.server.sites.delete, "") - - def test_encrypt(self) -> None: - with requests_mock.mock() as m: - m.post(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/encrypt-extracts", status_code=200) - self.server.sites.encrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") - - def test_recrypt(self) -> None: - with requests_mock.mock() as m: - m.post(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/reencrypt-extracts", status_code=200) - self.server.sites.re_encrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") - - def test_decrypt(self) -> None: - with requests_mock.mock() as m: - m.post(self.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/decrypt-extracts", status_code=200) - self.server.sites.decrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") - - def test_list_auth_configurations(self) -> None: - self.server.version = "3.24" - self.baseurl = self.server.sites.baseurl - with open(SITE_AUTH_CONFIG_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - - assert self.baseurl == self.server.sites.baseurl - - with requests_mock.mock() as m: - m.get(f"{self.baseurl}/{self.server.site_id}/site-auth-configurations", status_code=200, text=response_xml) - configs = self.server.sites.list_auth_configurations() - - assert len(configs) == 2, "Expected 2 auth configurations" - - assert configs[0].auth_setting == "OIDC" - assert configs[0].enabled - assert configs[0].idp_configuration_id == "00000000-0000-0000-0000-000000000000" - assert configs[0].idp_configuration_name == "Initial Salesforce" - assert configs[0].known_provider_alias == "Salesforce" - assert configs[1].auth_setting == "SAML" - assert configs[1].enabled - 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.filterwarnings("ignore:FlowsEnabled has been removed") +def test_create(server: TSC.Server) -> None: + response_xml = CREATE_XML.read_text() + with requests_mock.mock() as m: + m.post(server.sites.baseurl, text=response_xml) + new_site = TSC.SiteItem( + name="Tableau", + content_url="tableau", + admin_mode=TSC.SiteItem.AdminMode.ContentAndUsers, + user_quota=15, + storage_quota=1000, + disable_subscriptions=True, + ) + new_site = server.sites.create(new_site) + + new_site._tier_viewer_capacity = None + new_site._tier_creator_capacity = None + new_site._tier_explorer_capacity = None + assert "0626857c-1def-4503-a7d8-7907c3ff9d9f" == new_site.id + assert "tableau" == new_site.content_url + assert "Tableau" == new_site.name + assert "Active" == new_site.state + assert "ContentAndUsers" == new_site.admin_mode + assert new_site.revision_history_enabled is False + assert new_site.subscribe_others_enabled is True + assert new_site.disable_subscriptions is True + assert 15 == new_site.user_quota + + +def test_delete(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.delete(server.sites.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f", status_code=204) + server.sites.delete("0626857c-1def-4503-a7d8-7907c3ff9d9f") + + +def test_delete_missing_id(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.sites.delete("") + + +def test_encrypt(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.post(server.sites.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/encrypt-extracts", status_code=200) + server.sites.encrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") + + +def test_recrypt(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.post(server.sites.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/reencrypt-extracts", status_code=200) + server.sites.re_encrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") + + +def test_decrypt(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.post(server.sites.baseurl + "/0626857c-1def-4503-a7d8-7907c3ff9d9f/decrypt-extracts", status_code=200) + server.sites.decrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") + + +def test_list_auth_configurations(server: TSC.Server) -> None: + server.version = "3.24" + response_xml = SITE_AUTH_CONFIG_XML.read_text() + + assert server.sites.baseurl == server.sites.baseurl + + with requests_mock.mock() as m: + m.get(f"{server.sites.baseurl}/{server.site_id}/site-auth-configurations", status_code=200, text=response_xml) + configs = server.sites.list_auth_configurations() + + assert len(configs) == 2, "Expected 2 auth configurations" + + assert configs[0].auth_setting == "OIDC" + assert configs[0].enabled + assert configs[0].idp_configuration_id == "00000000-0000-0000-0000-000000000000" + assert configs[0].idp_configuration_name == "Initial Salesforce" + assert configs[0].known_provider_alias == "Salesforce" + assert configs[1].auth_setting == "SAML" + assert configs[1].enabled + 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]) diff --git a/test/test_site_model.py b/test/test_site_model.py index 60ad9c5e5..14914b875 100644 --- a/test/test_site_model.py +++ b/test/test_site_model.py @@ -1,65 +1,73 @@ -import unittest +import pytest import tableauserverclient as TSC -class SiteModelTests(unittest.TestCase): - def test_invalid_name(self): - self.assertRaises(ValueError, TSC.SiteItem, None, "url") - self.assertRaises(ValueError, TSC.SiteItem, "", "url") - site = TSC.SiteItem("site", "url") - with self.assertRaises(ValueError): - site.name = None +def test_invalid_name(): + with pytest.raises(ValueError): + TSC.SiteItem(None, "url") + with pytest.raises(ValueError): + TSC.SiteItem("", "url") + site = TSC.SiteItem("site", "url") + with pytest.raises(ValueError): + site.name = None - with self.assertRaises(ValueError): - site.name = "" + with pytest.raises(ValueError): + site.name = "" - def test_invalid_admin_mode(self): - site = TSC.SiteItem("site", "url") - with self.assertRaises(ValueError): - site.admin_mode = "Hello" - def test_invalid_content_url(self): - with self.assertRaises(ValueError): - site = TSC.SiteItem(name="蚵仔煎", content_url="蚵仔煎") +def test_invalid_admin_mode(): + site = TSC.SiteItem("site", "url") + with pytest.raises(ValueError): + site.admin_mode = "Hello" - with self.assertRaises(ValueError): - site = TSC.SiteItem(name="蚵仔煎", content_url=None) - def test_set_valid_content_url(self): - # Default Site - site = TSC.SiteItem(name="Default", content_url="") - self.assertEqual(site.content_url, "") +def test_invalid_content_url(): + with pytest.raises(ValueError): + site = TSC.SiteItem(name="蚵仔煎", content_url="蚵仔煎") - # Unicode Name and ascii content_url - site = TSC.SiteItem(name="蚵仔煎", content_url="omlette") - self.assertEqual(site.content_url, "omlette") + with pytest.raises(ValueError): + site = TSC.SiteItem(name="蚵仔煎", content_url=None) - def test_invalid_disable_subscriptions(self): - site = TSC.SiteItem("site", "url") - with self.assertRaises(ValueError): - site.disable_subscriptions = "Hello" - with self.assertRaises(ValueError): - site.disable_subscriptions = None +def test_set_valid_content_url(): + # Default Site + site = TSC.SiteItem(name="Default", content_url="") + assert site.content_url == "" - def test_invalid_revision_history_enabled(self): - site = TSC.SiteItem("site", "url") - with self.assertRaises(ValueError): - site.revision_history_enabled = "Hello" + # Unicode Name and ascii content_url + site = TSC.SiteItem(name="蚵仔煎", content_url="omlette") + assert site.content_url == "omlette" - with self.assertRaises(ValueError): - site.revision_history_enabled = None - def test_invalid_state(self): - site = TSC.SiteItem("site", "url") - with self.assertRaises(ValueError): - site.state = "Hello" +def test_invalid_disable_subscriptions(): + site = TSC.SiteItem("site", "url") + with pytest.raises(ValueError): + site.disable_subscriptions = "Hello" - def test_invalid_subscribe_others_enabled(self): - site = TSC.SiteItem("site", "url") - with self.assertRaises(ValueError): - site.subscribe_others_enabled = "Hello" + with pytest.raises(ValueError): + site.disable_subscriptions = None - with self.assertRaises(ValueError): - site.subscribe_others_enabled = None + +def test_invalid_revision_history_enabled(): + site = TSC.SiteItem("site", "url") + with pytest.raises(ValueError): + site.revision_history_enabled = "Hello" + + with pytest.raises(ValueError): + site.revision_history_enabled = None + + +def test_invalid_state(): + site = TSC.SiteItem("site", "url") + with pytest.raises(ValueError): + site.state = "Hello" + + +def test_invalid_subscribe_others_enabled(): + site = TSC.SiteItem("site", "url") + with pytest.raises(ValueError): + site.subscribe_others_enabled = "Hello" + + with pytest.raises(ValueError): + site.subscribe_others_enabled = None From b4ef4a87bfbc76f89d56001ad127deb3eab3dbed Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:43:53 -0600 Subject: [PATCH 83/98] chore: pytestify file uploads (#1715) * fix: black ci errors * chore: pytestify file uploads --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_fileuploads.py | 127 ++++++++++++++++++++------------------- 1 file changed, 65 insertions(+), 62 deletions(-) diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index 9567bc3ad..2e69b5884 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -1,17 +1,18 @@ import contextlib import io import os -import unittest +from pathlib import Path +import pytest import requests_mock +import tableauserverclient as TSC from tableauserverclient.config import BYTES_PER_MB, config -from tableauserverclient.server import Server -from ._utils import asset -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") -FILEUPLOAD_INITIALIZE = os.path.join(TEST_ASSET_DIR, "fileupload_initialize.xml") -FILEUPLOAD_APPEND = os.path.join(TEST_ASSET_DIR, "fileupload_append.xml") +TEST_ASSET_DIR = Path(__file__).parent / "assets" +FILEUPLOAD_INITIALIZE = TEST_ASSET_DIR / "fileupload_initialize.xml" +FILEUPLOAD_APPEND = TEST_ASSET_DIR / "fileupload_append.xml" +SAMPLE_WB = TEST_ASSET_DIR / "SampleWB.twbx" @contextlib.contextmanager @@ -25,65 +26,67 @@ def set_env(**environ): os.environ.update(old_environ) -class FileuploadsTests(unittest.TestCase): - def setUp(self): - self.server = Server("http://test", False) +@pytest.fixture(scope="function") +def server(): + """Fixture to create a TSC.Server instance for testing.""" + server = TSC.Server("http://test", False) - # Fake sign in - self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + # Fake signin + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/fileUploads" + return server - def test_read_chunks_file_path(self): - file_path = asset("SampleWB.twbx") - chunks = self.server.fileuploads._read_chunks(file_path) + +def test_read_chunks_file_path(server: TSC.Server) -> None: + file_path = str(SAMPLE_WB) + chunks = server.fileuploads._read_chunks(file_path) + for chunk in chunks: + assert chunk is not None + + +def test_read_chunks_file_object(server: TSC.Server) -> None: + with SAMPLE_WB.open("rb") as f: + chunks = server.fileuploads._read_chunks(f) for chunk in chunks: - self.assertIsNotNone(chunk) - - def test_read_chunks_file_object(self): - with open(asset("SampleWB.twbx"), "rb") as f: - chunks = self.server.fileuploads._read_chunks(f) - for chunk in chunks: - self.assertIsNotNone(chunk) - - def test_upload_chunks_file_path(self): - file_path = asset("SampleWB.twbx") - upload_id = "7720:170fe6b1c1c7422dadff20f944d58a52-1:0" - - with open(FILEUPLOAD_INITIALIZE, "rb") as f: - initialize_response_xml = f.read().decode("utf-8") - with open(FILEUPLOAD_APPEND, "rb") as f: - append_response_xml = f.read().decode("utf-8") + assert chunk is not None + + +def test_upload_chunks_file_path(server: TSC.Server) -> None: + file_path = str(SAMPLE_WB) + upload_id = "7720:170fe6b1c1c7422dadff20f944d58a52-1:0" + + initialize_response_xml = FILEUPLOAD_INITIALIZE.read_text() + append_response_xml = FILEUPLOAD_APPEND.read_text() + with requests_mock.mock() as m: + m.post(server.fileuploads.baseurl, text=initialize_response_xml) + m.put(f"{server.fileuploads.baseurl}/{upload_id}", text=append_response_xml) + actual = server.fileuploads.upload(file_path) + + assert upload_id == actual + + +def test_upload_chunks_file_object(server: TSC.Server) -> None: + upload_id = "7720:170fe6b1c1c7422dadff20f944d58a52-1:0" + + with SAMPLE_WB.open("rb") as file_content: + initialize_response_xml = FILEUPLOAD_INITIALIZE.read_text() + append_response_xml = FILEUPLOAD_APPEND.read_text() with requests_mock.mock() as m: - m.post(self.baseurl, text=initialize_response_xml) - m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) - actual = self.server.fileuploads.upload(file_path) - - self.assertEqual(upload_id, actual) - - def test_upload_chunks_file_object(self): - upload_id = "7720:170fe6b1c1c7422dadff20f944d58a52-1:0" - - with open(asset("SampleWB.twbx"), "rb") as file_content: - with open(FILEUPLOAD_INITIALIZE, "rb") as f: - initialize_response_xml = f.read().decode("utf-8") - with open(FILEUPLOAD_APPEND, "rb") as f: - append_response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=initialize_response_xml) - m.put(f"{self.baseurl}/{upload_id}", text=append_response_xml) - actual = self.server.fileuploads.upload(file_content) - - self.assertEqual(upload_id, actual) - - def test_upload_chunks_config(self): - data = io.BytesIO() - data.write(b"1" * (config.CHUNK_SIZE_MB * BYTES_PER_MB + 1)) + m.post(server.fileuploads.baseurl, text=initialize_response_xml) + m.put(f"{server.fileuploads.baseurl}/{upload_id}", text=append_response_xml) + actual = server.fileuploads.upload(file_content) + + assert upload_id == actual + + +def test_upload_chunks_config(server: TSC.Server) -> None: + data = io.BytesIO() + data.write(b"1" * (config.CHUNK_SIZE_MB * BYTES_PER_MB + 1)) + data.seek(0) + with set_env(TSC_CHUNK_SIZE_MB="1"): + chunker = server.fileuploads._read_chunks(data) + chunk = next(chunker) + assert len(chunk) == config.CHUNK_SIZE_MB * BYTES_PER_MB data.seek(0) - with set_env(TSC_CHUNK_SIZE_MB="1"): - chunker = self.server.fileuploads._read_chunks(data) - chunk = next(chunker) - assert len(chunk) == config.CHUNK_SIZE_MB * BYTES_PER_MB - data.seek(0) - assert len(chunk) < len(data.read()) + assert len(chunk) < len(data.read()) From fbe5a95a6378edaf99fa5dec12bf0e313013832a Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 23 Dec 2025 13:41:01 -0800 Subject: [PATCH 84/98] Update urllib (FOSSA) and black (#1723) Update pyproject.toml --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 52f75f5f4..872f50ce0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ 'defusedxml>=0.7.1', # latest as at 7/31/23 'packaging>=23.1', # latest as at 7/31/23 'requests>=2.32', # latest as at 7/31/23 - 'urllib3>=2.2.2,<3', + 'urllib3>=2.6.0,<3', 'typing_extensions>=4.0', ] requires-python = ">=3.9" @@ -32,7 +32,7 @@ classifiers = [ repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["black==24.8", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", +test = ["black==24.10", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", "requests-mock>=1.0,<2.0", "types-requests>=2.32.4.20250913"] [tool.black] line-length = 120 From ba43c976bae7993e872e3f613ef7d11319a75d8e Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 15:42:11 -0600 Subject: [PATCH 85/98] feat: delete view (#1712) * fix: black ci errors * chore: pytestify views * chore: pytestify view acceleration * feat: delete_view Starting in Server 2025.3, views can be deleted. --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../server/endpoint/views_endpoint.py | 23 +++++++++++++++++++ test/test_view.py | 17 ++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 9d1c8b00f..162c04105 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -371,6 +371,29 @@ def update(self, view_item: ViewItem) -> ViewItem: # Returning view item to stay consistent with datasource/view update functions return view_item + @api(version="3.27") + def delete(self, view: ViewItem | str) -> None: + """ + Deletes a view in a workbook. If you delete the only view in a workbook, + the workbook is deleted. Can be used to remove hidden views when + republishing or migrating to a different environment. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_view + + Parameters + ---------- + view: ViewItem | str + The ViewItem or the luid for the view to be deleted. + + Returns + ------- + None + """ + id_ = getattr(view, "id", view) + self.delete_request(f"{self.baseurl}/{id_}") + logger.info(f"View({id_}) deleted.") + return None + @api(version="1.0") def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> set[str]: """ diff --git a/test/test_view.py b/test/test_view.py index e032ed569..b16f47c72 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -520,3 +520,20 @@ def test_view_get_all_fields(server: TSC.Server) -> None: assert isinstance(views[2].location, TSC.LocationItem) assert views[2].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a" assert views[2].location.type == "Project" + + +def make_view() -> TSC.ViewItem: + view = TSC.ViewItem() + view._id = "1234" + return view + + +@pytest.mark.parametrize("view", [make_view, "1234"]) +def test_delete_view(server: TSC.Server, view: TSC.ViewItem | str) -> None: + server.version = "3.27" + id_ = getattr(view, "id", view) + with requests_mock.mock() as m: + m.delete(f"{server.views.baseurl}/{id_}") + server.views.delete(view) + assert m.called + assert m.call_count == 1 From 1e116f35f7f9981c77d712b3eef9a04a3e66a039 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 15:55:18 -0600 Subject: [PATCH 86/98] feat: batch create schedule (#1714) * chore: pytestify schedule * chore: remove unused imports * feat: batch update schedule state --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/models/schedule_item.py | 11 +++- .../server/endpoint/schedules_endpoint.py | 49 +++++++++++++++- tableauserverclient/server/request_factory.py | 10 ++++ test/assets/schedule_batch_update_state.xml | 7 +++ test/test_schedule.py | 57 ++++++++++++++++++- 5 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 test/assets/schedule_batch_update_state.xml diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index a2118e3d6..794d9883b 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from datetime import datetime -from typing import Optional, Union +from typing import Optional, Union, TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -16,6 +16,10 @@ property_is_enum, ) +if TYPE_CHECKING: + from requests import Response + + Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] @@ -407,3 +411,8 @@ def _read_warnings(parsed_response, ns): for warning_xml in all_warning_xml: warnings.append(warning_xml.get("message", None)) return warnings + + +def parse_batch_schedule_state(response: "Response", ns) -> list[str]: + xml = fromstring(response.content) + return [text for tag in xml.findall(".//t:scheduleLuid", namespaces=ns) if (text := tag.text)] diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 090d400b6..8984af407 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -1,13 +1,15 @@ +from collections.abc import Iterable import copy import logging import warnings from collections import namedtuple -from typing import TYPE_CHECKING, Callable, Optional, Union +from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Union, overload from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory from tableauserverclient.models import PaginationItem, ScheduleItem, TaskItem, ExtractItem +from tableauserverclient.models.schedule_item import parse_batch_schedule_state from tableauserverclient.helpers.logging import logger @@ -279,3 +281,48 @@ def get_extract_refresh_tasks( extract_items = ExtractItem.from_response(server_response.content, self.parent_srv.namespace) return extract_items, pagination_item + + @overload + def batch_update_state( + self, + schedules: Iterable[ScheduleItem | str], + state: Literal["active", "suspended"], + update_all: Literal[False] = False, + ) -> list[str]: ... + + @overload + def batch_update_state( + self, schedules: Any, state: Literal["active", "suspended"], update_all: Literal[True] + ) -> list[str]: ... + + @api(version="3.27") + def batch_update_state(self, schedules, state, update_all=False) -> list[str]: + """ + Batch update the status of one or more scheudles. If update_all is set, + all schedules on the Tableau Server are affected. + + Parameters + ---------- + schedules: Iterable[ScheudleItem | str] | Any + The schedules to be updated. If update_all=True, this is ignored. + + state: Literal["active", "suspended"] + The state of the schedules, whether active or suspended. + + update_all: bool + Whether or not to apply the status to all schedules. + + Returns + ------- + List[str] + The IDs of the affected schedules. + """ + params = {"state": state} + if update_all: + params["updateAll"] = "true" + payload = RequestFactory.Empty.empty_req() + else: + payload = RequestFactory.Schedule.batch_update_state(schedules) + + response = self.put_request(self.baseurl, payload, parameters={"params": params}) + return parse_batch_schedule_state(response, self.parent_srv.namespace) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 66071bbeb..e05ccb8c8 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -643,6 +643,16 @@ def add_datasource_req(self, id_: Optional[str], task_type: str = TaskItem.Type. def add_flow_req(self, id_: Optional[str], task_type: str = TaskItem.Type.RunFlow) -> bytes: return self._add_to_req(id_, "flow", task_type) + @_tsrequest_wrapped + def batch_update_state(self, xml: ET.Element, schedules: Iterable[ScheduleItem | str]) -> None: + luids = ET.SubElement(xml, "scheduleLuids") + for schedule in schedules: + luid = getattr(schedule, "id", schedule) + if not isinstance(luid, str): + continue + luid_tag = ET.SubElement(luids, "scheduleLuid") + luid_tag.text = luid + class SiteRequest: def update_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None): diff --git a/test/assets/schedule_batch_update_state.xml b/test/assets/schedule_batch_update_state.xml new file mode 100644 index 000000000..7749a3eeb --- /dev/null +++ b/test/assets/schedule_batch_update_state.xml @@ -0,0 +1,7 @@ + + + 593d2ebf-0d18-4deb-9d21-b113a4902583 + cecbb71e-def0-4030-8068-5ae50f51db1c + f39a6e7d-405e-4c07-8c18-95845f9da80e + + diff --git a/test/test_schedule.py b/test/test_schedule.py index 307bc0e51..45e35ec25 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -26,7 +26,7 @@ ADD_DATASOURCE_TO_SCHEDULE = TEST_ASSET_DIR / "schedule_add_datasource.xml" ADD_FLOW_TO_SCHEDULE = TEST_ASSET_DIR / "schedule_add_flow.xml" GET_EXTRACT_TASKS_XML = TEST_ASSET_DIR / "schedule_get_extract_refresh_tasks.xml" -BATCH_UPDATE_STATE = TEST_ASSET_DIR / "schedules_batch_update_state.xml" +BATCH_UPDATE_STATE = TEST_ASSET_DIR / "schedule_batch_update_state.xml" WORKBOOK_GET_BY_ID_XML = TEST_ASSET_DIR / "workbook_get_by_id.xml" DATASOURCE_GET_BY_ID_XML = TEST_ASSET_DIR / "datasource_get_by_id.xml" @@ -421,3 +421,58 @@ def test_get_extract_refresh_tasks(server: TSC.Server) -> None: assert isinstance(extracts[0], list) assert 2 == len(extracts[0]) assert "task1" == extracts[0][0].id + + +def test_batch_update_state_items(server: TSC.Server) -> None: + server.version = "3.27" + hourly_interval = TSC.HourlyInterval(start_time=time(2, 30), end_time=time(23, 0), interval_value=2) + args = ("hourly", 50, TSC.ScheduleItem.Type.Extract, TSC.ScheduleItem.ExecutionOrder.Parallel, hourly_interval) + new_schedules = [TSC.ScheduleItem(*args), TSC.ScheduleItem(*args), TSC.ScheduleItem(*args)] + new_schedules[0]._id = "593d2ebf-0d18-4deb-9d21-b113a4902583" + new_schedules[1]._id = "cecbb71e-def0-4030-8068-5ae50f51db1c" + new_schedules[2]._id = "f39a6e7d-405e-4c07-8c18-95845f9da80e" + + state = "active" + with requests_mock.mock() as m: + m.put(f"{server.schedules.baseurl}?state={state}", text=BATCH_UPDATE_STATE.read_text()) + resp = server.schedules.batch_update_state(new_schedules, state) + + assert len(resp) == 3 + for sch, r in zip(new_schedules, resp): + assert sch.id == r + + +def test_batch_update_state_str(server: TSC.Server) -> None: + server.version = "3.27" + new_schedules = [ + "593d2ebf-0d18-4deb-9d21-b113a4902583", + "cecbb71e-def0-4030-8068-5ae50f51db1c", + "f39a6e7d-405e-4c07-8c18-95845f9da80e", + ] + + state = "suspended" + with requests_mock.mock() as m: + m.put(f"{server.schedules.baseurl}?state={state}", text=BATCH_UPDATE_STATE.read_text()) + resp = server.schedules.batch_update_state(new_schedules, state) + + assert len(resp) == 3 + for sch, r in zip(new_schedules, resp): + assert sch == r + + +def test_batch_update_state_all(server: TSC.Server) -> None: + server.version = "3.27" + new_schedules = [ + "593d2ebf-0d18-4deb-9d21-b113a4902583", + "cecbb71e-def0-4030-8068-5ae50f51db1c", + "f39a6e7d-405e-4c07-8c18-95845f9da80e", + ] + + state = "suspended" + with requests_mock.mock() as m: + m.put(f"{server.schedules.baseurl}?state={state}&updateAll=true", text=BATCH_UPDATE_STATE.read_text()) + _ = server.schedules.batch_update_state(new_schedules, state, True) + + history = m.request_history[0] + + assert history.text == "" From fe977497746cbb0338494927fbc0ebf8e7669108 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 16:45:52 -0600 Subject: [PATCH 87/98] feat: users csv import (#1409) * fix: black ci errors * feat: enable bulk adding users * feat: ensure domain name is included if provided * style: black * chore: test missing user name * feat: implement users bulk_remove * chore: suppress deprecation warning in test * chore: split csv add creation to own test * chore: use subTests in remove_users * chore: user factory function in make_user * docs: bulk_add docstring * fix: assert on warning instead of ignore * chore: missed an absolute import * docs: bulk_add docstring * docs: create_users_csv docstring * chore: deprecate add_all method * test: test add_all and check DeprecationWarning * docs: docstring updates for bulk add operations * docs: add examples to docstrings * chore: update deprecated version # * feat: enable idp_configuration_id in bulk_add * chore: remove outdated docstring text * test: remove_users_csv * chore: update deprecated version number * chore: pytestify test_user * chore: pytestify test_user_model * style: black --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../server/endpoint/users_endpoint.py | 279 +++++++++++++++++- tableauserverclient/server/request_factory.py | 26 ++ test/assets/users_bulk_add_job.xml | 4 + test/test_user.py | 242 ++++++++++++++- 4 files changed, 544 insertions(+), 7 deletions(-) create mode 100644 test/assets/users_bulk_add_job.xml diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 17af21a03..6deb76716 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -1,14 +1,19 @@ +from collections.abc import Iterable import copy +import csv +import io +import itertools import logging from typing import Optional +import warnings from tableauserverclient.server.query import QuerySet -from .endpoint import QuerysetEndpoint, api -from .exceptions import MissingRequiredFieldError, ServerResponseError +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError, ServerResponseError from tableauserverclient.server import RequestFactory, RequestOptions -from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem -from ..pager import Pager +from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem, JobItem +from tableauserverclient.server.pager import Pager from tableauserverclient.helpers.logging import logger @@ -344,7 +349,34 @@ def add(self, user_item: UserItem) -> UserItem: # Add new users to site. This does not actually perform a bulk action, it's syntactic sugar @api(version="2.0") - def add_all(self, users: list[UserItem]): + def add_all(self, users: list[UserItem]) -> tuple[list[UserItem], list[UserItem]]: + """ + Syntactic sugar for calling users.add multiple times. This method has + been deprecated in favor of using the bulk_add which accomplishes the + same task in one API call. + + .. deprecated:: v0.41.0 + `add_all` will be removed as its functionality is replicated via + the `bulk_add` method. + + Parameters + ---------- + users: list[UserItem] + A list of UserItem objects to add to the site. Each UserItem object + will be passed to the `add` method individually. + + Returns + ------- + tuple[list[UserItem], list[UserItem]] + The first element of the tuple is a list of UserItem objects that + were successfully added to the site. The second element is a list + of UserItem objects that failed to be added to the site. + + Warnings + -------- + This method is deprecated. Use the `bulk_add` method instead. + """ + warnings.warn("This method is deprecated, use bulk_add method instead.", DeprecationWarning) created = [] failed = [] for user in users: @@ -357,8 +389,143 @@ def add_all(self, users: list[UserItem]): # helping the user by parsing a file they could have used to add users through the UI # line format: Username [required], password, display name, license, admin, publish + @api(version="3.15") + def bulk_add(self, users: Iterable[UserItem]) -> JobItem: + """ + When adding users in bulk, the server will return a job item that can be used to track the progress of the + operation. This method will return the job item that was created when the users were added. + + For each user, name is required, and other fields are optional. If connected to activte directory and + the user name is not unique across domains, then the domain attribute must be populated on + the UserItem. + + The user's display name is read from the fullname attribute. + + Email is optional, but if provided, it must be a valid email address. + + If auth_setting is not provided, and idp_configuration_id is None, then + default is ServerDefault. + + If site_role is not provided, the default is Unlicensed. + + Password is optional, and only used if the server is using local + authentication. If using any other authentication method, the password + should not be provided. + + Details about administrator level and publishing capability are + inferred from the site_role. + + If the user belongs to a different IDP configuration, the UserItem's + idp_configuration_id attribute must be set to the IDP configuration ID + that the user belongs to. + + Parameters + ---------- + users: Iterable[UserItem] + An iterable of UserItem objects to add to the site. See above for + what fields are required and optional. + + Returns + ------- + JobItem + The job that is started for adding the users in bulk. + + Examples + -------- + >>> import tableauserverclient as TSC + >>> server = TSC.Server('http://localhost') + >>> # Login to the server + + >>> # Create a list of UserItem objects to add to the site + >>> users = [ + >>> TSC.UserItem(name="user1", site_role="Unlicensed"), + >>> TSC.UserItem(name="user2", site_role="Explorer"), + >>> TSC.UserItem(name="user3", site_role="Creator"), + >>> ] + + >>> # Set the domain name for the users + >>> for user in users: + >>> user.domain_name = "example.com" + + >>> # Add the users to the site + >>> job = server.users.bulk_add(users) + + """ + url = f"{self.baseurl}/import" + # Allow for iterators to be passed into the function + csv_users, xml_users = itertools.tee(users, 2) + csv_content = create_users_csv(csv_users) + + xml_request, content_type = RequestFactory.User.import_from_csv_req(csv_content, xml_users) + server_response = self.post_request(url, xml_request, content_type) + return JobItem.from_response(server_response.content, self.parent_srv.namespace).pop() + + @api(version="3.15") + def bulk_remove(self, users: Iterable[UserItem]) -> None: + """ + Remove multiple users from the site. The users are identified by their + domain and name. The users are removed in bulk, so the server will not + return a job item to track the progress of the operation nor a response + for each user that was removed. + + Parameters + ---------- + users: Iterable[UserItem] + An iterable of UserItem objects to remove from the site. Each + UserItem object should have the domain and name attributes set. + + Returns + ------- + None + + Examples + -------- + >>> import tableauserverclient as TSC + >>> server = TSC.Server('http://localhost') + >>> # Login to the server + + >>> # Find the users to remove + >>> example_users = server.users.filter(domain_name="example.com") + >>> server.users.bulk_remove(example_users) + """ + url = f"{self.baseurl}/delete" + csv_content = remove_users_csv(users) + request, content_type = RequestFactory.User.delete_csv_req(csv_content) + server_response = self.post_request(url, request, content_type) + return None + @api(version="2.0") def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]: + """ + Syntactic sugar for calling users.add multiple times. This method has + been deprecated in favor of using the bulk_add which accomplishes the + same task in one API call. + + .. deprecated:: v0.41.0 + `add_all` will be removed as its functionality is replicated via + the `bulk_add` method. + + Parameters + ---------- + filepath: str + The path to the CSV file containing the users to add to the site. + The file is read in line by line and each line is passed to the + `add` method. + + Returns + ------- + tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]] + The first element of the tuple is a list of UserItem objects that + were successfully added to the site. The second element is a list + of tuples where the first element is the UserItem object that failed + to be added to the site and the second element is the ServerResponseError + that was raised when attempting to add the user. + + Warnings + -------- + This method is deprecated. Use the `bulk_add` method instead. + """ + warnings.warn("This method is deprecated, use bulk_add instead", DeprecationWarning) created = [] failed = [] if not filepath.find("csv"): @@ -569,3 +736,105 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe """ return super().filter(*invalid, page_size=page_size, **kwargs) + + +def create_users_csv(users: Iterable[UserItem]) -> bytes: + """ + Create a CSV byte string from an Iterable of UserItem objects. The CSV will + have the following columns, and no header row: + + - Username + - Password + - Display Name + - License + - Admin Level + - Publish capability + - Email + + Parameters + ---------- + users: Iterable[UserItem] + An iterable of UserItem objects to create the CSV from. + + Returns + ------- + bytes + A byte string containing the CSV data. + """ + with io.StringIO() as output: + writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL) + for user in users: + site_role = user.site_role or "Unlicensed" + if site_role == "ServerAdministrator": + license = "Creator" + admin_level = "System" + elif site_role.startswith("SiteAdministrator"): + admin_level = "Site" + license = site_role.replace("SiteAdministrator", "") + else: + license = site_role + admin_level = "" + + if any(x in site_role for x in ("Creator", "Admin", "Publish")): + publish = 1 + else: + publish = 0 + + writer.writerow( + ( + f"{user.domain_name}\\{user.name}" if user.domain_name else user.name, + getattr(user, "password", ""), + user.fullname, + license, + admin_level, + publish, + user.email, + ) + ) + output.seek(0) + result = output.read().encode("utf-8") + return result + + +def remove_users_csv(users: Iterable[UserItem]) -> bytes: + """ + Create a CSV byte string from an Iterable of UserItem objects. This function + only consumes the domain and name attributes of the UserItem objects. The + CSV will have space for the following columns, though only the first column + will be populated, and no header row: + + - Username + - Password + - Display Name + - License + - Admin Level + - Publish capability + - Email + + Parameters + ---------- + users: Iterable[UserItem] + An iterable of UserItem objects to create the CSV from. + + Returns + ------- + bytes + A byte string containing the CSV data. + """ + with io.StringIO() as output: + writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL) + for user in users: + writer.writerow( + ( + f"{user.domain_name}\\{user.name}" if user.domain_name else user.name, + None, + None, + None, + None, + None, + None, + ) + ) + output.seek(0) + result = output.read().encode("utf-8") + return result diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index e05ccb8c8..cfd202377 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -985,6 +985,32 @@ def add_req(self, user_item: UserItem) -> bytes: user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id return ET.tostring(xml_request) + def import_from_csv_req(self, csv_content: bytes, users: Iterable[UserItem]): + xml_request = ET.Element("tsRequest") + for user in users: + if user.name is None: + raise ValueError("User name must be populated.") + user_element = ET.SubElement(xml_request, "user") + user_element.attrib["name"] = user.name + if user.auth_setting is not None and user.idp_configuration_id is not None: + raise ValueError("User cannot have both authSetting and idpConfigurationId.") + elif user.idp_configuration_id is not None: + user_element.attrib["idpConfigurationId"] = user.idp_configuration_id + else: + user_element.attrib["authSetting"] = user.auth_setting or "ServerDefault" + + parts = { + "tableau_user_import": ("tsc_users_file.csv", csv_content, "file"), + "request_payload": ("", ET.tostring(xml_request), "text/xml"), + } + return _add_multipart(parts) + + def delete_csv_req(self, csv_content: bytes): + parts = { + "tableau_user_delete": ("tsc_users_file.csv", csv_content, "file"), + } + return _add_multipart(parts) + class WorkbookRequest: def _generate_xml( diff --git a/test/assets/users_bulk_add_job.xml b/test/assets/users_bulk_add_job.xml new file mode 100644 index 000000000..7301ac7d3 --- /dev/null +++ b/test/assets/users_bulk_add_job.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/test_user.py b/test/test_user.py index f2e778bc3..8f489187f 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,3 +1,8 @@ +import csv +import io +from pathlib import Path +import re +from unittest.mock import patch from pathlib import Path from defusedxml import ElementTree as ET @@ -6,9 +11,11 @@ import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime, parse_datetime +from tableauserverclient.server.endpoint.users_endpoint import create_users_csv, remove_users_csv TEST_ASSET_DIR = Path(__file__).parent / "assets" +BULK_ADD_XML = TEST_ASSET_DIR / "users_bulk_add_job.xml" GET_XML = TEST_ASSET_DIR / "user_get.xml" GET_XML_ALL_FIELDS = TEST_ASSET_DIR / "user_get_all_fields.xml" GET_EMPTY_XML = TEST_ASSET_DIR / "user_get_empty.xml" @@ -23,6 +30,29 @@ USERS = TEST_ASSET_DIR / "Data" / "user_details.csv" +def make_user( + name: str, + site_role: str = "", + auth_setting: str = "", + domain: str = "", + fullname: str = "", + email: str = "", + idp_id: str = "", +) -> TSC.UserItem: + user = TSC.UserItem(name, site_role or None) + if auth_setting: + user.auth_setting = auth_setting + if domain: + user._domain_name = domain + if fullname: + user.fullname = fullname + if email: + user.email = email + if idp_id: + user.idp_configuration_id = idp_id + return user + + @pytest.fixture(scope="function") def server(): """Fixture to create a TSC.Server instance for testing.""" @@ -252,7 +282,8 @@ def test_get_usernames_from_file(server: TSC.Server): response_xml = ADD_XML.read_text() with requests_mock.mock() as m: m.post(server.users.baseurl, text=response_xml) - user_list, failures = server.users.create_from_file(str(USERNAMES)) + with pytest.warns(DeprecationWarning): + user_list, failures = server.users.create_from_file(str(USERNAMES)) assert user_list[0].name == "Cassie", user_list assert failures == [], failures @@ -261,7 +292,8 @@ def test_get_users_from_file(server: TSC.Server): response_xml = ADD_XML.read_text() with requests_mock.mock() as m: m.post(server.users.baseurl, text=response_xml) - users, failures = server.users.create_from_file(str(USERS)) + with pytest.warns(DeprecationWarning): + users, failures = server.users.create_from_file(str(USERS)) assert users[0].name == "Cassie", users assert failures == [] @@ -333,3 +365,209 @@ def test_update_user_idp_configuration(server: TSC.Server) -> None: user_elem = tree.find(".//user") assert user_elem is not None assert user_elem.attrib["idpConfigurationId"] == "012345" + + +def test_create_users_csv() -> None: + users = [ + make_user("Alice", "Viewer"), + make_user("Bob", "Explorer"), + make_user("Charlie", "Creator", "SAML"), + make_user("Dave"), + make_user("Eve", "ServerAdministrator", "OpenID", "example.com", "Eve Example", "Eve@example.com"), + make_user("Frank", "SiteAdministratorExplorer", "TableauIDWithMFA", email="Frank@example.com"), + make_user("Grace", "SiteAdministratorCreator", "SAML", "example.com", "Grace Example", "gex@example.com"), + make_user("Hank", "Unlicensed"), + ] + + license_map = { + "Viewer": "Viewer", + "Explorer": "Explorer", + "ExplorerCanPublish": "Explorer", + "Creator": "Creator", + "SiteAdministratorExplorer": "Explorer", + "SiteAdministratorCreator": "Creator", + "ServerAdministrator": "Creator", + "Unlicensed": "Unlicensed", + } + publish_map = { + "Unlicensed": 0, + "Viewer": 0, + "Explorer": 0, + "Creator": 1, + "ExplorerCanPublish": 1, + "SiteAdministratorExplorer": 1, + "SiteAdministratorCreator": 1, + "ServerAdministrator": 1, + } + admin_map = { + "SiteAdministratorExplorer": "Site", + "SiteAdministratorCreator": "Site", + "ServerAdministrator": "System", + } + + csv_columns = ["name", "password", "fullname", "license", "admin", "publish", "email"] + csv_data = create_users_csv(users) + csv_file = io.StringIO(csv_data.decode("utf-8")) + csv_reader = csv.reader(csv_file) + for user, row in zip(users, csv_reader): + site_role = user.site_role or "Unlicensed" + name = f"{user.domain_name}\\{user.name}" if user.domain_name else user.name + csv_user = dict(zip(csv_columns, row)) + assert name == csv_user["name"] + assert (user.fullname or "") == csv_user["fullname"] + assert (user.email or "") == csv_user["email"] + assert license_map[site_role] == csv_user["license"] + assert admin_map.get(site_role, "") == csv_user["admin"] + assert publish_map[site_role] == int(csv_user["publish"]) + + +def test_bulk_add(server: TSC.Server) -> None: + server.version = "3.15" + users = [ + make_user("Alice", "Viewer"), + make_user("Bob", "Explorer"), + make_user("Charlie", "Creator", "SAML"), + make_user("Dave"), + make_user("Eve", "ServerAdministrator", "OpenID", "example.com", "Eve Example", "Eve@example.com"), + make_user("Frank", "SiteAdministratorExplorer", "TableauIDWithMFA", email="Frank@example.com"), + make_user("Grace", "SiteAdministratorCreator", "SAML", "example.com", "Grace Example", "gex@example.com"), + make_user("Hank", "Unlicensed"), + make_user("Ivy", "Unlicensed", idp_id="0123456789"), + ] + with requests_mock.mock() as m: + m.post(f"{server.users.baseurl}/import", text=BULK_ADD_XML.read_text()) + + job = server.users.bulk_add(users) + + assert isinstance(job, TSC.JobItem) + + assert m.last_request.method == "POST" + assert m.last_request.url == f"{server.users.baseurl}/import" + + body = m.last_request.body.replace(b"\r\n", b"\n") + assert body.startswith(b"--") # Check if it's a multipart request + boundary = body.split(b"\n")[0].strip() + + # Body starts and ends with a boundary string. Split the body into + # segments and ignore the empty sections at the start and end. + segments = [seg for s in body.split(boundary) if (seg := s.strip()) not in [b"", b"--"]] + assert len(segments) == 2 # Check if there are two segments + + # Check if the first segment is the csv file and the second segment is the xml + assert b'Content-Disposition: form-data; name="tableau_user_import"' in segments[0] + assert b'Content-Disposition: form-data; name="request_payload"' in segments[1] + assert b"Content-Type: file" in segments[0] + assert b"Content-Type: text/xml" in segments[1] + + xml_string = segments[1].split(b"\n\n")[1].strip() + xml = ET.fromstring(xml_string) + xml_users = xml.findall(".//user", namespaces={}) + assert len(xml_users) == len(users) + + for user, xml_user in zip(users, xml_users): + assert user.name == xml_user.get("name") + if user.idp_configuration_id is None: + assert xml_user.get("authSetting") == (user.auth_setting or "ServerDefault") + else: + assert xml_user.get("idpConfigurationId") == user.idp_configuration_id + assert xml_user.get("authSetting") is None + + csv_data = create_users_csv(users).replace(b"\r\n", b"\n") + assert csv_data.strip() == segments[0].split(b"\n\n")[1].strip() + + +def test_bulk_add_no_name(server: TSC.Server) -> None: + server.version = "3.15" + users = [ + TSC.UserItem(site_role="Viewer"), + ] + with requests_mock.mock() as m: + m.post(f"{server.users.baseurl}/import", text=BULK_ADD_XML.read_text()) + + with pytest.raises(ValueError, match="User name must be populated."): + server.users.bulk_add(users) + + +def test_bulk_remove(server: TSC.Server) -> None: + server.version = "3.15" + users = [ + make_user("Alice"), + make_user("Bob", domain="example.com"), + ] + with requests_mock.mock() as m: + m.post(f"{server.users.baseurl}/delete") + + server.users.bulk_remove(users) + + assert m.last_request.method == "POST" + assert m.last_request.url == f"{server.users.baseurl}/delete" + + body = m.last_request.body.replace(b"\r\n", b"\n") + assert body.startswith(b"--") # Check if it's a multipart request + boundary = body.split(b"\n")[0].strip() + + content = next(seg for seg in body.split(boundary) if seg.strip()) + assert b'Content-Disposition: form-data; name="tableau_user_delete"' in content + assert b"Content-Type: file" in content + + content = content.replace(b"\r\n", b"\n") + csv_data = content.split(b"\n\n")[1].decode("utf-8") + for user, row in zip(users, csv_data.split("\n")): + name, *_ = row.split(",") + assert name == f"{user.domain_name}\\{user.name}" if user.domain_name else user.name + + +def test_add_all(server: TSC.Server) -> None: + server.version = "2.0" + users = [ + make_user("Alice", "Viewer"), + make_user("Bob", "Explorer"), + make_user("Charlie", "Creator", "SAML"), + make_user("Dave"), + ] + + with patch("tableauserverclient.server.endpoint.users_endpoint.Users.add", autospec=True) as mock_add: + with pytest.warns(DeprecationWarning): + server.users.add_all(users) + + assert mock_add.call_count == len(users) + + +def test_add_idp_and_auth_error(server: TSC.Server) -> None: + server.version = "3.24" + users = [make_user("Alice", "Viewer", auth_setting="SAML", idp_id="01234")] + + with pytest.raises(ValueError, match="User cannot have both authSetting and idpConfigurationId."): + server.users.bulk_add(users) + + +def test_remove_users_csv(server: TSC.Server) -> None: + server.version = "3.15" + users = [ + make_user("Alice", "Viewer"), + make_user("Bob", "Explorer"), + make_user("Charlie", "Creator", "SAML"), + make_user("Dave"), + make_user("Eve", "ServerAdministrator", "OpenID", "example.com", "Eve Example", "Eve@example.com"), + make_user("Frank", "SiteAdministratorExplorer", "TableauIDWithMFA", email="Frank@example.com"), + make_user("Grace", "SiteAdministratorCreator", "SAML", "example.com", "Grace Example", "gex@example.com"), + make_user("Hank", "Unlicensed"), + make_user("Ivy", "Unlicensed", idp_id="0123456789"), + ] + + data = remove_users_csv(users) + assert isinstance(data, bytes), "remove_users_csv should return bytes" + csv_data = data.decode("utf-8") + records = re.split(r"\r?\n", csv_data.strip()) + assert len(records) == len(users), "Number of records in csv does not match number of users" + + for user, record in zip(users, records): + name, *rest = record.strip().split(",") + assert len(rest) == 6, "Number of fields in csv does not match expected number" + assert all([f == "" for f in rest]), "All fields except name should be empty" + if user.domain_name is None: + assert name == user.name, f"Name in csv does not match expected name: {user.name}" + else: + assert ( + name == f"{user.domain_name}\\{user.name}" + ), f"Name in csv does not match expected name: {user.domain_name}\\{user.name}" From 4d67996287ac048d2664576ca82a9caee646e49c Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 23 Dec 2025 14:47:14 -0800 Subject: [PATCH 88/98] Add pytest-xdist plugin to speed up tests (#1681) Running locally on my Mac: - pytest: 1min 20sec - pytest -n auto: 15sec https://pypi.org/project/pytest-xdist/ Co-authored-by: Jac --- .github/workflows/run-tests.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 4af48d064..9ac7ebb6a 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -48,7 +48,7 @@ jobs: - name: Test with pytest if: always() run: | - pytest test + pytest test -n auto - name: Test build if: always() diff --git a/pyproject.toml b/pyproject.toml index 872f50ce0..857f3b7ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] test = ["black==24.10", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", - "requests-mock>=1.0,<2.0", "types-requests>=2.32.4.20250913"] + "pytest-xdist", "requests-mock>=1.0,<2.0", "types-requests>=2.32.4.20250913"] [tool.black] line-length = 120 target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] From 19aaa33b6e071b8cce91af6ece0369f785cdb89a Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 26 Dec 2025 14:30:12 -0600 Subject: [PATCH 89/98] chore: pytestify sort (#1725) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_sort.py | 185 +++++++++++++++++++++++----------------------- 1 file changed, 94 insertions(+), 91 deletions(-) diff --git a/test/test_sort.py b/test/test_sort.py index 18c403540..f6ae576f4 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -1,5 +1,3 @@ -import re -import unittest from urllib.parse import parse_qs import pytest @@ -8,97 +6,102 @@ import tableauserverclient as TSC -class SortTests(unittest.TestCase): - def setUp(self): - self.server = TSC.Server("http://test", False) - self.server.version = "3.10" - self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" - self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = self.server.workbooks.baseurl - - def test_empty_filter(self): - with pytest.raises(TypeError): - TSC.Filter("") - - def test_filter_equals(self): - with requests_mock.mock() as m: - m.get(requests_mock.ANY) - url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" - opts = TSC.RequestOptions(pagesize=13, pagenumber=13) - opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "Superstore")) - - resp = self.server.workbooks.get_request(url, request_object=opts) - query = parse_qs(resp.request.query) - assert "pagenumber" in query - assert query["pagenumber"] == ["13"] - assert "pagesize" in query - assert query["pagesize"] == ["13"] - assert "filter" in query - assert query["filter"] == ["name:eq:superstore"] - - def test_filter_equals_list(self): - with pytest.raises(ValueError, match="Filter values can only be a list if the operator is 'in'.") as cm: - TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.Equals, ["foo", "bar"]) - - def test_filter_in(self): - with requests_mock.mock() as m: - m.get(requests_mock.ANY) - url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" - opts = TSC.RequestOptions(pagesize=13, pagenumber=13) - - opts.filter.add( - TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["stocks", "market"]) - ) +@pytest.fixture(scope="function") +def server(): + """Fixture to create a TSC.Server instance for testing.""" + server = TSC.Server("http://test", False) - resp = self.server.workbooks.get_request(url, request_object=opts) - query = parse_qs(resp.request.query) - assert "pagenumber" in query - assert query["pagenumber"] == ["13"] - assert "pagesize" in query - assert query["pagesize"] == ["13"] - assert "filter" in query - assert query["filter"] == ["tags:in:[stocks,market]"] - - def test_sort_asc(self): - with requests_mock.mock() as m: - m.get(requests_mock.ANY) - url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" - opts = TSC.RequestOptions(pagesize=13, pagenumber=13) - opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Asc)) - - resp = self.server.workbooks.get_request(url, request_object=opts) - query = parse_qs(resp.request.query) - assert "pagenumber" in query - assert query["pagenumber"] == ["13"] - assert "pagesize" in query - assert query["pagesize"] == ["13"] - assert "sort" in query - assert query["sort"] == ["name:asc"] - - def test_filter_combo(self): - with requests_mock.mock() as m: - m.get(requests_mock.ANY) - url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/users" - opts = TSC.RequestOptions(pagesize=13, pagenumber=13) - - opts.filter.add( - TSC.Filter( - TSC.RequestOptions.Field.LastLogin, - TSC.RequestOptions.Operator.GreaterThanOrEqual, - "2017-01-15T00:00:00:00Z", - ) - ) + # Fake signin + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + return server + + +def test_empty_filter() -> None: + with pytest.raises(TypeError): + TSC.Filter("") # type: ignore + + +def test_filter_equals(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" + opts = TSC.RequestOptions(pagesize=13, pagenumber=13) + opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "Superstore")) + + resp = server.workbooks.get_request(url, request_object=opts) + query = parse_qs(resp.request.query) + assert "pagenumber" in query + assert query["pagenumber"] == ["13"] + assert "pagesize" in query + assert query["pagesize"] == ["13"] + assert "filter" in query + assert query["filter"] == ["name:eq:superstore"] + + +def test_filter_equals_list() -> None: + with pytest.raises(ValueError, match="Filter values can only be a list if the operator is 'in'.") as cm: + TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.Equals, ["foo", "bar"]) - opts.filter.add( - TSC.Filter(TSC.RequestOptions.Field.SiteRole, TSC.RequestOptions.Operator.Equals, "Publisher") + +def test_filter_in(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" + opts = TSC.RequestOptions(pagesize=13, pagenumber=13) + + opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["stocks", "market"])) + + resp = server.workbooks.get_request(url, request_object=opts) + query = parse_qs(resp.request.query) + assert "pagenumber" in query + assert query["pagenumber"] == ["13"] + assert "pagesize" in query + assert query["pagesize"] == ["13"] + assert "filter" in query + assert query["filter"] == ["tags:in:[stocks,market]"] + + +def test_sort_asc(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks" + opts = TSC.RequestOptions(pagesize=13, pagenumber=13) + opts.sort.add(TSC.Sort(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Direction.Asc)) + + resp = server.workbooks.get_request(url, request_object=opts) + query = parse_qs(resp.request.query) + assert "pagenumber" in query + assert query["pagenumber"] == ["13"] + assert "pagesize" in query + assert query["pagesize"] == ["13"] + assert "sort" in query + assert query["sort"] == ["name:asc"] + + +def test_filter_combo(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = "http://test/api/2.3/sites/dad65087-b08b-4603-af4e-2887b8aafc67/users" + opts = TSC.RequestOptions(pagesize=13, pagenumber=13) + + opts.filter.add( + TSC.Filter( + TSC.RequestOptions.Field.LastLogin, + TSC.RequestOptions.Operator.GreaterThanOrEqual, + "2017-01-15T00:00:00:00Z", ) + ) + + opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.SiteRole, TSC.RequestOptions.Operator.Equals, "Publisher")) - resp = self.server.workbooks.get_request(url, request_object=opts) + resp = server.workbooks.get_request(url, request_object=opts) - query = parse_qs(resp.request.query) - assert "pagenumber" in query - assert query["pagenumber"] == ["13"] - assert "pagesize" in query - assert query["pagesize"] == ["13"] - assert "filter" in query - assert query["filter"] == ["lastlogin:gte:2017-01-15t00:00:00:00z,siterole:eq:publisher"] + query = parse_qs(resp.request.query) + assert "pagenumber" in query + assert query["pagenumber"] == ["13"] + assert "pagesize" in query + assert query["pagesize"] == ["13"] + assert "filter" in query + assert query["filter"] == ["lastlogin:gte:2017-01-15t00:00:00:00z,siterole:eq:publisher"] From e6b0ba17c83abf7cc623c42a76420df2104f90c2 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 26 Dec 2025 16:26:13 -0600 Subject: [PATCH 90/98] chore: pytestify task_request (#1726) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/request_factory/test_task_requests.py | 98 ++++++++++++---------- 1 file changed, 56 insertions(+), 42 deletions(-) diff --git a/test/request_factory/test_task_requests.py b/test/request_factory/test_task_requests.py index 6287fa6ea..bf2ccd5fe 100644 --- a/test/request_factory/test_task_requests.py +++ b/test/request_factory/test_task_requests.py @@ -1,47 +1,61 @@ -import unittest import xml.etree.ElementTree as ET from unittest.mock import Mock + +import pytest + from tableauserverclient.server.request_factory import TaskRequest -class TestTaskRequest(unittest.TestCase): - def setUp(self): - self.task_request = TaskRequest() - self.xml_request = ET.Element("tsRequest") - - def test_refresh_req_default(self): - result = self.task_request.refresh_req() - self.assertEqual(result, ET.tostring(self.xml_request)) - - def test_refresh_req_incremental(self): - with self.assertRaises(ValueError): - self.task_request.refresh_req(incremental=True) - - def test_refresh_req_with_parent_srv_version_3_25(self): - parent_srv = Mock() - parent_srv.check_at_least_version.return_value = True - result = self.task_request.refresh_req(incremental=True, parent_srv=parent_srv) - expected_xml = ET.Element("tsRequest") - task_element = ET.SubElement(expected_xml, "extractRefresh") - task_element.attrib["incremental"] = "true" - self.assertEqual(result, ET.tostring(expected_xml)) - - def test_refresh_req_with_parent_srv_version_3_25_non_incremental(self): - parent_srv = Mock() - parent_srv.check_at_least_version.return_value = True - result = self.task_request.refresh_req(incremental=False, parent_srv=parent_srv) - expected_xml = ET.Element("tsRequest") - ET.SubElement(expected_xml, "extractRefresh") - self.assertEqual(result, ET.tostring(expected_xml)) - - def test_refresh_req_with_parent_srv_version_below_3_25(self): - parent_srv = Mock() - parent_srv.check_at_least_version.return_value = False - with self.assertRaises(ValueError): - self.task_request.refresh_req(incremental=True, parent_srv=parent_srv) - - def test_refresh_req_with_parent_srv_version_below_3_25_non_incremental(self): - parent_srv = Mock() - parent_srv.check_at_least_version.return_value = False - result = self.task_request.refresh_req(incremental=False, parent_srv=parent_srv) - self.assertEqual(result, ET.tostring(self.xml_request)) +@pytest.fixture +def task_request() -> TaskRequest: + return TaskRequest() + + +@pytest.fixture +def xml_request() -> ET.Element: + return ET.Element("tsRequest") + + +def test_refresh_req_default(task_request: TaskRequest, xml_request: ET.Element) -> None: + result = task_request.refresh_req() + assert result == ET.tostring(xml_request) + + +def test_refresh_req_incremental(task_request: TaskRequest) -> None: + with pytest.raises(ValueError): + task_request.refresh_req(incremental=True) + + +def test_refresh_req_with_parent_srv_version_3_25(task_request: TaskRequest) -> None: + parent_srv = Mock() + parent_srv.check_at_least_version.return_value = True + result = task_request.refresh_req(incremental=True, parent_srv=parent_srv) + expected_xml = ET.Element("tsRequest") + task_element = ET.SubElement(expected_xml, "extractRefresh") + task_element.attrib["incremental"] = "true" + assert result == ET.tostring(expected_xml) + + +def test_refresh_req_with_parent_srv_version_3_25_non_incremental(task_request: TaskRequest) -> None: + parent_srv = Mock() + parent_srv.check_at_least_version.return_value = True + result = task_request.refresh_req(incremental=False, parent_srv=parent_srv) + expected_xml = ET.Element("tsRequest") + ET.SubElement(expected_xml, "extractRefresh") + assert result == ET.tostring(expected_xml) + + +def test_refresh_req_with_parent_srv_version_below_3_25(task_request: TaskRequest) -> None: + parent_srv = Mock() + parent_srv.check_at_least_version.return_value = False + with pytest.raises(ValueError): + task_request.refresh_req(incremental=True, parent_srv=parent_srv) + + +def test_refresh_req_with_parent_srv_version_below_3_25_non_incremental( + task_request: TaskRequest, xml_request: ET.Element +) -> None: + parent_srv = Mock() + parent_srv.check_at_least_version.return_value = False + result = task_request.refresh_req(incremental=False, parent_srv=parent_srv) + assert result == ET.tostring(xml_request) From bfc692ca25c3304055701939a2abddd9812f7b49 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 26 Dec 2025 16:27:22 -0600 Subject: [PATCH 91/98] chore: pytestify exponential_backoff (#1727) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_exponential_backoff.py | 102 ++++++++++++++++--------------- 1 file changed, 52 insertions(+), 50 deletions(-) diff --git a/test/test_exponential_backoff.py b/test/test_exponential_backoff.py index a07eb5d3a..b5c37002f 100644 --- a/test/test_exponential_backoff.py +++ b/test/test_exponential_backoff.py @@ -1,60 +1,62 @@ -import unittest +import pytest from tableauserverclient.exponential_backoff import ExponentialBackoffTimer from ._utils import mocked_time -class ExponentialBackoffTests(unittest.TestCase): - def test_exponential(self): - with mocked_time() as mock_time: - exponentialBackoff = ExponentialBackoffTimer() - # The creation of our mock shouldn't sleep - self.assertAlmostEqual(mock_time(), 0) - # The first sleep sleeps for a rather short time, the following sleeps become longer - exponentialBackoff.sleep() - self.assertAlmostEqual(mock_time(), 0.5) +def test_exponential() -> None: + with mocked_time() as mock_time: + exponentialBackoff = ExponentialBackoffTimer() + # The creation of our mock shouldn't sleep + pytest.approx(mock_time(), 0) + # The first sleep sleeps for a rather short time, the following sleeps become longer + exponentialBackoff.sleep() + pytest.approx(mock_time(), 0.5) + exponentialBackoff.sleep() + pytest.approx(mock_time(), 1.2) + exponentialBackoff.sleep() + pytest.approx(mock_time(), 2.18) + exponentialBackoff.sleep() + pytest.approx(mock_time(), 3.552) + exponentialBackoff.sleep() + pytest.approx(mock_time(), 5.4728) + + +def test_exponential_saturation() -> None: + with mocked_time() as mock_time: + exponentialBackoff = ExponentialBackoffTimer() + for _ in range(99): exponentialBackoff.sleep() - self.assertAlmostEqual(mock_time(), 1.2) + # We don't increase the sleep time above 30 seconds. + # Otherwise, the exponential sleep time could easily + # reach minutes or even hours between polls + for _ in range(5): + s = mock_time() exponentialBackoff.sleep() - self.assertAlmostEqual(mock_time(), 2.18) + slept = mock_time() - s + pytest.approx(slept, 30) + + +def test_timeout() -> None: + with mocked_time() as mock_time: + exponentialBackoff = ExponentialBackoffTimer(timeout=4.5) + for _ in range(4): exponentialBackoff.sleep() - self.assertAlmostEqual(mock_time(), 3.552) + pytest.approx(mock_time(), 3.552) + # Usually, the following sleep would sleep until 5.5, but due to + # the timeout we wait less; thereby we make sure to take the timeout + # into account as good as possible + exponentialBackoff.sleep() + pytest.approx(mock_time(), 4.5) + # The next call to `sleep` will raise a TimeoutError + with pytest.raises(TimeoutError): exponentialBackoff.sleep() - self.assertAlmostEqual(mock_time(), 5.4728) - - def test_exponential_saturation(self): - with mocked_time() as mock_time: - exponentialBackoff = ExponentialBackoffTimer() - for _ in range(99): - exponentialBackoff.sleep() - # We don't increase the sleep time above 30 seconds. - # Otherwise, the exponential sleep time could easily - # reach minutes or even hours between polls - for _ in range(5): - s = mock_time() - exponentialBackoff.sleep() - slept = mock_time() - s - self.assertAlmostEqual(slept, 30) - - def test_timeout(self): - with mocked_time() as mock_time: - exponentialBackoff = ExponentialBackoffTimer(timeout=4.5) - for _ in range(4): - exponentialBackoff.sleep() - self.assertAlmostEqual(mock_time(), 3.552) - # Usually, the following sleep would sleep until 5.5, but due to - # the timeout we wait less; thereby we make sure to take the timeout - # into account as good as possible + + +def test_timeout_zero() -> None: + with mocked_time() as mock_time: + # The construction of the timer doesn't throw, yet + exponentialBackoff = ExponentialBackoffTimer(timeout=0) + # But the first `sleep` immediately throws + with pytest.raises(TimeoutError): exponentialBackoff.sleep() - self.assertAlmostEqual(mock_time(), 4.5) - # The next call to `sleep` will raise a TimeoutError - with self.assertRaises(TimeoutError): - exponentialBackoff.sleep() - - def test_timeout_zero(self): - with mocked_time() as mock_time: - # The construction of the timer doesn't throw, yet - exponentialBackoff = ExponentialBackoffTimer(timeout=0) - # But the first `sleep` immediately throws - with self.assertRaises(TimeoutError): - exponentialBackoff.sleep() From 24615189038b191922fbb92701b5566d5e3ddbc1 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 27 Dec 2025 01:02:06 -0600 Subject: [PATCH 92/98] chore: remove unused unittest imports (#1728) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/http/test_http_requests.py | 1 - test/test_metadata.py | 1 - test/test_server_info.py | 1 - 3 files changed, 3 deletions(-) diff --git a/test/http/test_http_requests.py b/test/http/test_http_requests.py index 62d6d7d3c..a595e7d36 100644 --- a/test/http/test_http_requests.py +++ b/test/http/test_http_requests.py @@ -1,6 +1,5 @@ import pytest import tableauserverclient as TSC -import unittest import requests import requests_mock diff --git a/test/test_metadata.py b/test/test_metadata.py index cf3e6ad4a..8b8b25151 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -1,6 +1,5 @@ import json from pathlib import Path -import unittest import pytest import requests_mock diff --git a/test/test_server_info.py b/test/test_server_info.py index af911508f..bc1a1bcb3 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -1,5 +1,4 @@ from pathlib import Path -import unittest import pytest import requests_mock From 5146c09195aa836e59c569acb0b4ad5cb083d671 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 6 Jan 2026 09:53:33 -0600 Subject: [PATCH 93/98] fix: add workbook and view setter for custom view (#1730) * fix: add workbook and view setter for custom view * chore: use workbook setter in tests Closes #1729 --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- .../models/custom_view_item.py | 8 +++++++ test/test_custom_view.py | 22 +++++++++---------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index 5cafe469c..92acb0e9b 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -158,10 +158,18 @@ def owner(self, value: UserItem): def workbook(self) -> Optional[WorkbookItem]: return self._workbook + @workbook.setter + def workbook(self, value: WorkbookItem) -> None: + self._workbook = value + @property def view(self) -> Optional[ViewItem]: return self._view + @view.setter + def view(self, value: ViewItem) -> None: + self._view = value + @classmethod def from_response(cls, resp, ns, workbook_id="") -> Optional["CustomViewItem"]: item = cls.list_from_response(resp, ns, workbook_id) diff --git a/test/test_custom_view.py b/test/test_custom_view.py index 98dd9b6a4..b2117358a 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -174,8 +174,8 @@ 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" + 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) @@ -190,8 +190,8 @@ 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" + 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)) @@ -206,8 +206,8 @@ 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" + 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()) @@ -222,8 +222,8 @@ def test_publish_file_io(server: TSC.Server) -> 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" + 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): @@ -234,7 +234,7 @@ 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() + 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): @@ -245,8 +245,8 @@ 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" + 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" From f66bd09b28b4dfab1e7775a4051782f808bf392f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 8 Jan 2026 14:22:53 -0600 Subject: [PATCH 94/98] feat: support extensions api (#1672) Adding support for the REST APIs which provide access to Tableau Extensions configuration. --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Co-authored-by: Brian Cantoni --- tableauserverclient/__init__.py | 6 + tableauserverclient/models/__init__.py | 4 + tableauserverclient/models/extensions_item.py | 186 +++++++++++++++++ .../models/property_decorators.py | 4 +- .../server/endpoint/__init__.py | 2 + .../server/endpoint/extensions_endpoint.py | 79 +++++++ tableauserverclient/server/request_factory.py | 59 ++++++ tableauserverclient/server/server.py | 2 + .../extensions_server_settings_false.xml | 6 + .../extensions_server_settings_true.xml | 8 + test/assets/extensions_site_settings.xml | 16 ++ test/test_extensions.py | 195 ++++++++++++++++++ 12 files changed, 565 insertions(+), 2 deletions(-) create mode 100644 tableauserverclient/models/extensions_item.py create mode 100644 tableauserverclient/server/endpoint/extensions_endpoint.py create mode 100644 test/assets/extensions_server_settings_false.xml create mode 100644 test/assets/extensions_server_settings_true.xml create mode 100644 test/assets/extensions_site_settings.xml create mode 100644 test/test_extensions.py diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index b041fcdae..cd0ec3e03 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -13,6 +13,8 @@ DatabaseItem, DataFreshnessPolicyItem, DatasourceItem, + ExtensionsServer, + ExtensionsSiteSettings, FavoriteItem, FlowItem, FlowRunItem, @@ -36,6 +38,7 @@ ProjectItem, Resource, RevisionItem, + SafeExtension, ScheduleItem, SiteAuthConfiguration, SiteOIDCConfiguration, @@ -88,6 +91,8 @@ "DEFAULT_NAMESPACE", "DQWItem", "ExcelRequestOptions", + "ExtensionsServer", + "ExtensionsSiteSettings", "FailedSignInError", "FavoriteItem", "FileuploadItem", @@ -121,6 +126,7 @@ "RequestOptions", "Resource", "RevisionItem", + "SafeExtension", "ScheduleItem", "Server", "ServerInfoItem", diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 67f6553fd..aa28e0dbf 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -10,6 +10,7 @@ from tableauserverclient.models.datasource_item import DatasourceItem from tableauserverclient.models.dqw_item import DQWItem from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.extensions_item import ExtensionsServer, ExtensionsSiteSettings, SafeExtension from tableauserverclient.models.favorites_item import FavoriteItem from tableauserverclient.models.fileupload_item import FileuploadItem from tableauserverclient.models.flow_item import FlowItem @@ -113,4 +114,7 @@ "LinkedTaskStepItem", "LinkedTaskFlowRunItem", "ExtractItem", + "ExtensionsServer", + "ExtensionsSiteSettings", + "SafeExtension", ] diff --git a/tableauserverclient/models/extensions_item.py b/tableauserverclient/models/extensions_item.py new file mode 100644 index 000000000..87466cdea --- /dev/null +++ b/tableauserverclient/models/extensions_item.py @@ -0,0 +1,186 @@ +from typing import overload +from typing_extensions import Self + +from defusedxml.ElementTree import fromstring + +from tableauserverclient.models.property_decorators import property_is_boolean + + +class ExtensionsServer: + def __init__(self) -> None: + self._enabled: bool | None = None + self._block_list: list[str] | None = None + + @property + def enabled(self) -> bool | None: + """Indicates whether the extensions server is enabled.""" + return self._enabled + + @enabled.setter + @property_is_boolean + def enabled(self, value: bool | None) -> None: + self._enabled = value + + @property + def block_list(self) -> list[str] | None: + """List of blocked extensions.""" + return self._block_list + + @block_list.setter + def block_list(self, value: list[str] | None) -> None: + self._block_list = value + + @classmethod + def from_response(cls: type[Self], response, ns) -> Self: + xml = fromstring(response) + obj = cls() + element = xml.find(".//t:extensionsServerSettings", namespaces=ns) + if element is None: + raise ValueError("Missing extensionsServerSettings element in response") + + if (enabled_element := element.find("./t:extensionsGloballyEnabled", namespaces=ns)) is not None: + obj.enabled = string_to_bool(enabled_element.text) + obj.block_list = [e.text for e in element.findall("./t:blockList", namespaces=ns) if e.text is not None] + + return obj + + +class SafeExtension: + def __init__( + self, url: str | None = None, full_data_allowed: bool | None = None, prompt_needed: bool | None = None + ) -> None: + self.url = url + self._full_data_allowed = full_data_allowed + self._prompt_needed = prompt_needed + + @property + def full_data_allowed(self) -> bool | None: + return self._full_data_allowed + + @full_data_allowed.setter + @property_is_boolean + def full_data_allowed(self, value: bool | None) -> None: + self._full_data_allowed = value + + @property + def prompt_needed(self) -> bool | None: + return self._prompt_needed + + @prompt_needed.setter + @property_is_boolean + def prompt_needed(self, value: bool | None) -> None: + self._prompt_needed = value + + +class ExtensionsSiteSettings: + def __init__(self) -> None: + self._enabled: bool | None = None + self._use_default_setting: bool | None = None + self.safe_list: list[SafeExtension] | None = None + self._allow_trusted: bool | None = None + self._include_tableau_built: bool | None = None + self._include_partner_built: bool | None = None + self._include_sandboxed: bool | None = None + + @property + def enabled(self) -> bool | None: + return self._enabled + + @enabled.setter + @property_is_boolean + def enabled(self, value: bool | None) -> None: + self._enabled = value + + @property + def use_default_setting(self) -> bool | None: + return self._use_default_setting + + @use_default_setting.setter + @property_is_boolean + def use_default_setting(self, value: bool | None) -> None: + self._use_default_setting = value + + @property + def allow_trusted(self) -> bool | None: + return self._allow_trusted + + @allow_trusted.setter + @property_is_boolean + def allow_trusted(self, value: bool | None) -> None: + self._allow_trusted = value + + @property + def include_tableau_built(self) -> bool | None: + return self._include_tableau_built + + @include_tableau_built.setter + @property_is_boolean + def include_tableau_built(self, value: bool | None) -> None: + self._include_tableau_built = value + + @property + def include_partner_built(self) -> bool | None: + return self._include_partner_built + + @include_partner_built.setter + @property_is_boolean + def include_partner_built(self, value: bool | None) -> None: + self._include_partner_built = value + + @property + def include_sandboxed(self) -> bool | None: + return self._include_sandboxed + + @include_sandboxed.setter + @property_is_boolean + def include_sandboxed(self, value: bool | None) -> None: + self._include_sandboxed = value + + @classmethod + def from_response(cls: type[Self], response, ns) -> Self: + xml = fromstring(response) + element = xml.find(".//t:extensionsSiteSettings", namespaces=ns) + obj = cls() + if element is None: + raise ValueError("Missing extensionsSiteSettings element in response") + + if (enabled_element := element.find("./t:extensionsEnabled", namespaces=ns)) is not None: + obj.enabled = string_to_bool(enabled_element.text) + if (default_settings_element := element.find("./t:useDefaultSetting", namespaces=ns)) is not None: + obj.use_default_setting = string_to_bool(default_settings_element.text) + if (allow_trusted_element := element.find("./t:allowTrusted", namespaces=ns)) is not None: + obj.allow_trusted = string_to_bool(allow_trusted_element.text) + if (include_tableau_built_element := element.find("./t:includeTableauBuilt", namespaces=ns)) is not None: + obj.include_tableau_built = string_to_bool(include_tableau_built_element.text) + if (include_partner_built_element := element.find("./t:includePartnerBuilt", namespaces=ns)) is not None: + obj.include_partner_built = string_to_bool(include_partner_built_element.text) + if (include_sandboxed_element := element.find("./t:includeSandboxed", namespaces=ns)) is not None: + obj.include_sandboxed = string_to_bool(include_sandboxed_element.text) + + safe_list = [] + for safe_extension_element in element.findall("./t:safeList", namespaces=ns): + url = safe_extension_element.find("./t:url", namespaces=ns) + full_data_allowed = safe_extension_element.find("./t:fullDataAllowed", namespaces=ns) + prompt_needed = safe_extension_element.find("./t:promptNeeded", namespaces=ns) + + safe_extension = SafeExtension( + url=url.text if url is not None else None, + full_data_allowed=string_to_bool(full_data_allowed.text) if full_data_allowed is not None else None, + prompt_needed=string_to_bool(prompt_needed.text) if prompt_needed is not None else None, + ) + safe_list.append(safe_extension) + + obj.safe_list = safe_list + return obj + + +@overload +def string_to_bool(s: str) -> bool: ... + + +@overload +def string_to_bool(s: None) -> None: ... + + +def string_to_bool(s): + return s.lower() == "true" if s is not None else None diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 5048b3498..050346594 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,7 +1,7 @@ import datetime import re from functools import wraps -from typing import Any, Optional +from typing import Any from collections.abc import Container from tableauserverclient.datetime_helpers import parse_datetime @@ -67,7 +67,7 @@ def wrapper(self, value): return wrapper -def property_is_int(range: tuple[int, int], allowed: Optional[Container[Any]] = None): +def property_is_int(range: tuple[int, int], allowed: Container[Any] | None = None): """Takes a range of ints and a list of exemptions to check against when setting a property on a model. The range is a tuple of (min, max) and the allowed list (empty by default) allows values outside that range. diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 3c1266f90..d944bc429 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -6,6 +6,7 @@ from tableauserverclient.server.endpoint.datasources_endpoint import Datasources from tableauserverclient.server.endpoint.endpoint import Endpoint, QuerysetEndpoint from tableauserverclient.server.endpoint.exceptions import ServerResponseError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.extensions_endpoint import Extensions from tableauserverclient.server.endpoint.favorites_endpoint import Favorites from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads from tableauserverclient.server.endpoint.flow_runs_endpoint import FlowRuns @@ -42,6 +43,7 @@ "QuerysetEndpoint", "MissingRequiredFieldError", "Endpoint", + "Extensions", "Favorites", "Fileuploads", "FlowRuns", diff --git a/tableauserverclient/server/endpoint/extensions_endpoint.py b/tableauserverclient/server/endpoint/extensions_endpoint.py new file mode 100644 index 000000000..d14855931 --- /dev/null +++ b/tableauserverclient/server/endpoint/extensions_endpoint.py @@ -0,0 +1,79 @@ +from tableauserverclient.models.extensions_item import ExtensionsServer, ExtensionsSiteSettings +from tableauserverclient.server.endpoint.endpoint import Endpoint +from tableauserverclient.server.endpoint.endpoint import api +from tableauserverclient.server.request_factory import RequestFactory + + +class Extensions(Endpoint): + def __init__(self, parent_srv): + super().__init__(parent_srv) + + @property + def _server_baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/settings/extensions" + + @property + def baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/settings/extensions" + + @api(version="3.21") + def get_server_settings(self) -> ExtensionsServer: + """Lists the settings for extensions of a server + + Returns + ------- + ExtensionsServer + The server extensions settings + """ + response = self.get_request(self._server_baseurl) + return ExtensionsServer.from_response(response.content, self.parent_srv.namespace) + + @api(version="3.21") + def update_server_settings(self, extensions_server: ExtensionsServer) -> ExtensionsServer: + """Updates the settings for extensions of a server. Overwrites all existing settings. Any + sites omitted from the block list will be unblocked. + + Parameters + ---------- + extensions_server : ExtensionsServer + The server extensions settings to update + + Returns + ------- + ExtensionsServer + The updated server extensions settings + """ + req = RequestFactory.Extensions.update_server_extensions(extensions_server) + response = self.put_request(self._server_baseurl, req) + return ExtensionsServer.from_response(response.content, self.parent_srv.namespace) + + @api(version="3.21") + def get(self) -> ExtensionsSiteSettings: + """Lists the extensions settings for the site + + Returns + ------- + ExtensionsSiteSettings + The site extensions settings + """ + response = self.get_request(self.baseurl) + return ExtensionsSiteSettings.from_response(response.content, self.parent_srv.namespace) + + @api(version="3.21") + def update(self, extensions_site_settings: ExtensionsSiteSettings) -> ExtensionsSiteSettings: + """Updates the extensions settings for the site. Any extensions omitted + from the safe extensions list will be removed. + + Parameters + ---------- + extensions_site_settings : ExtensionsSiteSettings + The site extensions settings to update + + Returns + ------- + ExtensionsSiteSettings + The updated site extensions settings + """ + req = RequestFactory.Extensions.update_site_extensions(extensions_site_settings) + response = self.put_request(self.baseurl, req) + return ExtensionsSiteSettings.from_response(response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index cfd202377..57deb6e26 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1665,6 +1665,64 @@ def update_req(self, xml_request: ET.Element, oidc_item: SiteOIDCConfiguration) return ET.tostring(xml_request) +class ExtensionsRequest: + @_tsrequest_wrapped + def update_server_extensions(self, xml_request: ET.Element, extensions_server: "ExtensionsServer") -> None: + extensions_element = ET.SubElement(xml_request, "extensionsServerSettings") + if not isinstance(extensions_server.enabled, bool): + raise ValueError(f"Extensions Server missing enabled: {extensions_server}") + enabled_element = ET.SubElement(extensions_element, "extensionsGloballyEnabled") + enabled_element.text = str(extensions_server.enabled).lower() + + if extensions_server.block_list is None: + return + for blocked in extensions_server.block_list: + blocked_element = ET.SubElement(extensions_element, "blockList") + blocked_element.text = blocked + return + + @_tsrequest_wrapped + def update_site_extensions(self, xml_request: ET.Element, extensions_site_settings: ExtensionsSiteSettings) -> None: + ext_element = ET.SubElement(xml_request, "extensionsSiteSettings") + if not isinstance(extensions_site_settings.enabled, bool): + raise ValueError(f"Extensions Site Settings missing enabled: {extensions_site_settings}") + enabled_element = ET.SubElement(ext_element, "extensionsEnabled") + enabled_element.text = str(extensions_site_settings.enabled).lower() + if not isinstance(extensions_site_settings.use_default_setting, bool): + raise ValueError( + f"Extensions Site Settings missing use_default_setting: {extensions_site_settings.use_default_setting}" + ) + default_element = ET.SubElement(ext_element, "useDefaultSetting") + default_element.text = str(extensions_site_settings.use_default_setting).lower() + if extensions_site_settings.allow_trusted is not None: + allow_trusted_element = ET.SubElement(ext_element, "allowTrusted") + allow_trusted_element.text = str(extensions_site_settings.allow_trusted).lower() + if extensions_site_settings.include_sandboxed is not None: + include_sandboxed_element = ET.SubElement(ext_element, "includeSandboxed") + include_sandboxed_element.text = str(extensions_site_settings.include_sandboxed).lower() + if extensions_site_settings.include_tableau_built is not None: + include_tableau_built_element = ET.SubElement(ext_element, "includeTableauBuilt") + include_tableau_built_element.text = str(extensions_site_settings.include_tableau_built).lower() + if extensions_site_settings.include_partner_built is not None: + include_partner_built_element = ET.SubElement(ext_element, "includePartnerBuilt") + include_partner_built_element.text = str(extensions_site_settings.include_partner_built).lower() + + if extensions_site_settings.safe_list is None: + return + + safe_element = ET.SubElement(ext_element, "safeList") + for safe in extensions_site_settings.safe_list: + if safe.url is not None: + url_element = ET.SubElement(safe_element, "url") + url_element.text = safe.url + if safe.full_data_allowed is not None: + full_data_element = ET.SubElement(safe_element, "fullDataAllowed") + full_data_element.text = str(safe.full_data_allowed).lower() + if safe.prompt_needed is not None: + prompt_element = ET.SubElement(safe_element, "promptNeeded") + prompt_element.text = str(safe.prompt_needed).lower() + + class RequestFactory: Auth = AuthRequest() Connection = Connection() @@ -1675,6 +1733,7 @@ class RequestFactory: Database = DatabaseRequest() DQW = DQWRequest() Empty = EmptyRequest() + Extensions = ExtensionsRequest() Favorite = FavoriteRequest() Fileupload = FileuploadRequest() Flow = FlowRequest() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 9202e3e63..b497e9086 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -39,6 +39,7 @@ Tags, VirtualConnections, OIDC, + Extensions, ) from tableauserverclient.server.exceptions import ( ServerInfoEndpointNotFoundError, @@ -185,6 +186,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.tags = Tags(self) self.virtual_connections = VirtualConnections(self) self.oidc = OIDC(self) + self.extensions = Extensions(self) self._session = self._session_factory() self._http_options = dict() # must set this before making a server call diff --git a/test/assets/extensions_server_settings_false.xml b/test/assets/extensions_server_settings_false.xml new file mode 100644 index 000000000..16fd3e85d --- /dev/null +++ b/test/assets/extensions_server_settings_false.xml @@ -0,0 +1,6 @@ + + + + false + + diff --git a/test/assets/extensions_server_settings_true.xml b/test/assets/extensions_server_settings_true.xml new file mode 100644 index 000000000..c562d4719 --- /dev/null +++ b/test/assets/extensions_server_settings_true.xml @@ -0,0 +1,8 @@ + + + + true + https://test.com + https://example.com + + diff --git a/test/assets/extensions_site_settings.xml b/test/assets/extensions_site_settings.xml new file mode 100644 index 000000000..e5f963ca9 --- /dev/null +++ b/test/assets/extensions_site_settings.xml @@ -0,0 +1,16 @@ + + + + true + false + true + false + false + false + + http://localhost:9123/Dynamic.html + true + true + + + diff --git a/test/test_extensions.py b/test/test_extensions.py new file mode 100644 index 000000000..9dc001876 --- /dev/null +++ b/test/test_extensions.py @@ -0,0 +1,195 @@ +from pathlib import Path + +from defusedxml.ElementTree import fromstring +import requests_mock +import pytest + +import tableauserverclient as TSC + + +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +GET_SERVER_EXT_SETTINGS = TEST_ASSET_DIR / "extensions_server_settings_true.xml" +GET_SERVER_EXT_SETTINGS_FALSE = TEST_ASSET_DIR / "extensions_server_settings_false.xml" +GET_SITE_SETTINGS = TEST_ASSET_DIR / "extensions_site_settings.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" + server.version = "3.21" + + return server + + +def test_get_server_extensions_settings(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(server.extensions._server_baseurl, text=GET_SERVER_EXT_SETTINGS.read_text()) + ext_settings = server.extensions.get_server_settings() + + assert ext_settings.enabled is True + assert ext_settings.block_list is not None + assert set(ext_settings.block_list) == {"https://test.com", "https://example.com"} + + +def test_get_server_extensions_settings_false(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(server.extensions._server_baseurl, text=GET_SERVER_EXT_SETTINGS_FALSE.read_text()) + ext_settings = server.extensions.get_server_settings() + + assert ext_settings.enabled is False + assert ext_settings.block_list is not None + assert len(ext_settings.block_list) == 0 + + +def test_update_server_extensions_settings(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.put(server.extensions._server_baseurl, text=GET_SERVER_EXT_SETTINGS_FALSE.read_text()) + + ext_settings = TSC.ExtensionsServer() + ext_settings.enabled = False + ext_settings.block_list = [] + + updated_settings = server.extensions.update_server_settings(ext_settings) + + assert updated_settings.enabled is False + assert updated_settings.block_list is not None + assert len(updated_settings.block_list) == 0 + + +def test_get_site_settings(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(server.extensions.baseurl, text=GET_SITE_SETTINGS.read_text()) + site_settings = server.extensions.get() + + assert isinstance(site_settings, TSC.ExtensionsSiteSettings) + assert site_settings.enabled is True + assert site_settings.use_default_setting is False + assert site_settings.safe_list is not None + assert site_settings.allow_trusted is True + assert site_settings.include_partner_built is False + assert site_settings.include_sandboxed is False + assert site_settings.include_tableau_built is False + assert len(site_settings.safe_list) == 1 + first_safe = site_settings.safe_list[0] + assert first_safe.url == "http://localhost:9123/Dynamic.html" + assert first_safe.full_data_allowed is True + assert first_safe.prompt_needed is True + + +def test_update_site_settings(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.put(server.extensions.baseurl, text=GET_SITE_SETTINGS.read_text()) + + site_settings = TSC.ExtensionsSiteSettings() + site_settings.enabled = True + site_settings.use_default_setting = False + safe_extension = TSC.SafeExtension( + url="http://localhost:9123/Dynamic.html", + full_data_allowed=True, + prompt_needed=True, + ) + site_settings.safe_list = [safe_extension] + + updated_settings = server.extensions.update(site_settings) + history = m.request_history + + assert isinstance(updated_settings, TSC.ExtensionsSiteSettings) + assert updated_settings.enabled is True + assert updated_settings.use_default_setting is False + assert updated_settings.safe_list is not None + assert len(updated_settings.safe_list) == 1 + first_safe = updated_settings.safe_list[0] + assert first_safe.url == "http://localhost:9123/Dynamic.html" + assert first_safe.full_data_allowed is True + assert first_safe.prompt_needed is True + + # Verify that the request body was as expected + assert len(history) == 1 + xml_payload = fromstring(history[0].body) + extensions_site_settings_elem = xml_payload.find(".//extensionsSiteSettings") + assert extensions_site_settings_elem is not None + enabled_elem = extensions_site_settings_elem.find("extensionsEnabled") + assert enabled_elem is not None + assert enabled_elem.text == "true" + use_default_elem = extensions_site_settings_elem.find("useDefaultSetting") + assert use_default_elem is not None + assert use_default_elem.text == "false" + safe_list_elements = list(extensions_site_settings_elem.findall("safeList")) + assert len(safe_list_elements) == 1 + safe_extension_elem = safe_list_elements[0] + url_elem = safe_extension_elem.find("url") + assert url_elem is not None + assert url_elem.text == "http://localhost:9123/Dynamic.html" + full_data_allowed_elem = safe_extension_elem.find("fullDataAllowed") + assert full_data_allowed_elem is not None + assert full_data_allowed_elem.text == "true" + prompt_needed_elem = safe_extension_elem.find("promptNeeded") + assert prompt_needed_elem is not None + assert prompt_needed_elem.text == "true" + + +def test_update_safe_list_none(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.put(server.extensions.baseurl, text=GET_SITE_SETTINGS.read_text()) + + site_settings = TSC.ExtensionsSiteSettings() + site_settings.enabled = True + site_settings.use_default_setting = False + + updated_settings = server.extensions.update(site_settings) + history = m.request_history + + assert isinstance(updated_settings, TSC.ExtensionsSiteSettings) + assert updated_settings.enabled is True + assert updated_settings.use_default_setting is False + assert updated_settings.safe_list is not None + assert len(updated_settings.safe_list) == 1 + first_safe = updated_settings.safe_list[0] + assert first_safe.url == "http://localhost:9123/Dynamic.html" + assert first_safe.full_data_allowed is True + assert first_safe.prompt_needed is True + + # Verify that the request body was as expected + assert len(history) == 1 + xml_payload = fromstring(history[0].body) + extensions_site_settings_elem = xml_payload.find(".//extensionsSiteSettings") + assert extensions_site_settings_elem is not None + safe_list_element = extensions_site_settings_elem.find("safeList") + assert safe_list_element is None + + +def test_update_safe_list_empty(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.put(server.extensions.baseurl, text=GET_SITE_SETTINGS.read_text()) + + site_settings = TSC.ExtensionsSiteSettings() + site_settings.enabled = True + site_settings.use_default_setting = False + site_settings.safe_list = [] + + updated_settings = server.extensions.update(site_settings) + history = m.request_history + + assert isinstance(updated_settings, TSC.ExtensionsSiteSettings) + assert updated_settings.enabled is True + assert updated_settings.use_default_setting is False + assert updated_settings.safe_list is not None + assert len(updated_settings.safe_list) == 1 + first_safe = updated_settings.safe_list[0] + assert first_safe.url == "http://localhost:9123/Dynamic.html" + assert first_safe.full_data_allowed is True + assert first_safe.prompt_needed is True + + # Verify that the request body was as expected + assert len(history) == 1 + xml_payload = fromstring(history[0].body) + extensions_site_settings_elem = xml_payload.find(".//extensionsSiteSettings") + assert extensions_site_settings_elem is not None + safe_list_element = extensions_site_settings_elem.find("safeList") + assert safe_list_element is not None + assert len(safe_list_element) == 0 From 24cf1adfdbd4b0e17e789899b09f00b60ca5bef9 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 13 Jan 2026 17:01:29 -0800 Subject: [PATCH 95/98] Add support for receiving "Customized Monthly" schedule intervals (#1670) * Add test for retrieving Customized Monthly schedule * Add "Customized Monthly" as a possible schedule interval type that might be returned --- tableauserverclient/models/interval_item.py | 1 + .../schedule_get_customized_monthly_id.xml | 10 ++++++++++ test/test_schedule.py | 16 ++++++++++++++++ 3 files changed, 27 insertions(+) create mode 100644 test/assets/schedule_get_customized_monthly_id.xml diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 14cec1878..0888441f9 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -305,6 +305,7 @@ def interval(self, interval_values): "Fourth", "Fifth", "Last", + "Customized Monthly", ] for value in range(1, 32): VALID_INTERVALS.append(str(value)) diff --git a/test/assets/schedule_get_customized_monthly_id.xml b/test/assets/schedule_get_customized_monthly_id.xml new file mode 100644 index 000000000..cc2bf5606 --- /dev/null +++ b/test/assets/schedule_get_customized_monthly_id.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/test/test_schedule.py b/test/test_schedule.py index 45e35ec25..823a87607 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -15,6 +15,7 @@ GET_DAILY_ID_XML = TEST_ASSET_DIR / "schedule_get_daily_id.xml" GET_MONTHLY_ID_XML = TEST_ASSET_DIR / "schedule_get_monthly_id.xml" GET_MONTHLY_ID_2_XML = TEST_ASSET_DIR / "schedule_get_monthly_id_2.xml" +GET_CUSTOMIZED_MONTHLY_ID_XML = TEST_ASSET_DIR / "schedule_get_customized_monthly_id.xml" GET_EMPTY_XML = TEST_ASSET_DIR / "schedule_get_empty.xml" CREATE_HOURLY_XML = TEST_ASSET_DIR / "schedule_create_hourly.xml" CREATE_DAILY_XML = TEST_ASSET_DIR / "schedule_create_daily.xml" @@ -178,6 +179,21 @@ def test_get_monthly_by_id_2(server: TSC.Server) -> None: assert ("Monday", "First") == schedule.interval_item.interval +def test_get_customized_monthly_by_id(server: TSC.Server) -> None: + server.version = "3.15" + response_xml = GET_CUSTOMIZED_MONTHLY_ID_XML.read_text() + with requests_mock.mock() as m: + schedule_id = "f048d794-90dc-40b0-bfad-2ca78e437369" + baseurl = f"{server.baseurl}/schedules/{schedule_id}" + m.get(baseurl, text=response_xml) + schedule = server.schedules.get_by_id(schedule_id) + assert schedule is not None + assert schedule_id == schedule.id + assert "Monthly customized" == schedule.name + assert "Active" == schedule.state + assert ("Customized Monthly",) == schedule.interval_item.interval + + def test_delete(server: TSC.Server) -> None: with requests_mock.mock() as m: m.delete(server.schedules.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", status_code=204) From 6f525ffd6519f314d483f8f57a96dfdde0874e97 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Wed, 14 Jan 2026 03:56:49 -0600 Subject: [PATCH 96/98] fix: handle parameters for view filters (#1633) * fix: black ci errors * chore: pytestify request_option * fix: encoding error * fix: handle parameters for view filters Closes #1632 Parameters need to be prefixed with "vf_Parameters." in order to be properly registered as setting a parameter value. This PR adds that prefix where it was missing, but leaves parameter names that already included the prefix unmodified. * docs: case sensitivity in the test's query string * chore: pytest style asserts * fix: black ci errors --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- tableauserverclient/server/request_options.py | 9 +++++++- test/test_request_option.py | 22 +++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 45a4f6df0..70c85d140 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -387,7 +387,14 @@ def parameter(self, name: str, value: str) -> Self: Self The current object """ - self.view_parameters.append((name, value)) + prefix = "vf_Parameters." + if name.startswith(prefix): + proper_name = name + elif name.startswith("Parameters."): + proper_name = f"vf_{name}" + else: + proper_name = f"{prefix}{name}" + self.view_parameters.append((proper_name, value)) return self def _append_view_filters(self, params) -> None: diff --git a/test/test_request_option.py b/test/test_request_option.py index 2d7402d23..2c5354b2a 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -339,14 +339,25 @@ def test_filtering_parameters(server: TSC.Server) -> None: opts = TSC.PDFRequestOptions() opts.parameter("name1@", "value1") opts.parameter("name2$", "value2") + opts.parameter("Parameters.name3", "value3") + opts.parameter("vf_Parameters.name4", "value4") opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid + # While Tableau Server side IS case sensitive with the query string, + # requiring the prefix to be "vf_Parameters", requests does not end + # up preserving the case sensitivity with the Response.Request + # object. It also shows up lowercased in the requests_mock request + # history. resp = server.workbooks.get_request(url, request_object=opts) query_params = parse_qs(resp.request.query) - assert "name1@" in query_params - assert "value1" in query_params["name1@"] - assert "name2$" in query_params - assert "value2" in query_params["name2$"] + assert "vf_parameters.name1@" in query_params + assert "value1" in query_params["vf_parameters.name1@"] + assert "vf_parameters.name2$" in query_params + assert "value2" in query_params["vf_parameters.name2$"] + assert "vf_parameters.name3" in query_params + assert "value3" in query_params["vf_parameters.name3"] + assert "vf_parameters.name4" in query_params + assert "value4" in query_params["vf_parameters.name4"] assert "type" in query_params assert "tabloid" in query_params["type"] @@ -369,6 +380,9 @@ def test_queryset_endpoint_pagesize_filter(server: TSC.Server, page_size: int) - _ = list(queryset) +44 + + @pytest.mark.parametrize("page_size", [1, 10, 100, 1_000]) def test_queryset_pagesize_filter(server: TSC.Server, page_size: int) -> None: with requests_mock.mock() as m: From d9f644d64cd5ad40ce3564776d1d009aa55a1b66 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 21 Jan 2026 14:04:08 -0800 Subject: [PATCH 97/98] Jac/release automation (#1613) * update publish workflow * Update pyproject.toml and setup * add init files to find test_repr, fix it to pass --- .gitattributes | 1 + .github/workflows/publish-pypi.yml | 11 ++++-- MANIFEST.in | 3 -- publish.sh | 11 ------ pyproject.toml | 13 +++++-- setup.py | 13 ++----- tableauserverclient/__init__.py | 4 -- tableauserverclient/bin/__init__.py | 3 ++ tableauserverclient/bin/_version.py | 5 +-- test/http/__init__.py | 0 test/models/__init__.py | 0 test/models/_models.py | 59 ++++++++++------------------- test/models/test_repr.py | 44 +++++++++++---------- test/test_custom_view.py | 5 +++ 14 files changed, 73 insertions(+), 99 deletions(-) delete mode 100755 publish.sh create mode 100644 tableauserverclient/bin/__init__.py create mode 100644 test/http/__init__.py create mode 100644 test/models/__init__.py diff --git a/.gitattributes b/.gitattributes index ade44ab7c..040321c04 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ tableauserverclient/_version.py export-subst +tableauserverclient/bin/_version.py export-subst diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 3bbecd3c2..d6d36f7ba 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,9 +1,12 @@ name: Publish to PyPi -# This will publish a package to TestPyPi (and real Pypi if run on master) with a version -# number generated by versioneer from the most recent tag looking like v____ -# TODO: maybe move this into the package job so all release-based actions are together +# This will build a package with a version set by versioneer from the most recent tag matching v____ +# It will publish to TestPyPi, and to real Pypi *if* run on master where head has a release tag +# For a live run, this should only need to be triggered by a newly published repo release. +# This can also be run manually for testing on: + release: + types: [published] workflow_dispatch: push: tags: @@ -23,7 +26,7 @@ jobs: - name: Build dist files run: | python -m pip install --upgrade pip - pip install -e .[test] build + python -m pip install -e .[test] build python -m build git describe --tag --dirty --always diff --git a/MANIFEST.in b/MANIFEST.in index 9b7512fb9..7acbed103 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,8 +4,6 @@ include CONTRIBUTORS.md include LICENSE include LICENSE.versioneer include README.md -include tableauserverclient/_version.py -include versioneer.py recursive-include docs *.md recursive-include samples *.py recursive-include samples *.txt @@ -18,5 +16,4 @@ recursive-include test *.png recursive-include test *.py recursive-include test *.xml recursive-include test *.tde -global-include *.pyi global-include *.typed diff --git a/publish.sh b/publish.sh deleted file mode 100755 index 46d54a1ee..000000000 --- a/publish.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -# tag the release version and confirm a clean version number -git tag vxxxx -git describe --tag --dirty --always - -set -e - -rm -rf dist -python setup.py sdist bdist_wheel -twine upload dist/* diff --git a/pyproject.toml b/pyproject.toml index 857f3b7ab..22760e803 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=75.0", "versioneer[toml]==0.29", "wheel"] +requires = ["setuptools>=77.0", "versioneer[toml]==0.29", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -8,7 +8,7 @@ name="tableauserverclient" dynamic = ["version"] description='A Python module for working with the Tableau Server REST API.' authors = [{name="Tableau", email="github@tableau.com"}] -license = {file = "LICENSE"} +license-files = ["LICENSE"] readme = "README.md" dependencies = [ @@ -34,6 +34,13 @@ repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] test = ["black==24.10", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", "pytest-xdist", "requests-mock>=1.0,<2.0", "types-requests>=2.32.4.20250913"] + +[tool.setuptools.packages.find] +where = ["tableauserverclient", "tableauserverclient.helpers", "tableauserverclient.models", "tableauserverclient.server", "tableauserverclient.server.endpoint"] + +[tool.setuptools.dynamic] +version = {attr = "versioneer.get_version"} + [tool.black] line-length = 120 target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] @@ -60,5 +67,5 @@ addopts = "--junitxml=./test.junit.xml" VCS = "git" style = "pep440-pre" versionfile_source = "tableauserverclient/bin/_version.py" -versionfile_build = "tableauserverclient/bin/_version.py" +versionfile_build = "_version.py" tag_prefix = "v" diff --git a/setup.py b/setup.py index bdce51f2e..b52ba267e 100644 --- a/setup.py +++ b/setup.py @@ -2,14 +2,7 @@ from setuptools import setup setup( - version=versioneer.get_version(), - cmdclass=versioneer.get_cmdclass(), - # not yet sure how to move this to pyproject.toml - packages=[ - "tableauserverclient", - "tableauserverclient.helpers", - "tableauserverclient.models", - "tableauserverclient.server", - "tableauserverclient.server.endpoint", - ], + # This line is required to set the version number when building the wheel + # not yet sure how to move this to pyproject.toml - it may require work in versioneer + cmdclass=versioneer.get_cmdclass() ) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index cd0ec3e03..7241f23ca 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -148,7 +148,3 @@ "WeeklyInterval", "WorkbookItem", ] - -from .bin import _version - -__version__ = _version.get_versions()["version"] diff --git a/tableauserverclient/bin/__init__.py b/tableauserverclient/bin/__init__.py new file mode 100644 index 000000000..e4605a43b --- /dev/null +++ b/tableauserverclient/bin/__init__.py @@ -0,0 +1,3 @@ +# generated during initial setup of versioneer +from . import _version +__version__ = _version.get_versions()['version'] diff --git a/tableauserverclient/bin/_version.py b/tableauserverclient/bin/_version.py index f23819e86..680304c7d 100644 --- a/tableauserverclient/bin/_version.py +++ b/tableauserverclient/bin/_version.py @@ -46,14 +46,13 @@ class VersioneerConfig: def get_config() -> VersioneerConfig: """Create, populate and return the VersioneerConfig() object.""" - # these strings are filled in when 'setup.py versioneer' creates - # _version.py + # these strings are filled in from pyproject.toml at file generation time cfg = VersioneerConfig() cfg.VCS = "git" cfg.style = "pep440-pre" cfg.tag_prefix = "v" cfg.parentdir_prefix = "None" - cfg.versionfile_source = "tableauserverclient/_version.py" + cfg.versionfile_source = "tableauserverclient/bin/_version.py" cfg.verbose = False return cfg diff --git a/test/http/__init__.py b/test/http/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/models/__init__.py b/test/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/models/_models.py b/test/models/_models.py index 59011c6c3..9be97a87b 100644 --- a/test/models/_models.py +++ b/test/models/_models.py @@ -10,48 +10,27 @@ ) -def get_defined_models(): - # nothing clever here: list was manually copied from tsc/models/__init__.py - return [ - BackgroundJobItem, - ConnectionItem, - DataAccelerationReportItem, - DataAlertItem, - DatasourceItem, - FlowItem, - GroupItem, - JobItem, - MetricItem, - PermissionsRule, - ProjectItem, - RevisionItem, - ScheduleItem, - SubscriptionItem, - Credentials, - JWTAuth, - TableauAuth, - PersonalAccessTokenAuth, - ServerInfoItem, - SiteItem, - TaskItem, - UserItem, - ViewItem, - WebhookItem, - WorkbookItem, - PaginationItem, - Permission.Mode, - Permission.Capability, - DailyInterval, - WeeklyInterval, - MonthlyInterval, - HourlyInterval, - TableItem, - Target, - ] - - def get_unimplemented_models(): return [ + # these items should have repr , please fix + CollectionItem, + DQWItem, + ExtensionsServer, + ExtensionsSiteSettings, + FileuploadItem, + FlowRunItem, + LinkedTaskFlowRunItem, + LinkedTaskItem, + LinkedTaskStepItem, + SafeExtension, + # these should be implemented together for consistency + CSVRequestOptions, + ExcelRequestOptions, + ImageRequestOptions, + PDFRequestOptions, + PPTXRequestOptions, + RequestOptions, + # these don't need it FavoriteItem, # no repr because there is no state Resource, # list of type names TableauItem, # should be an interface diff --git a/test/models/test_repr.py b/test/models/test_repr.py index 0f6057f4f..34f8509a7 100644 --- a/test/models/test_repr.py +++ b/test/models/test_repr.py @@ -1,15 +1,30 @@ import inspect from typing import Any - -import _models # type: ignore # did not set types for this +from test.models._models import get_unimplemented_models import tableauserverclient as TSC import pytest -# ensure that all models that don't need parameters can be instantiated -# todo.... -def instantiate_class(name: str, obj: Any): +def is_concrete(obj: Any): + return inspect.isclass(obj) and not inspect.isabstract(obj) + + +@pytest.mark.parametrize("class_name, obj", inspect.getmembers(TSC, is_concrete)) +def test_by_reflection(class_name, obj): + instance = try_instantiate_class(class_name, obj) + if instance: + class_type = type(instance) + if class_type in get_unimplemented_models(): + print(f"Class '{class_name}' has no repr defined, skipping test") + return + else: + assert type(instance.__repr__).__name__ == "method" + print(instance.__repr__.__name__) + + +# Instantiate a class if it doesn't require any parameters +def try_instantiate_class(name: str, obj: Any) -> Any | None: # Get the constructor (init) of the class constructor = getattr(obj, "__init__", None) if constructor: @@ -22,25 +37,12 @@ def instantiate_class(name: str, obj: Any): print(f"Class '{name}' requires the following parameters for instantiation:") for param in required_parameters: print(f"- {param.name}") + return None else: print(f"Class '{name}' does not require any parameters for instantiation.") # Instantiate the class instance = obj() - print(f"Instantiated: {name} -> {instance}") + return instance else: print(f"Class '{name}' does not have a constructor (__init__ method).") - - -def is_concrete(obj: Any): - return inspect.isclass(obj) and not inspect.isabstract(obj) - - -@pytest.mark.parametrize("class_name, obj", inspect.getmembers(TSC, is_concrete)) -def test_by_reflection(class_name, obj): - instantiate_class(class_name, obj) - - -@pytest.mark.parametrize("model", _models.get_defined_models()) -def test_repr_is_implemented(model): - print(model.__name__, type(model.__repr__).__name__) - assert type(model.__repr__).__name__ == "function" + return None diff --git a/test/test_custom_view.py b/test/test_custom_view.py index b2117358a..2a3932726 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -175,6 +175,7 @@ def test_publish_filepath(server: TSC.Server) -> None: cv._owner = TSC.UserItem() cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" cv.workbook = TSC.WorkbookItem() + assert cv.workbook is not None 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()) @@ -191,6 +192,7 @@ def test_publish_file_str(server: TSC.Server) -> None: cv._owner = TSC.UserItem() cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" cv.workbook = TSC.WorkbookItem() + assert cv.workbook is not None 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()) @@ -207,6 +209,7 @@ def test_publish_file_io(server: TSC.Server) -> None: cv._owner = TSC.UserItem() cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" cv.workbook = TSC.WorkbookItem() + assert cv.workbook is not None cv.workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" data = io.BytesIO(CUSTOM_VIEW_DOWNLOAD.read_bytes()) with requests_mock.mock() as m: @@ -223,6 +226,7 @@ def test_publish_missing_owner_id(server: TSC.Server) -> None: cv = TSC.CustomViewItem(name="test") cv._owner = TSC.UserItem() cv.workbook = TSC.WorkbookItem() + assert cv.workbook is not None 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()) @@ -246,6 +250,7 @@ def test_large_publish(server: TSC.Server): cv._owner = TSC.UserItem() cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" cv.workbook = TSC.WorkbookItem() + assert cv.workbook is not None cv.workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" with ExitStack() as stack: temp_dir = stack.enter_context(TemporaryDirectory()) From 12f50c48a295466a4c8ff620682962518748e05b Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 22 Jan 2026 14:43:39 -0800 Subject: [PATCH 98/98] implement #816: project.get_by_id (#1736) * implement project.get_by_id * format --- tableauserverclient/models/project_item.py | 3 ++- .../server/endpoint/projects_endpoint.py | 12 ++++++++++++ test/test_project.py | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 0e4e5af56..dd90f8bfb 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -86,9 +86,10 @@ def __init__( content_permissions: Optional[str] = None, parent_id: Optional[str] = None, samples: Optional[bool] = None, + id: Optional[str] = None, ) -> None: self._content_permissions = None - self._id: Optional[str] = None + self._id: Optional[str] = id self.description: Optional[str] = description self.name: str = name self.content_permissions: Optional[str] = content_permissions diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 68eb573cc..c7a834802 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -89,6 +89,18 @@ def delete(self, project_id: str) -> None: self.delete_request(url) logger.info(f"Deleted single project (ID: {project_id})") + @api(version="2.0") + def get_by_id(self, project_id: str) -> ProjectItem: + """ + Fetch a project by ID. This is a convenience method making up for a gap in the server API. + It uses the same endpoint as the update method, but without the ability to update the project. + """ + if not project_id: + error = "Project ID undefined." + raise ValueError(error) + project = ProjectItem(id=project_id) + return self.update(project, samples=False) + @api(version="2.0") def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: """ diff --git a/test/test_project.py b/test/test_project.py index f2cfab5d1..eb33f6732 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -80,6 +80,25 @@ def test_delete_missing_id(server: TSC.Server) -> None: server.projects.delete("") +def test_get_by_id(server: TSC.Server) -> None: + response_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.put(server.projects.baseurl + "/1d0304cd-3796-429f-b815-7258370b9b74", text=response_xml) + project = server.projects.get_by_id("1d0304cd-3796-429f-b815-7258370b9b74") + assert "1d0304cd-3796-429f-b815-7258370b9b74" == project.id + assert "Test Project" == project.name + assert "Project created for testing" == project.description + assert "LockedToProject" == project.content_permissions + assert "9a8f2265-70f3-4494-96c5-e5949d7a1120" == project.parent_id + assert "dd2239f6-ddf1-4107-981a-4cf94e415794" == project.owner_id + assert "LockedToProject" == project.content_permissions + + +def test_get_by_id_missing_id(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.projects.get_by_id("") + + def test_update(server: TSC.Server) -> None: response_xml = UPDATE_XML.read_text() with requests_mock.mock() as m: