From 8abda4bafdd5526aefa19bc2113afed150658309 Mon Sep 17 00:00:00 2001 From: Vitor Honna Date: Thu, 15 May 2025 16:07:41 -0300 Subject: [PATCH 001/219] 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 002/219] 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 003/219] 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 004/219] 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 005/219] 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 006/219] 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 007/219] 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 008/219] 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 009/219] 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 010/219] 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 011/219] 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 012/219] 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 013/219] 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 014/219] 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 015/219] 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 016/219] 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 017/219] 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 018/219] 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 019/219] 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 020/219] 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 021/219] 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 022/219] 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 023/219] 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 024/219] 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 025/219] 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 026/219] 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 027/219] 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 028/219] 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 029/219] 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 030/219] 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 031/219] 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 032/219] 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 033/219] 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 034/219] 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 035/219] 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 036/219] 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 037/219] 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 038/219] 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 039/219] 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 040/219] 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 041/219] 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 042/219] 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 043/219] 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 044/219] 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 045/219] 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 046/219] 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 047/219] 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 048/219] 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 049/219] 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 050/219] 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 051/219] 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 052/219] 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 053/219] 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 054/219] 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 055/219] 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 056/219] 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 057/219] 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 058/219] 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 059/219] 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 060/219] 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 061/219] 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 062/219] 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 063/219] 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 064/219] 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 065/219] 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 066/219] 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 067/219] 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 068/219] 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 069/219] 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 070/219] 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 071/219] 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 072/219] 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 073/219] 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 074/219] 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 075/219] 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 076/219] 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 077/219] 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 078/219] 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 079/219] 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 080/219] 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 081/219] 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 082/219] 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 083/219] 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 084/219] 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 085/219] 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 086/219] 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 087/219] 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 088/219] 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 089/219] 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 090/219] 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 091/219] 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 092/219] 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 093/219] 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 094/219] 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 095/219] 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 096/219] 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 097/219] 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 098/219] 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: From 043efa14ecf72228801ee8342a74bfe51d163b4a Mon Sep 17 00:00:00 2001 From: Jac Date: Sat, 31 Jan 2026 21:20:37 -0800 Subject: [PATCH 099/219] refer to single package entrypoint (#1739) enumerating subpackages doesn't (shouldn't) actually do anything, so we shouldn't do it. --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 22760e803..1f71a6bac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,8 +36,7 @@ test = ["black==24.10", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pyte "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"] - +where = ["tableauserverclient"] [tool.setuptools.dynamic] version = {attr = "versioneer.get_version"} From 6498a4f19f083f4079b4e960c1db67ffada2ed10 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 3 Feb 2026 12:12:12 -0600 Subject: [PATCH 100/219] jorwoods/package install fix (#1747) * Revert "refer to single package entrypoint (#1739)" This reverts commit 043efa14ecf72228801ee8342a74bfe51d163b4a. * fix: make pyproject.toml more explicity in what is included * chore: remove commented out config --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- pyproject.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1f71a6bac..a4a66685f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,8 +35,14 @@ repository = "https://github.com/tableau/server-client-python" 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.package-data] +# Only include data for tableauserverclient, not for samples, test, docs +tableauserverclient = ["*"] + [tool.setuptools.packages.find] -where = ["tableauserverclient"] +where = ["."] +include = ["tableauserverclient*"] + [tool.setuptools.dynamic] version = {attr = "versioneer.get_version"} From fd3cc99232a7989e9f69ad75704ef730f890c42c Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Mon, 14 Jul 2025 21:07:55 -0700 Subject: [PATCH 101/219] Updated TSC with new API's --- samples/update_connection_auth.py | 14 +-- samples/update_connections_auth.py | 21 ++-- tableauserverclient/models/connection_item.py | 4 + .../server/endpoint/datasources_endpoint.py | 55 +++++---- .../server/endpoint/workbooks_endpoint.py | 108 ++++++++---------- test/assets/datasource_connections_update.xml | 6 +- test/assets/workbook_update_connections.xml | 6 +- test/test_datasource.py | 93 ++++++++++----- test/test_workbook.py | 85 +++++++++----- 9 files changed, 231 insertions(+), 161 deletions(-) diff --git a/samples/update_connection_auth.py b/samples/update_connection_auth.py index 19134e60c..c5ccd54d6 100644 --- a/samples/update_connection_auth.py +++ b/samples/update_connection_auth.py @@ -4,9 +4,7 @@ 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) @@ -14,8 +12,7 @@ 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)", @@ -39,7 +36,10 @@ 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) @@ -51,7 +51,7 @@ def main(): connection = connections[0] connection.username = args.datasource_username connection.password = args.datasource_password - connection.auth_type = args.authentication_type + connection.authentication_type = args.authentication_type connection.embed_password = True updated_connection = update_function(resource, connection) diff --git a/samples/update_connections_auth.py b/samples/update_connections_auth.py index f0c8dd852..563ca898e 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("--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("--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", @@ -25,9 +25,7 @@ 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() @@ -35,11 +33,14 @@ def main(): 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) + 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) + endpoint = { + "workbook": server.workbooks, + "datasource": server.datasources + }.get(args.resource_type) resource = endpoint.get_by_id(args.resource_id) endpoint.populate_connections(resource) @@ -48,16 +49,16 @@ def main(): embed_password = args.embed_password.lower() == "true" # Call unified update_connections method - connection_items = endpoint.update_connections( + 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, + embed_password=embed_password ) - print(f"Updated connections on {args.resource_type} {args.resource_id}: {connection_items}") + print(f"Updated connections on {args.resource_type} {args.resource_id}: {updated_ids}") if __name__ == "__main__": diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index e155a3e3a..9044d131d 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -84,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]): diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 6a734f7b3..b898a2bc1 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -376,14 +376,8 @@ def update_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]: + 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. @@ -392,7 +386,7 @@ def update_connections( datasource_item : DatasourceItem The datasource item containing the connections. - connection_luids : Iterable of str + connection_luids : list of str The connection LUIDs to update. authentication_type : str @@ -409,25 +403,42 @@ def update_connections( Returns ------- - Iterable of str + 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) - 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] + 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) - logger.info(f"Updated connections for datasource {datasource_item.id}: {', '.join(updated_ids)}") - return connection_items + 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: Union[DatasourceItem, str], incremental: bool = False) -> JobItem: diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 5f9695829..865064430 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -330,79 +330,69 @@ 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: 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. + 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. + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item containing the connections. - connection_luids : Iterable of str - The connection LUIDs to update. + connection_luids : list of str + The connection LUIDs to update. - authentication_type : str - The authentication type to use (e.g., 'AD Service Principal'). + 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). + username : str, optional + The username to set (e.g., client ID for keypair auth). - password : str, optional - The password or secret to set. + password : str, optional + The password or secret to set. - embed_password : bool, optional - Whether to embed the password. + embed_password : bool, optional + Whether to embed the password. - Returns - ------- - Iterable of str - The connection LUIDs that were updated. - """ + 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" + 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, - ) + ts_request = Element("tsRequest") - # 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] + # + conn_luids_elem = SubElement(ts_request, "connectionLuids") + for luid in connection_luids: + SubElement(conn_luids_elem, "connectionLuid").text = luid - logger.info(f"Updated connections for workbook {workbook_item.id}: {', '.join(updated_ids)}") - return connection_items + # + connection_elem = SubElement(ts_request, "connection") + connection_elem.set("authenticationType", authentication_type) - T = TypeVar("T", bound=FileObjectW) + if username: + connection_elem.set("userName", username) - @overload - def download( - self, - workbook_id: str, - filepath: T, - include_extract: bool = True, - ) -> T: ... + if password: + connection_elem.set("password", password) - @overload - def download( - self, - workbook_id: str, - filepath: Optional[FilePath] = None, - include_extract: bool = True, - ) -> str: ... + 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") diff --git a/test/assets/datasource_connections_update.xml b/test/assets/datasource_connections_update.xml index d726aad25..5cc8ac001 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 http://tableau.com/api/ts-api-3.26.xsd"> + authentication="auth-keypair" /> + authentication="auth-keypair" /> diff --git a/test/assets/workbook_update_connections.xml b/test/assets/workbook_update_connections.xml index ce6ca227f..1e9b3342e 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 http://tableau.com/api/ts-api-3.26.xsd"> + authentication="AD Service Principal" /> + authentication="AD Service Principal" /> diff --git a/test/test_datasource.py b/test/test_datasource.py index 56eb11ab7..13154deb2 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -17,24 +17,21 @@ from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads from tableauserverclient.server.request_factory import RequestFactory -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" -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" -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" +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" @pytest.fixture(scope="function") @@ -160,19 +157,53 @@ def test_update_copy_fields(server) -> None: 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) + 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") + 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" assert single_datasource.tags == updated_datasource.tags assert single_datasource._initial_tags == updated_datasource._initial_tags diff --git a/test/test_workbook.py b/test/test_workbook.py index e6e807f89..046049e56 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -13,31 +13,32 @@ 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 = 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" +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") @pytest.fixture(scope="function") @@ -707,10 +708,42 @@ def test_publish_with_thumbnails_user_id(server: TSC.Server) -> None: assert re.search(b'thumbnailsUserId=\\"ee8c6e70-43b6-11e6-af4f-f7b0d8e20761\\"', request_body) -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) + 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 new_workbook = TSC.WorkbookItem( name="Sample", From 72885d1af48ab2f9ba3eaad34853d96a774fc143 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 102/219] chore: refactor XML payload into RequestFactory Also correct the type hints to clarify that it accepts any Iterable. --- tableauserverclient/models/connection_item.py | 4 - .../server/endpoint/datasources_endpoint.py | 48 ++++------ .../server/endpoint/workbooks_endpoint.py | 94 +++++++++---------- test/test_datasource.py | 25 +++-- test/test_workbook.py | 24 +++-- 5 files changed, 82 insertions(+), 113 deletions(-) diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 9044d131d..e155a3e3a 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]): diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index b898a2bc1..b9aa16776 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -376,8 +376,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. @@ -386,7 +392,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 @@ -403,41 +409,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 865064430..121b1fbc5 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -330,69 +330,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/test/test_datasource.py b/test/test_datasource.py index 13154deb2..a1bff6812 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -158,18 +158,12 @@ def test_update_copy_fields(server) -> None: 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" @@ -177,10 +171,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") @@ -191,12 +189,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 046049e56..b00c0267d 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -709,26 +709,24 @@ def test_publish_with_thumbnails_user_id(server: TSC.Server) -> None: 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, @@ -736,7 +734,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 16b49b44b6679ef0615053e2e2fd4832ec3a9264 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 103/219] 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 f5015df2a34f639632deeccf53685a06ad956cf0 Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Mon, 21 Jul 2025 11:01:35 -0700 Subject: [PATCH 104/219] 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 58c3a2515b2fa46599faef015e81bc966eda8cca Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Mon, 21 Jul 2025 11:06:25 -0700 Subject: [PATCH 105/219] 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 b9aa16776..b93386c0f 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -414,7 +414,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 46f363fd3841615cee2fde8697b5ed9cd6339f05 Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Mon, 21 Jul 2025 13:47:55 -0700 Subject: [PATCH 106/219] 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 b93386c0f..4538bebfe 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -422,10 +422,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: Union[DatasourceItem, str], incremental: bool = False) -> JobItem: diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 121b1fbc5..0fc78fa73 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -379,10 +379,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 a1bff6812..bca896fd3 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -183,7 +183,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", @@ -191,8 +191,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 b00c0267d..b52ece916 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -728,7 +728,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", @@ -736,8 +736,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 2db07a979af64a264e19fd9594fb36ede515ec18 Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Mon, 21 Jul 2025 14:06:06 -0700 Subject: [PATCH 107/219] 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 4538bebfe..6a734f7b3 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -383,7 +383,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. @@ -423,8 +423,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 0fc78fa73..546b2114d 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -338,7 +338,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. @@ -380,8 +380,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 b6880a32cf282a4943dec71f05ce216e1aa1ee6f Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Tue, 22 Jul 2025 16:53:31 -0700 Subject: [PATCH 108/219] Minor fixes to request payloads --- samples/update_connection_auth.py | 2 +- 1 file changed, 1 insertion(+), 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) From bc75250b665d49ef69f57eea5d3581cd771c573f Mon Sep 17 00:00:00 2001 From: Vineeth Sai Surya Chavatapalli Date: Mon, 21 Jul 2025 16:00:38 -0700 Subject: [PATCH 109/219] 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 | 2 +- test/test_datasource.py | 38 +++++++++++++++++++++++++++++++ test/test_workbook.py | 32 ++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/samples/update_connection_auth.py b/samples/update_connection_auth.py index 19134e60c..661a5e275 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.auth_type = args.authentication_type + connection.authentication_type = args.authentication_type connection.embed_password = True updated_connection = update_function(resource, connection) diff --git a/test/test_datasource.py b/test/test_datasource.py index bca896fd3..2d0a3cf7c 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -157,6 +157,44 @@ def test_update_copy_fields(server) -> None: assert single_datasource._project_name == updated_datasource._project_name + 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_update_connections(self) -> None: populate_xml, response_xml = read_xml_assets(POPULATE_CONNECTIONS_XML, UPDATE_CONNECTIONS_XML) diff --git a/test/test_workbook.py b/test/test_workbook.py index b52ece916..7974c8a68 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -708,6 +708,38 @@ def test_publish_with_thumbnails_user_id(server: TSC.Server) -> None: assert re.search(b'thumbnailsUserId=\\"ee8c6e70-43b6-11e6-af4f-f7b0d8e20761\\"', request_body) + 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_update_workbook_connections(self) -> None: populate_xml, response_xml = read_xml_assets(POPULATE_CONNECTIONS_XML, UPDATE_CONNECTIONS_XML) From b40f4b83f282bfcdf9169a7e3081dc45d22c6f00 Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Wed, 23 Jul 2025 10:33:17 -0700 Subject: [PATCH 110/219] 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 2d0a3cf7c..6946fad13 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -232,6 +232,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 7974c8a68..b039ff7d2 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -771,6 +771,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 6e7709633465dd42cd64bfbb1d5fca844d652d2a Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Wed, 23 Jul 2025 10:38:10 -0700 Subject: [PATCH 111/219] 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 6946fad13..4ebaeb2ca 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -232,7 +232,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 691597ed33986bf6efe58fd654bfdf91a698c009 Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Thu, 24 Jul 2025 11:11:39 -0700 Subject: [PATCH 112/219] 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 6da100f8dd6aa7d3eebc4d5b26acc66adaa08b6a Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 1 Aug 2025 01:50:20 -0500 Subject: [PATCH 113/219] 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> --- test/test_site.py | 49 ++++++++++++++--------------------------------- 1 file changed, 14 insertions(+), 35 deletions(-) diff --git a/test/test_site.py b/test/test_site.py index e976bc1d2..0bdb39f00 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -1,5 +1,6 @@ from itertools import product -from pathlib import Path +import os.path +import unittest from defusedxml import ElementTree as ET import pytest @@ -10,6 +11,8 @@ from . import _utils +from . import _utils + TEST_ASSET_DIR = Path(__file__).parent / "assets" GET_XML = TEST_ASSET_DIR / "site_get.xml" @@ -270,40 +273,16 @@ def test_encrypt(server: TSC.Server) -> None: 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 + 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]) From c0af92df9dc5c2398146ac6b560104522cf65370 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 1 Aug 2025 01:51:12 -0500 Subject: [PATCH 114/219] 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> --- test/test_request_option.py | 38 +++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/test/test_request_option.py b/test/test_request_option.py index 2c5354b2a..82a3e50fd 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -431,13 +431,31 @@ def test_queryset_field_order(server: TSC.Server) -> None: 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_" + 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 c19f5818d9a8c0877882ba5f86ae350180166864 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 1 Aug 2025 01:55:51 -0500 Subject: [PATCH 115/219] 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/server/request_factory.py | 58 ---- tableauserverclient/server/server.py | 2 - test/test_oidc.py | 288 +++++++++--------- 3 files changed, 141 insertions(+), 207 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 57deb6e26..f021b6dfc 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1665,64 +1665,6 @@ 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() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index b497e9086..9202e3e63 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -39,7 +39,6 @@ Tags, VirtualConnections, OIDC, - Extensions, ) from tableauserverclient.server.exceptions import ( ServerInfoEndpointNotFoundError, @@ -186,7 +185,6 @@ 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/test_oidc.py b/test/test_oidc.py index 476d902a1..4c3187355 100644 --- a/test/test_oidc.py +++ b/test/test_oidc.py @@ -1,8 +1,7 @@ +import unittest import requests_mock from pathlib import Path -import pytest - import tableauserverclient as TSC assets = Path(__file__).parent / "assets" @@ -12,148 +11,143 @@ OIDC_CREATE = assets / "oidc_create.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.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" +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 f1f7878d84f63a355ae1690ce037a32714e243bf Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 18:35:18 -0500 Subject: [PATCH 116/219] 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 | 18 +++ test/test_workbook.py | 117 ++++-------------- 2 files changed, 44 insertions(+), 91 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 546b2114d..5f9695829 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -386,6 +386,24 @@ 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") diff --git a/test/test_workbook.py b/test/test_workbook.py index b039ff7d2..e6e807f89 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -13,32 +13,31 @@ 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 = Path(__file__).parent / "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") +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") @@ -708,74 +707,10 @@ def test_publish_with_thumbnails_user_id(server: TSC.Server) -> None: assert re.search(b'thumbnailsUserId=\\"ee8c6e70-43b6-11e6-af4f-f7b0d8e20761\\"', request_body) - 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_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) - self.assertEqual("AD Service Principal", connection_items[0].auth_type) - - def test_get_workbook_all_fields(self) -> None: - self.server.version = "3.21" - baseurl = self.server.workbooks.baseurl +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) new_workbook = TSC.WorkbookItem( name="Sample", From eb08a9c03ee852a8647701b775d9544ebe447a3d Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 22:51:56 -0500 Subject: [PATCH 117/219] chore: embrace pytest in test_datasource (#1648) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_datasource.py | 183 +++++++--------------------------------- 1 file changed, 30 insertions(+), 153 deletions(-) diff --git a/test/test_datasource.py b/test/test_datasource.py index 4ebaeb2ca..e9635874d 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -17,21 +17,23 @@ from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads from tableauserverclient.server.request_factory import RequestFactory -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" +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") @@ -157,90 +159,19 @@ def test_update_copy_fields(server) -> None: assert single_datasource._project_name == updated_datasource._project_name - 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_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) - self.assertEqual("auth-keypair", connection_items[0].auth_type) - - 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" +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 @@ -919,57 +850,3 @@ 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_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()) - datasources, _ = server.datasources.get() - - datasource = datasources[0] - assert datasource.owner is None - assert datasource.project is None From e110b21c3f643c562aeff0c56c102083c3e75f00 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 22:57:42 -0500 Subject: [PATCH 118/219] 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 | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/test/test_custom_view.py b/test/test_custom_view.py index 2a3932726..0df3b849f 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -141,11 +141,9 @@ 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 @@ -174,9 +172,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() - assert cv.workbook is not None - 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) @@ -191,9 +188,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() - assert cv.workbook is not None - 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)) @@ -208,9 +204,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() - assert cv.workbook is not None - 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()) @@ -225,9 +220,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() - assert cv.workbook is not None - 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): @@ -238,7 +232,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): @@ -249,9 +243,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() - assert cv.workbook is not None - 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 69e10245c58e1fbc3ab4c2d5000243205f1ad96e 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 119/219] feat: add WebAuthoringForFlows capability to Permission class (#1642) Co-authored-by: Jac --- tableauserverclient/models/permissions_item.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index bc29234c4..0171e07d1 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -43,7 +43,6 @@ class Capability: CreateRefreshMetrics = "CreateRefreshMetrics" SaveAs = "SaveAs" PulseMetricDefine = "PulseMetricDefine" - ExtractRefresh = "ExtractRefresh" WebAuthoringForFlows = "WebAuthoringForFlows" def __repr__(self): From b4814980368ce6303f1d001db4ec5fcce73d4471 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 17 Oct 2025 00:54:48 -0500 Subject: [PATCH 120/219] 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> --- test/test_favorites.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/test/test_favorites.py b/test/test_favorites.py index a7bed8d9b..710477a33 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -5,6 +5,7 @@ import tableauserverclient as TSC from tableauserverclient.datetime_helpers import parse_datetime +from ._utils import read_xml_asset TEST_ASSET_DIR = Path(__file__).parent / "assets" @@ -44,12 +45,25 @@ def test_get(server: TSC.Server, user: TSC.UserItem) -> None: 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] + 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) assert workbook.id == "6d13b0ca-043d-4d42-8c9d-3f3313ea3a00" assert view.id == "d79634e1-6063-4ec9-95ff-50acbf609ff5" From 977a7e1d028ed0978342a366717589505e9918ac Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 28 Oct 2025 23:27:02 -0500 Subject: [PATCH 121/219] fix: mypy issues (#1667) --- test/test_custom_view.py | 2 ++ 1 file changed, 2 insertions(+) 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 e6e667b5b2d9e33c37b810d6db16b6ffa019ec7c Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 28 Oct 2025 23:35:14 -0500 Subject: [PATCH 122/219] 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 6415917186e55f30f1fea4fdf6a15429e54f8e09 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 28 Oct 2025 23:38:44 -0500 Subject: [PATCH 123/219] feat: make refresh consistent between endpoints (#1665) --- test/test_flow.py | 88 +++++++++++++++++------------------------------ 1 file changed, 32 insertions(+), 56 deletions(-) diff --git a/test/test_flow.py b/test/test_flow.py index 9ebbbe5d6..08127bf3a 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -180,63 +180,39 @@ def test_publish_file_object(server: TSC.Server) -> None: 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 + 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) 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 + 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( + self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", + headers={"Content-Disposition": '''name="tableau_flow"; filename*=UTF-8''"Sample flow.tfl"'''}, + ) + file_path = self.server.flows.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) + self.assertTrue(os.path.exists(file_path)) From b75ebc8463b60e44d96c70f16b477acaa2e98bc8 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 20 Nov 2025 15:11:32 -0800 Subject: [PATCH 124/219] 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> --- test/test_datasource.py | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 659a445e314b779549283a8d3d45b91bb008d8af Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 20 Nov 2025 16:00:46 -0800 Subject: [PATCH 125/219] 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 --- test/test_datasource.py | 43 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) 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 5394f4d0e8e13abd0f2c7e5212002bec00ed8dc7 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 15:32:42 -0600 Subject: [PATCH 126/219] chore: pytestify favorites (#1674) * fix: black ci errors * chore: pytestify favorites --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- pyproject.toml | 16 ++-------------- test/test_datasource.py | 3 ++- test/test_favorites.py | 26 ++++++-------------------- 3 files changed, 10 insertions(+), 35 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a4a66685f..7b24bb62f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,20 +32,8 @@ classifiers = [ 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.package-data] -# Only include data for tableauserverclient, not for samples, test, docs -tableauserverclient = ["*"] - -[tool.setuptools.packages.find] -where = ["."] -include = ["tableauserverclient*"] - -[tool.setuptools.dynamic] -version = {attr = "versioneer.get_version"} - +test = ["black==24.8", "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 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 710477a33..a7bed8d9b 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -5,7 +5,6 @@ import tableauserverclient as TSC from tableauserverclient.datetime_helpers import parse_datetime -from ._utils import read_xml_asset TEST_ASSET_DIR = Path(__file__).parent / "assets" @@ -45,25 +44,12 @@ def test_get(server: TSC.Server, user: TSC.UserItem) -> None: assert len(user.favorites["projects"]) == 1 assert len(user.favorites["datasources"]) == 1 - 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) + 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" From 61847151f5702884a4b083a2d8d6048bf30c2713 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 16:34:59 -0600 Subject: [PATCH 127/219] 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 | 88 ++++++++++++++++++++++++++++++----------------- 1 file changed, 56 insertions(+), 32 deletions(-) diff --git a/test/test_flow.py b/test/test_flow.py index 08127bf3a..9ebbbe5d6 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -180,39 +180,63 @@ def test_publish_file_object(server: TSC.Server) -> None: with requests_mock.mock() as m: m.post(server.flows.baseurl, text=response_xml) - 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) + 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 - 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( - self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content", - headers={"Content-Disposition": '''name="tableau_flow"; filename*=UTF-8''"Sample flow.tfl"'''}, - ) - file_path = self.server.flows.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) - self.assertTrue(os.path.exists(file_path)) + 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 5a1eb006af9bc971faf470b53c49f6dc930008d7 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 16:39:48 -0600 Subject: [PATCH 128/219] 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 | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/test/models/test_repr.py b/test/models/test_repr.py index 34f8509a7..7f9617635 100644 --- a/test/models/test_repr.py +++ b/test/models/test_repr.py @@ -1,6 +1,7 @@ import inspect from typing import Any -from test.models._models import get_unimplemented_models + +import _models # type: ignore # did not set types for this import tableauserverclient as TSC import pytest @@ -45,4 +46,18 @@ def try_instantiate_class(name: str, obj: Any) -> Any | None: return instance else: print(f"Class '{name}' does not have a constructor (__init__ method).") - return None + + +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" From 56791da50436cd9bd119f734ee3e535be126416e Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 17:05:40 -0600 Subject: [PATCH 129/219] 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 | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_metadata.py b/test/test_metadata.py index 8b8b25151..cf3e6ad4a 100644 --- a/test/test_metadata.py +++ b/test/test_metadata.py @@ -1,5 +1,6 @@ import json from pathlib import Path +import unittest import pytest import requests_mock From 60d1ff47b65738ab4c8f44b1055d76d6886a41b4 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 17:10:23 -0600 Subject: [PATCH 130/219] 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 | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/test/test_project.py b/test/test_project.py index eb33f6732..f2cfab5d1 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -80,25 +80,6 @@ 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: From e508797abc48b01aef1d12693e51fe51ce268ac8 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 17:15:19 -0600 Subject: [PATCH 131/219] 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 ea1aa00ebfee20603f2a942087bf898f44c1c126 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:19:15 -0600 Subject: [PATCH 132/219] 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 | 241 +--------------------------------------------- 1 file changed, 2 insertions(+), 239 deletions(-) diff --git a/test/test_user.py b/test/test_user.py index 8f489187f..4768e2c6b 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -1,8 +1,3 @@ -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 @@ -15,7 +10,6 @@ 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" @@ -30,29 +24,6 @@ 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.""" @@ -282,8 +253,7 @@ 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) - with pytest.warns(DeprecationWarning): - user_list, failures = server.users.create_from_file(str(USERNAMES)) + user_list, failures = server.users.create_from_file(str(USERNAMES)) assert user_list[0].name == "Cassie", user_list assert failures == [], failures @@ -292,8 +262,7 @@ 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) - with pytest.warns(DeprecationWarning): - users, failures = server.users.create_from_file(str(USERS)) + users, failures = server.users.create_from_file(str(USERS)) assert users[0].name == "Cassie", users assert failures == [] @@ -365,209 +334,3 @@ 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 aeac5156d691d010cb791b0cb5e011ad584056da Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:21:17 -0600 Subject: [PATCH 133/219] 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 | 56 ++++++++----------------------------- 1 file changed, 12 insertions(+), 44 deletions(-) diff --git a/test/test_request_option.py b/test/test_request_option.py index 82a3e50fd..2d7402d23 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -339,25 +339,14 @@ 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 "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 "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"] @@ -380,9 +369,6 @@ 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: @@ -431,31 +417,13 @@ def test_queryset_field_order(server: TSC.Server) -> None: assert "name" in 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] +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] + fields = history.qs.get("fields", [""])[0] - assert fields == "_all_" + assert fields == "_all_" From 4dd27a6dcae3c6b238ccd2e8c3f68fa2d8158668 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:21:42 -0600 Subject: [PATCH 134/219] 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 | 73 +------------------------------------------ 1 file changed, 1 insertion(+), 72 deletions(-) diff --git a/test/test_schedule.py b/test/test_schedule.py index 823a87607..307bc0e51 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -15,7 +15,6 @@ 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" @@ -27,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 / "schedule_batch_update_state.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" @@ -179,21 +178,6 @@ 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) @@ -437,58 +421,3 @@ 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 ace1a3541b2aca50c608e8b95f4816085110426b Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:21:54 -0600 Subject: [PATCH 135/219] 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 | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_server_info.py b/test/test_server_info.py index bc1a1bcb3..af911508f 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -1,4 +1,5 @@ from pathlib import Path +import unittest import pytest import requests_mock From d7f5600cfce176ba733d2664d76cd1a64552e630 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:22:16 -0600 Subject: [PATCH 136/219] 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 | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/test/test_view.py b/test/test_view.py index b16f47c72..e032ed569 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -520,20 +520,3 @@ 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 bb894db60c008536a6dedc702ecb6dd38d96ef46 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:23:09 -0600 Subject: [PATCH 137/219] 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 | 95 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 65 insertions(+), 30 deletions(-) diff --git a/test/test_sort.py b/test/test_sort.py index f6ae576f4..ee03a9de9 100644 --- a/test/test_sort.py +++ b/test/test_sort.py @@ -1,3 +1,5 @@ +import re +import unittest from urllib.parse import parse_qs import pytest @@ -11,33 +13,30 @@ 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" + def test_empty_filter(self): + with pytest.raises(TypeError): + TSC.Filter("") return server + 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_empty_filter() -> None: - with pytest.raises(TypeError): - TSC.Filter("") # type: ignore + 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_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_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) def test_filter_equals_list() -> None: @@ -94,14 +93,50 @@ def test_filter_combo(server: TSC.Server) -> None: ) ) - opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.SiteRole, TSC.RequestOptions.Operator.Equals, "Publisher")) + 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]"] 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"] + 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", + ) + ) + + opts.filter.add( + TSC.Filter(TSC.RequestOptions.Field.SiteRole, TSC.RequestOptions.Operator.Equals, "Publisher") + ) + + 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"] == ["lastlogin:gte:2017-01-15t00:00:00:00z,siterole:eq:publisher"] From f991c67331f5fd5c70424241ef68568a8fc0a38c Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:26:10 -0600 Subject: [PATCH 138/219] 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 | 49 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/test/test_site.py b/test/test_site.py index 0bdb39f00..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,8 +10,6 @@ from . import _utils -from . import _utils - TEST_ASSET_DIR = Path(__file__).parent / "assets" GET_XML = TEST_ASSET_DIR / "site_get.xml" @@ -273,16 +270,40 @@ def test_encrypt(server: TSC.Server) -> None: server.sites.encrypt_extracts("0626857c-1def-4503-a7d8-7907c3ff9d9f") - 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 +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]) From b8310e8f520dc12caa0032145976615e898d1a15 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 23 Dec 2025 13:41:01 -0800 Subject: [PATCH 139/219] Update urllib (FOSSA) and black (#1723) Update pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7b24bb62f..bf3c23efe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 50971e87918d0c48db4b4c6248c9ffa7a8ac89bd Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 15:42:11 -0600 Subject: [PATCH 140/219] 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> --- test/test_view.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 a8a2f3a68632478e4acaa9db530a033d84939a9f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 15:55:18 -0600 Subject: [PATCH 141/219] 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> --- test/test_schedule.py | 57 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) 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 6f92520399089e55600c56c8cc2f64fdbe75e207 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 16:45:52 -0600 Subject: [PATCH 142/219] 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> --- test/test_user.py | 241 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 239 insertions(+), 2 deletions(-) diff --git a/test/test_user.py b/test/test_user.py index 4768e2c6b..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 @@ -10,6 +15,7 @@ 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" @@ -24,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.""" @@ -253,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 @@ -262,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 == [] @@ -334,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 6e58e4aee30fe6162958980da92c806c08f0b306 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 23 Dec 2025 14:47:14 -0800 Subject: [PATCH 143/219] 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 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bf3c23efe..684cccd7c 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 c50ec973a2196926ba7d3160a5364a6e51cc75bd Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 26 Dec 2025 14:30:12 -0600 Subject: [PATCH 144/219] chore: pytestify sort (#1725) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_sort.py | 95 +++++++++++++++-------------------------------- 1 file changed, 30 insertions(+), 65 deletions(-) diff --git a/test/test_sort.py b/test/test_sort.py index ee03a9de9..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 @@ -13,30 +11,33 @@ def server(): """Fixture to create a TSC.Server instance for testing.""" server = TSC.Server("http://test", False) - def test_empty_filter(self): - with pytest.raises(TypeError): - TSC.Filter("") + # Fake signin + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" return server - 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_empty_filter() -> None: + with pytest.raises(TypeError): + TSC.Filter("") # type: ignore - 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) + +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: @@ -93,50 +94,14 @@ def test_filter_combo(server: TSC.Server) -> None: ) ) - 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]"] + opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.SiteRole, TSC.RequestOptions.Operator.Equals, "Publisher")) resp = server.workbooks.get_request(url, request_object=opts) - 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", - ) - ) - - opts.filter.add( - TSC.Filter(TSC.RequestOptions.Field.SiteRole, TSC.RequestOptions.Operator.Equals, "Publisher") - ) - - 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"] == ["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 1dbc4370c9d5d4fd7d23f71d27123a4047f45346 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 27 Dec 2025 01:02:06 -0600 Subject: [PATCH 145/219] chore: remove unused unittest imports (#1728) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- test/test_metadata.py | 1 - test/test_server_info.py | 1 - 2 files changed, 2 deletions(-) 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 6d05cda882d500fbab876e3d302656795cb7c513 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 6 Jan 2026 09:53:33 -0600 Subject: [PATCH 146/219] 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> --- test/test_custom_view.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) 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 c4570a5973bff66295c00f648d3d6a54a815b79b Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 8 Jan 2026 14:22:53 -0600 Subject: [PATCH 147/219] 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/server/request_factory.py | 58 +++++++++++++++++++ tableauserverclient/server/server.py | 2 + 2 files changed, 60 insertions(+) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index f021b6dfc..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() 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 From 0432ba36af5494524a71f0f3bf27717c69914769 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 13 Jan 2026 17:01:29 -0800 Subject: [PATCH 148/219] 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 --- test/test_schedule.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 08c315952cd8534302041728870ecdc052db9f33 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Wed, 14 Jan 2026 03:56:49 -0600 Subject: [PATCH 149/219] 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> --- test/test_request_option.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) 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 c71bb667cc59ac7e0048eb4a750c7067f88f86c7 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 21 Jan 2026 14:04:08 -0800 Subject: [PATCH 150/219] Jac/release automation (#1613) * update publish workflow * Update pyproject.toml and setup * add init files to find test_repr, fix it to pass --- pyproject.toml | 7 +++++++ test/models/test_repr.py | 19 ++----------------- test/test_custom_view.py | 5 +++++ 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 684cccd7c..22760e803 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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'] diff --git a/test/models/test_repr.py b/test/models/test_repr.py index 7f9617635..34f8509a7 100644 --- a/test/models/test_repr.py +++ b/test/models/test_repr.py @@ -1,7 +1,6 @@ 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 @@ -46,18 +45,4 @@ def try_instantiate_class(name: str, obj: Any) -> Any | None: 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 14c3ae21f3c5114f81cd95d1a07fe0fdbe21348e Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 22 Jan 2026 14:43:39 -0800 Subject: [PATCH 151/219] implement #816: project.get_by_id (#1736) * implement project.get_by_id * format --- test/test_project.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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: From 4086f032cae0684de583cb45652aed161986a5cc Mon Sep 17 00:00:00 2001 From: Jac Date: Sat, 31 Jan 2026 21:20:37 -0800 Subject: [PATCH 152/219] refer to single package entrypoint (#1739) enumerating subpackages doesn't (shouldn't) actually do anything, so we shouldn't do it. --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 22760e803..1f71a6bac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,8 +36,7 @@ test = ["black==24.10", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pyte "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"] - +where = ["tableauserverclient"] [tool.setuptools.dynamic] version = {attr = "versioneer.get_version"} From b116c69718cb0c6999a591ffdc57b980ae3716cf Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 3 Feb 2026 12:12:12 -0600 Subject: [PATCH 153/219] jorwoods/package install fix (#1747) * Revert "refer to single package entrypoint (#1739)" This reverts commit 043efa14ecf72228801ee8342a74bfe51d163b4a. * fix: make pyproject.toml more explicity in what is included * chore: remove commented out config --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- pyproject.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1f71a6bac..a4a66685f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,8 +35,14 @@ repository = "https://github.com/tableau/server-client-python" 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.package-data] +# Only include data for tableauserverclient, not for samples, test, docs +tableauserverclient = ["*"] + [tool.setuptools.packages.find] -where = ["tableauserverclient"] +where = ["."] +include = ["tableauserverclient*"] + [tool.setuptools.dynamic] version = {attr = "versioneer.get_version"} From bab12fcc51498821abebb181d2511d492c4e6a1a Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 3 Feb 2026 11:32:33 -0800 Subject: [PATCH 154/219] the attribute name is auth_type --- samples/update_connection_auth.py | 2 +- 1 file changed, 1 insertion(+), 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) From c1509167c8f04c55466b6ee75829eb1db40c6514 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 13:03:00 -0600 Subject: [PATCH 155/219] fix: black ci errors (#1713) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> From 157daabfbb48d68439600955137cbcc6a7056fc8 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:24:22 -0600 Subject: [PATCH 156/219] 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 3c5977d81586186014c2c550993fe84192fbd8fc Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:12:32 -0800 Subject: [PATCH 157/219] Fix incorrect assertion syntax in test_populate_data_quality_warning (#1740) * Initial plan * Fix assertion syntax in test_populate_data_quality_warning Co-authored-by: jacalata <2009720+jacalata@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jacalata <2009720+jacalata@users.noreply.github.com> Co-authored-by: Jac --- test/test_database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_database.py b/test/test_database.py index 8eb03c737..951eebe00 100644 --- a/test/test_database.py +++ b/test/test_database.py @@ -98,7 +98,7 @@ def test_populate_data_quality_warning(server): 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.message == "Hello, World!" assert first_dqw.owner_id == "eddc8c5f-6af0-40be-b6b0-2c790290a43f" assert first_dqw.active assert first_dqw.severe From 2f405f7febb92dee05efe95189aa7c91fd8fae80 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:13:27 -0800 Subject: [PATCH 158/219] Fix typos in batch_update_state docstring (#1744) Co-authored-by: jacalata <2009720+jacalata@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- tableauserverclient/server/endpoint/schedules_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 8984af407..99b83e646 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -298,12 +298,12 @@ def batch_update_state( @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, + Batch update the status of one or more schedules. If update_all is set, all schedules on the Tableau Server are affected. Parameters ---------- - schedules: Iterable[ScheudleItem | str] | Any + schedules: Iterable[ScheduleItem | str] | Any The schedules to be updated. If update_all=True, this is ignored. state: Literal["active", "suspended"] From 6282cdbb6a31cb584282e3aef10e13dfdf886824 Mon Sep 17 00:00:00 2001 From: Renata Murzina <149196868+RenaSpb@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:54:30 -0700 Subject: [PATCH 159/219] feat: add SVG format support for view and custom view images (#1755) * feat: add SVG format support for view and custom view images Co-Authored-By: Claude Sonnet 4.5 --- .../server/endpoint/custom_views_endpoint.py | 9 +++- .../server/endpoint/views_endpoint.py | 12 +++-- tableauserverclient/server/request_options.py | 13 ++++- test/test_custom_view.py | 48 +++++++++++++++++++ test/test_view.py | 46 ++++++++++++++++++ 5 files changed, 122 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 8d78dca7a..a82f1fa67 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -121,7 +121,7 @@ def populate_image(self, view_item: CustomViewItem, req_options: Optional["Image view_item : CustomViewItem req_options : ImageRequestOptions, optional - Options to customize the image returned, by default None + Options to customize the image returned, including format (PNG or SVG), by default None Returns ------- @@ -139,6 +139,13 @@ def populate_image(self, view_item: CustomViewItem, req_options: Optional["Image def image_fetcher(): return self._get_view_image(view_item, req_options) + if req_options is not None: + if not self.parent_srv.check_at_least_version("3.29"): + if req_options.format: + from tableauserverclient.server.endpoint.exceptions import UnsupportedAttributeError + + raise UnsupportedAttributeError("format parameter is only supported in 3.29+") + view_item._set_image(image_fetcher) logger.info(f"Populated image for custom view (ID: {view_item.id})") diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 162c04105..b95f3be0a 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -158,7 +158,7 @@ def populate_image(self, view_item: ViewItem, req_options: Optional["ImageReques req_options: Optional[ImageRequestOptions], default None Optional request options for the request. These options can include - parameters such as image resolution and max age. + parameters such as image resolution, max age, and format (PNG or SVG). Returns ------- @@ -171,9 +171,13 @@ def populate_image(self, view_item: ViewItem, req_options: Optional["ImageReques def image_fetcher(): return self._get_view_image(view_item, req_options) - if not self.parent_srv.check_at_least_version("3.23") and req_options is not None: - if req_options.viz_height or req_options.viz_width: - raise UnsupportedAttributeError("viz_height and viz_width are only supported in 3.23+") + if req_options is not None: + if not self.parent_srv.check_at_least_version("3.23"): + if req_options.viz_height or req_options.viz_width: + raise UnsupportedAttributeError("viz_height and viz_width are only supported in 3.23+") + if not self.parent_srv.check_at_least_version("3.29"): + if req_options.format: + raise UnsupportedAttributeError("format parameter is only supported in 3.29+") view_item._set_image(image_fetcher) logger.info(f"Populated image for view (ID: {view_item.id})") diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 70c85d140..870435eb0 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -497,6 +497,10 @@ class ImageRequestOptions(_ImagePDFCommonExportOptions): viz_width: int, optional The width of the viz in pixels. If specified, viz_height must also be specified. + format: str, optional + The format of the image to export. Use Format.PNG, Format.SVG, Format.png, or Format.svg. + Default is "PNG". Available in API version 3.29+. + """ extension = "png" @@ -505,14 +509,21 @@ class ImageRequestOptions(_ImagePDFCommonExportOptions): class Resolution: High = "high" - def __init__(self, imageresolution=None, maxage=-1, viz_height=None, viz_width=None): + class Format: + PNG = "PNG" + SVG = "SVG" + + def __init__(self, imageresolution=None, maxage=-1, viz_height=None, viz_width=None, format=None): super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) self.image_resolution = imageresolution + self.format = format def get_query_params(self): params = super().get_query_params() if self.image_resolution: params["resolution"] = self.image_resolution + if self.format: + params["format"] = self.format return params diff --git a/test/test_custom_view.py b/test/test_custom_view.py index 2a3932726..6cbe4b454 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -116,6 +116,54 @@ def test_populate_image_with_options(server: TSC.Server) -> None: assert response == single_view.image +def test_populate_image_svg_format(server: TSC.Server) -> None: + server.version = "3.29" + response = b"test" + with requests_mock.mock() as m: + m.get( + server.custom_views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?format=SVG", + content=response, + ) + single_view = TSC.CustomViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + req_option = TSC.ImageRequestOptions(format=TSC.ImageRequestOptions.Format.SVG) + server.custom_views.populate_image(single_view, req_option) + assert response == single_view.image + + +def test_populate_image_png_format(server: TSC.Server) -> None: + server.version = "3.29" + response = POPULATE_PREVIEW_IMAGE.read_bytes() + with requests_mock.mock() as m: + m.get( + server.custom_views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?format=PNG", + content=response, + ) + single_view = TSC.CustomViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + req_option = TSC.ImageRequestOptions(format=TSC.ImageRequestOptions.Format.PNG) + server.custom_views.populate_image(single_view, req_option) + assert response == single_view.image + + +def test_populate_image_format_unsupported_version(server: TSC.Server) -> None: + from tableauserverclient.server.endpoint.exceptions import UnsupportedAttributeError + + server.version = "3.28" + response = POPULATE_PREVIEW_IMAGE.read_bytes() + with requests_mock.mock() as m: + m.get( + server.custom_views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?format=SVG", + content=response, + ) + single_view = TSC.CustomViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + req_option = TSC.ImageRequestOptions(format=TSC.ImageRequestOptions.Format.SVG) + + with pytest.raises(UnsupportedAttributeError): + server.custom_views.populate_image(single_view, req_option) + + def test_populate_image_missing_id(server: TSC.Server) -> None: single_view = TSC.CustomViewItem() single_view._id = None diff --git a/test/test_view.py b/test/test_view.py index b16f47c72..a940e1d18 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -238,6 +238,52 @@ def test_populate_image_with_options(server: TSC.Server) -> None: assert response == single_view.image +def test_populate_image_svg_format(server: TSC.Server) -> None: + server.version = "3.29" + response = b"test" + with requests_mock.mock() as m: + m.get( + server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?format=SVG", + content=response, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + req_option = TSC.ImageRequestOptions(format=TSC.ImageRequestOptions.Format.SVG) + server.views.populate_image(single_view, req_option) + assert response == single_view.image + + +def test_populate_image_png_format(server: TSC.Server) -> None: + server.version = "3.29" + response = POPULATE_PREVIEW_IMAGE.read_bytes() + with requests_mock.mock() as m: + m.get( + server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?format=PNG", + content=response, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + req_option = TSC.ImageRequestOptions(format=TSC.ImageRequestOptions.Format.PNG) + server.views.populate_image(single_view, req_option) + assert response == single_view.image + + +def test_populate_image_format_unsupported_version(server: TSC.Server) -> None: + server.version = "3.28" + response = POPULATE_PREVIEW_IMAGE.read_bytes() + with requests_mock.mock() as m: + m.get( + server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?format=SVG", + content=response, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + req_option = TSC.ImageRequestOptions(format=TSC.ImageRequestOptions.Format.SVG) + + with pytest.raises(UnsupportedAttributeError): + server.views.populate_image(single_view, req_option) + + def test_populate_pdf(server: TSC.Server) -> None: response = POPULATE_PDF.read_bytes() with requests_mock.mock() as m: From ec0614e8caf26bd7eb261c765df7f8b420fa5895 Mon Sep 17 00:00:00 2001 From: Jac Date: Mon, 6 Apr 2026 10:48:32 -0700 Subject: [PATCH 160/219] #1090 add check for 409 error code (#1733) --- .../server/endpoint/workbooks_endpoint.py | 15 +++++++++++---- test/assets/workbook_refresh_duplicate.xml | 3 +++ test/test_workbook.py | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 test/assets/workbook_refresh_duplicate.xml diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 5f9695829..218a4016f 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -14,6 +14,7 @@ from tableauserverclient.server.endpoint.exceptions import ( InternalServerError, MissingRequiredFieldError, + ServerResponseError, UnsupportedAttributeError, ) from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint @@ -125,7 +126,7 @@ def get_by_id(self, workbook_id: str) -> WorkbookItem: return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.8") - def refresh(self, workbook_item: Union[WorkbookItem, str], incremental: bool = False) -> JobItem: + def refresh(self, workbook_item: Union[WorkbookItem, str], incremental: bool = False) -> JobItem | None: """ Refreshes the extract of an existing workbook. @@ -138,13 +139,19 @@ def refresh(self, workbook_item: Union[WorkbookItem, str], incremental: bool = F Returns ------- - JobItem - The job item. + JobItem | None + The job item, or None if a refresh job is already queued for this workbook. """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/refresh" refresh_req = RequestFactory.Task.refresh_req(incremental, self.parent_srv) - server_response = self.post_request(url, refresh_req) + try: + server_response = self.post_request(url, refresh_req) + except ServerResponseError as e: + if e.code.startswith("409") and "already" in e.detail: + logger.warning(f"{e.summary} {e.detail}") + return None + raise new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job diff --git a/test/assets/workbook_refresh_duplicate.xml b/test/assets/workbook_refresh_duplicate.xml new file mode 100644 index 000000000..eca4b4bcc --- /dev/null +++ b/test/assets/workbook_refresh_duplicate.xml @@ -0,0 +1,3 @@ + + +Resource ConflictJob for \'extract\' is already queued. Not queuing a duplicate. \ No newline at end of file diff --git a/test/test_workbook.py b/test/test_workbook.py index e6e807f89..b210e8402 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -34,6 +34,7 @@ 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" +WORKBOOK_REFRESH_DUPLICATE_XML = TEST_ASSET_DIR / "workbook_refresh_duplicate.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" @@ -178,6 +179,20 @@ def test_refresh_id(server: TSC.Server) -> None: server.workbooks.refresh("3cc6cd06-89ce-4fdc-b935-5294135d6d42") +def test_refresh_already_running(server: TSC.Server) -> None: + server.version = "2.8" + server.workbooks.baseurl + response_xml = WORKBOOK_REFRESH_DUPLICATE_XML.read_text() + with requests_mock.mock() as m: + m.post( + server.workbooks.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/refresh", + status_code=409, + text=response_xml, + ) + refresh_job = server.workbooks.refresh("3cc6cd06-89ce-4fdc-b935-5294135d6d42") + assert refresh_job is None + + def test_refresh_object(server: TSC.Server) -> None: server.version = "2.8" server.workbooks.baseurl From 314c9d47176173f57115222e56334825e2d25d9c Mon Sep 17 00:00:00 2001 From: Vineeth Sai Surya Chavatapalli Date: Sun, 10 May 2026 03:36:04 -0500 Subject: [PATCH 161/219] Fix W-21968404: Make authentication_type optional in update_connections (#1778) --- samples/update_connections_auth.py | 6 +-- .../server/endpoint/datasources_endpoint.py | 10 +++-- .../server/endpoint/workbooks_endpoint.py | 12 ++++-- tableauserverclient/server/request_factory.py | 10 +++-- .../datasource_connections_update_no_auth.xml | 21 ++++++++++ .../workbook_update_connections_no_auth.xml | 21 ++++++++++ test/test_datasource.py | 39 +++++++++++++++++++ test/test_workbook.py | 37 ++++++++++++++++++ 8 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 test/assets/datasource_connections_update_no_auth.xml create mode 100644 test/assets/workbook_update_connections_no_auth.xml diff --git a/samples/update_connections_auth.py b/samples/update_connections_auth.py index f0c8dd852..f99a866ef 100644 --- a/samples/update_connections_auth.py +++ b/samples/update_connections_auth.py @@ -22,9 +22,9 @@ def main(): # 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("--datasource_username", help="Datasource username (optional)") + parser.add_argument("--authentication_type", help="Authentication type (optional)") + parser.add_argument("--datasource_password", help="Datasource password (optional)") parser.add_argument( "--embed_password", default="true", choices=["true", "false"], help="Embed password (default: true)" ) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 6a734f7b3..7608f908e 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -379,7 +379,7 @@ def update_connections( self, datasource_item: DatasourceItem, connection_luids: Iterable[str], - authentication_type: str, + authentication_type: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, embed_password: Optional[bool] = None, @@ -387,6 +387,9 @@ def update_connections( """ Bulk updates one or more datasource connections by LUID. + This method allows updating authentication type, credentials, and other + connection properties for multiple connections at once. + Parameters ---------- datasource_item : DatasourceItem @@ -395,8 +398,9 @@ def update_connections( connection_luids : Iterable of str The connection LUIDs to update. - authentication_type : str - The authentication type to use (e.g., 'auth-keypair'). + authentication_type : str, optional + The authentication type to use (e.g., 'auth-keypair', 'AD Service Principal'). + If not provided, the existing authentication type is preserved. username : str, optional The username to set. diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 218a4016f..7db0a3d67 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -341,13 +341,16 @@ def update_connections( self, workbook_item: WorkbookItem, connection_luids: Iterable[str], - authentication_type: str, + authentication_type: Optional[str] = None, 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. + Bulk updates one or more workbook connections by LUID. + + This method allows updating authentication type, credentials, and other + connection properties for multiple connections at once. Parameters ---------- @@ -357,8 +360,9 @@ def update_connections( connection_luids : Iterable of str The connection LUIDs to update. - authentication_type : str - The authentication type to use (e.g., 'AD Service Principal'). + authentication_type : str, optional + The authentication type to use (e.g., 'AD Service Principal', 'auth-keypair'). + If not provided, the existing authentication type is preserved. username : str, optional The username to set (e.g., client ID for keypair auth). diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 57deb6e26..589b6beb4 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -254,7 +254,7 @@ def update_connections_req( self, element: ET.Element, connection_luids: Iterable[str], - authentication_type: str, + authentication_type: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, embed_password: Optional[bool] = None, @@ -264,7 +264,8 @@ def update_connections_req( ET.SubElement(conn_luids_elem, "connectionLUID").text = luid connection_elem = ET.SubElement(element, "connection") - connection_elem.set("authenticationType", authentication_type) + if authentication_type is not None: + connection_elem.set("authenticationType", authentication_type) if username is not None: connection_elem.set("userName", username) @@ -1172,7 +1173,7 @@ def update_connections_req( self, element: ET.Element, connection_luids: Iterable[str], - authentication_type: str, + authentication_type: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, embed_password: Optional[bool] = None, @@ -1182,7 +1183,8 @@ def update_connections_req( ET.SubElement(conn_luids_elem, "connectionLUID").text = luid connection_elem = ET.SubElement(element, "connection") - connection_elem.set("authenticationType", authentication_type) + if authentication_type is not None: + connection_elem.set("authenticationType", authentication_type) if username is not None: connection_elem.set("userName", username) diff --git a/test/assets/datasource_connections_update_no_auth.xml b/test/assets/datasource_connections_update_no_auth.xml new file mode 100644 index 000000000..b9d1bf3f0 --- /dev/null +++ b/test/assets/datasource_connections_update_no_auth.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/test/assets/workbook_update_connections_no_auth.xml b/test/assets/workbook_update_connections_no_auth.xml new file mode 100644 index 000000000..21860fa06 --- /dev/null +++ b/test/assets/workbook_update_connections_no_auth.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index 56eb11ab7..a0890f3a5 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -35,6 +35,7 @@ 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" +UPDATE_CONNECTIONS_NO_AUTH_XML = TEST_ASSET_DIR / "datasource_connections_update_no_auth.xml" @pytest.fixture(scope="function") @@ -276,6 +277,44 @@ def test_update_connections(server) -> None: assert "auth-keypair" == connection_items[0].auth_type +def test_update_connections_without_auth_type(server) -> None: + """Test that update_connections works when authentication_type is not provided.""" + populate_xml = POPULATE_CONNECTIONS_XML.read_text() + response_xml = UPDATE_CONNECTIONS_NO_AUTH_XML.read_text() + + 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" + server.version = "3.26" + + 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, + ) + + # Update connections without specifying authentication_type + connection_items = server.datasources.update_connections( + datasource_item=datasource, + connection_luids=connection_luids, + username="user1", + embed_password=True, + ) + updated_ids = [conn.id for conn in connection_items] + + assert updated_ids == connection_luids + # Verify that the auth type from the response is preserved (UsernamePassword) + assert connection_items[0].auth_type == "UsernamePassword" + + def test_populate_permissions(server) -> None: response_xml = POPULATE_PERMISSIONS_XML.read_text() with requests_mock.mock() as m: diff --git a/test/test_workbook.py b/test/test_workbook.py index b210e8402..c5c4f6662 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -39,6 +39,7 @@ 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" +UPDATE_CONNECTIONS_NO_AUTH_XML = TEST_ASSET_DIR / "workbook_update_connections_no_auth.xml" @pytest.fixture(scope="function") @@ -1047,6 +1048,42 @@ def test_update_workbook_connections(server: TSC.Server) -> None: assert "AD Service Principal" == connection_items[0].auth_type +def test_update_workbook_connections_without_auth_type(server: TSC.Server) -> None: + """Test that update_connections works when authentication_type is not provided.""" + populate_xml = POPULATE_CONNECTIONS_XML.read_text() + response_xml = UPDATE_CONNECTIONS_NO_AUTH_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, + ) + + # Update connections without specifying authentication_type + connection_items = server.workbooks.update_connections( + workbook_item=workbook, + connection_luids=connection_luids, + username="user1", + embed_password=True, + ) + updated_ids = [conn.id for conn in connection_items] + + assert updated_ids == connection_luids + # Verify that the auth type from the response is preserved (UsernamePassword) + assert connection_items[0].auth_type == "UsernamePassword" + + def test_get_workbook_all_fields(server: TSC.Server) -> None: server.version = "3.21" baseurl = server.workbooks.baseurl From 6acd12fb2d8da97c6f26e236da2af210d4cd86d2 Mon Sep 17 00:00:00 2001 From: Erik Cederstrand Date: Thu, 4 Jun 2026 05:57:03 +0200 Subject: [PATCH 162/219] Update license information in pyproject.toml (#1776) Update license information in pyproject.toml - Fixes license specifiers according to https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license-and-license-files - This allows automatic license inspection tools like pip-licenses to correctly categorize this package. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index a4a66685f..80196bd91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +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 = "MIT" license-files = ["LICENSE"] readme = "README.md" From 3b668cd98e227dfc3f7d8bb82125b9a56959c862 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:05:19 -0700 Subject: [PATCH 163/219] Remove deprecated `cgi` module and clean up related dead code (#1771) * Remove deprecated cgi module usage and clean up dead code Agent-Logs-Url: https://github.com/tableau/server-client-python/sessions/453f920c-7857-4503-ad99-0ccef076d792 Co-authored-by: jacalata <2009720+jacalata@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jacalata <2009720+jacalata@users.noreply.github.com> Co-authored-by: Brian Cantoni --- tableauserverclient/helpers/headers.py | 17 ----------------- .../server/endpoint/datasources_endpoint.py | 6 ++---- .../server/endpoint/flows_endpoint.py | 7 ++----- .../server/endpoint/workbooks_endpoint.py | 6 ++---- 4 files changed, 6 insertions(+), 30 deletions(-) delete mode 100644 tableauserverclient/helpers/headers.py diff --git a/tableauserverclient/helpers/headers.py b/tableauserverclient/helpers/headers.py deleted file mode 100644 index 2ed4a814d..000000000 --- a/tableauserverclient/helpers/headers.py +++ /dev/null @@ -1,17 +0,0 @@ -from copy import deepcopy -from urllib.parse import unquote_plus - - -def fix_filename(params): - if "filename*" not in params: - return params - - params = deepcopy(params) - filename = params["filename*"] - prefix = "UTF-8''" - if filename.startswith(prefix): - filename = filename[len(prefix) :] - - params["filename"] = unquote_plus(filename) - del params["filename*"] - return params diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 7608f908e..a346c2730 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -9,7 +9,6 @@ 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 from tableauserverclient.server.query import QuerySet @@ -1029,14 +1028,13 @@ def download_revision( with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() m["Content-Disposition"] = server_response.headers["Content-Disposition"] - params = m.get_filename(failobj="") + filename = m.get_filename(failobj="") if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) return_path = filepath else: - params = fix_filename(params) - filename = to_filename(os.path.basename(params)) + filename = to_filename(os.path.basename(filename)) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: for chunk in server_response.iter_content(1024): # 1KB diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index ea29f2963..23e67f5de 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -8,8 +8,6 @@ from typing import Optional, TYPE_CHECKING, Union from collections.abc import Iterable -from tableauserverclient.helpers.headers import fix_filename - from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError @@ -227,14 +225,13 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() m["Content-Disposition"] = server_response.headers["Content-Disposition"] - params = m.get_filename(failobj="") + filename = m.get_filename(failobj="") if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) return_path = filepath else: - params = fix_filename(params) - filename = to_filename(os.path.basename(params)) + filename = to_filename(os.path.basename(filename)) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: for chunk in server_response.iter_content(1024): # 1KB diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 7db0a3d67..14297518a 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -6,7 +6,6 @@ from contextlib import closing from pathlib import Path -from tableauserverclient.helpers.headers import fix_filename from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.query import QuerySet @@ -1102,14 +1101,13 @@ def download_revision( with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() m["Content-Disposition"] = server_response.headers["Content-Disposition"] - params = m.get_filename(failobj="") + filename = m.get_filename(failobj="") if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) return_path = filepath else: - params = fix_filename(params) - filename = to_filename(os.path.basename(params)) + filename = to_filename(os.path.basename(filename)) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: for chunk in server_response.iter_content(1024): # 1KB From 5d9a3c5e9c3a9e2c7c48872872eef15fcfa3f7c8 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Wed, 17 Jun 2026 00:59:48 -0500 Subject: [PATCH 164/219] chore: modernize Union and Optional type hints (#1774) * chore: replace Union with modern syntax * chore: modernize Optional type hints * fix: bump required versions * fix: typing on update_connections methods * docs: make docstrings match function signature * fix: clarify max_age can be None * fix: docstring indent * style: black --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> --- pyproject.toml | 6 +- tableauserverclient/bin/_version.py | 58 +++--- tableauserverclient/models/collection_item.py | 19 +- tableauserverclient/models/connection_item.py | 39 ++-- .../models/custom_view_item.py | 70 +++---- tableauserverclient/models/data_alert_item.py | 63 ++++--- .../models/data_freshness_policy_item.py | 21 +-- tableauserverclient/models/datasource_item.py | 171 +++++++++--------- tableauserverclient/models/extract_item.py | 15 +- tableauserverclient/models/favorites_item.py | 2 +- tableauserverclient/models/flow_item.py | 54 +++--- tableauserverclient/models/flow_run_item.py | 27 ++- tableauserverclient/models/group_item.py | 32 ++-- tableauserverclient/models/groupset_item.py | 9 +- tableauserverclient/models/job_item.py | 79 ++++---- .../models/linked_tasks_item.py | 33 ++-- tableauserverclient/models/location_item.py | 15 +- tableauserverclient/models/metric_item.py | 63 ++++--- tableauserverclient/models/oidc_item.py | 41 ++--- .../models/permissions_item.py | 3 +- tableauserverclient/models/project_item.py | 72 ++++---- tableauserverclient/models/revision_item.py | 33 ++-- tableauserverclient/models/schedule_item.py | 30 +-- tableauserverclient/models/site_item.py | 94 +++++----- tableauserverclient/models/table_item.py | 4 +- tableauserverclient/models/tableau_auth.py | 13 +- tableauserverclient/models/tableau_types.py | 2 +- tableauserverclient/models/task_item.py | 9 +- tableauserverclient/models/user_item.py | 76 ++++---- tableauserverclient/models/view_item.py | 108 +++++------ .../models/virtual_connection_item.py | 26 +-- tableauserverclient/models/webhook_item.py | 25 ++- tableauserverclient/models/workbook_item.py | 116 ++++++------ .../server/endpoint/custom_views_endpoint.py | 44 +++-- .../server/endpoint/data_alert_endpoint.py | 10 +- .../server/endpoint/databases_endpoint.py | 8 +- .../server/endpoint/datasources_endpoint.py | 148 +++++++-------- .../endpoint/default_permissions_endpoint.py | 12 +- .../server/endpoint/dqw_endpoint.py | 6 +- .../server/endpoint/endpoint.py | 24 +-- .../server/endpoint/exceptions.py | 6 +- .../server/endpoint/favorites_endpoint.py | 3 +- .../server/endpoint/flow_runs_endpoint.py | 10 +- .../server/endpoint/flows_endpoint.py | 30 +-- .../server/endpoint/groups_endpoint.py | 26 +-- .../server/endpoint/groupsets_endpoint.py | 14 +- .../server/endpoint/jobs_endpoint.py | 12 +- .../server/endpoint/linked_tasks_endpoint.py | 8 +- .../server/endpoint/metrics_endpoint.py | 4 +- .../server/endpoint/oidc_endpoint.py | 10 +- .../server/endpoint/permissions_endpoint.py | 6 +- .../server/endpoint/projects_endpoint.py | 6 +- .../server/endpoint/resource_tagger.py | 14 +- .../server/endpoint/schedules_endpoint.py | 28 +-- .../server/endpoint/server_info_endpoint.py | 2 +- .../server/endpoint/sites_endpoint.py | 4 +- .../server/endpoint/subscriptions_endpoint.py | 4 +- .../server/endpoint/tables_endpoint.py | 12 +- .../server/endpoint/tasks_endpoint.py | 4 +- .../server/endpoint/users_endpoint.py | 27 ++- .../server/endpoint/views_endpoint.py | 44 ++--- .../endpoint/virtual_connections_endpoint.py | 24 +-- .../server/endpoint/webhooks_endpoint.py | 6 +- .../server/endpoint/workbooks_endpoint.py | 58 +++--- tableauserverclient/server/pager.py | 10 +- tableauserverclient/server/query.py | 12 +- tableauserverclient/server/request_factory.py | 62 +++---- tableauserverclient/server/request_options.py | 14 +- test/_utils.py | 1 - test/test_datasource.py | 3 +- test/test_tagging.py | 4 +- 71 files changed, 1056 insertions(+), 1092 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 80196bd91..c832ffe71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,15 +19,15 @@ dependencies = [ 'urllib3>=2.6.0,<3', 'typing_extensions>=4.0', ] -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13" + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14" ] [project.urls] repository = "https://github.com/tableau/server-client-python" diff --git a/tableauserverclient/bin/_version.py b/tableauserverclient/bin/_version.py index 680304c7d..d34f78bd3 100644 --- a/tableauserverclient/bin/_version.py +++ b/tableauserverclient/bin/_version.py @@ -16,11 +16,11 @@ import re import subprocess import sys -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Any, Callable import functools -def get_keywords() -> Dict[str, str]: +def get_keywords() -> dict[str, str]: """Get the keywords needed to look up the version information.""" # these strings will be replaced by git during git-archive. # setup.py/versioneer.py will grep for the variable names, so they must @@ -61,8 +61,8 @@ class NotThisMethod(Exception): """Exception raised if a method is not valid for the current scenario.""" -LONG_VERSION_PY: Dict[str, str] = {} -HANDLERS: Dict[str, Dict[str, Callable]] = {} +LONG_VERSION_PY: dict[str, str] = {} +HANDLERS: dict[str, dict[str, Callable]] = {} def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator @@ -77,18 +77,18 @@ def decorate(f: Callable) -> Callable: def run_command( - commands: List[str], - args: List[str], - cwd: Optional[str] = None, + commands: list[str], + args: list[str], + cwd: str | None = None, verbose: bool = False, hide_stderr: bool = False, - env: Optional[Dict[str, str]] = None, -) -> Tuple[Optional[str], Optional[int]]: + env: dict[str, str] | None = None, +) -> tuple[str | None, int | None]: """Call the given command(s).""" assert isinstance(commands, list) process = None - popen_kwargs: Dict[str, Any] = {} + popen_kwargs: dict[str, Any] = {} if sys.platform == "win32": # This hides the console window if pythonw.exe is used startupinfo = subprocess.STARTUPINFO() @@ -128,7 +128,7 @@ def versions_from_parentdir( parentdir_prefix: str, root: str, verbose: bool, -) -> Dict[str, Any]: +) -> dict[str, Any]: """Try to determine the version from the parent directory name. Source tarballs conventionally unpack into a directory that includes both @@ -153,13 +153,13 @@ def versions_from_parentdir( @register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: +def git_get_keywords(versionfile_abs: str) -> dict[str, str]: """Extract version information from the given file.""" # the code embedded in _version.py can just fetch the value of these # keywords. When used from setup.py, we don't want to import _version.py, # so we do it with a regexp instead. This function is not used from # _version.py. - keywords: Dict[str, str] = {} + keywords: dict[str, str] = {} try: with open(versionfile_abs, "r") as fobj: for line in fobj: @@ -182,10 +182,10 @@ def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: @register_vcs_handler("git", "keywords") def git_versions_from_keywords( - keywords: Dict[str, str], + keywords: dict[str, str], tag_prefix: str, verbose: bool, -) -> Dict[str, Any]: +) -> dict[str, Any]: """Get version information from git keywords.""" if "refnames" not in keywords: raise NotThisMethod("Short version file found") @@ -254,7 +254,7 @@ def git_pieces_from_vcs( root: str, verbose: bool, runner: Callable = run_command -) -> Dict[str, Any]: +) -> dict[str, Any]: """Get version from 'git describe' in the root of the source tree. This only gets called if the git-archive 'subst' keywords were *not* @@ -294,7 +294,7 @@ def git_pieces_from_vcs( raise NotThisMethod("'git rev-parse' failed") full_out = full_out.strip() - pieces: Dict[str, Any] = {} + pieces: dict[str, Any] = {} pieces["long"] = full_out pieces["short"] = full_out[:7] # maybe improved later pieces["error"] = None @@ -386,14 +386,14 @@ def git_pieces_from_vcs( return pieces -def plus_or_dot(pieces: Dict[str, Any]) -> str: +def plus_or_dot(pieces: dict[str, Any]) -> str: """Return a + if we don't already have one, else return a .""" if "+" in pieces.get("closest-tag", ""): return "." return "+" -def render_pep440(pieces: Dict[str, Any]) -> str: +def render_pep440(pieces: dict[str, Any]) -> str: """Build up version string, with post-release "local version identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you @@ -418,7 +418,7 @@ def render_pep440(pieces: Dict[str, Any]) -> str: return rendered -def render_pep440_branch(pieces: Dict[str, Any]) -> str: +def render_pep440_branch(pieces: dict[str, Any]) -> str: """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . The ".dev0" means not master branch. Note that .dev0 sorts backwards @@ -448,7 +448,7 @@ def render_pep440_branch(pieces: Dict[str, Any]) -> str: return rendered -def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: +def pep440_split_post(ver: str) -> tuple[str, int | None]: """Split pep440 version string at the post-release segment. Returns the release segments before the post-release and the @@ -458,7 +458,7 @@ def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: return vc[0], int(vc[1] or 0) if len(vc) == 2 else None -def render_pep440_pre(pieces: Dict[str, Any]) -> str: +def render_pep440_pre(pieces: dict[str, Any]) -> str: """TAG[.postN.devDISTANCE] -- No -dirty. Exceptions: @@ -482,7 +482,7 @@ def render_pep440_pre(pieces: Dict[str, Any]) -> str: return rendered -def render_pep440_post(pieces: Dict[str, Any]) -> str: +def render_pep440_post(pieces: dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that .dev0 sorts backwards @@ -509,7 +509,7 @@ def render_pep440_post(pieces: Dict[str, Any]) -> str: return rendered -def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: +def render_pep440_post_branch(pieces: dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . The ".dev0" means not master branch. @@ -538,7 +538,7 @@ def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: return rendered -def render_pep440_old(pieces: Dict[str, Any]) -> str: +def render_pep440_old(pieces: dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty. @@ -560,7 +560,7 @@ def render_pep440_old(pieces: Dict[str, Any]) -> str: return rendered -def render_git_describe(pieces: Dict[str, Any]) -> str: +def render_git_describe(pieces: dict[str, Any]) -> str: """TAG[-DISTANCE-gHEX][-dirty]. Like 'git describe --tags --dirty --always'. @@ -580,7 +580,7 @@ def render_git_describe(pieces: Dict[str, Any]) -> str: return rendered -def render_git_describe_long(pieces: Dict[str, Any]) -> str: +def render_git_describe_long(pieces: dict[str, Any]) -> str: """TAG-DISTANCE-gHEX[-dirty]. Like 'git describe --tags --dirty --always -long'. @@ -600,7 +600,7 @@ def render_git_describe_long(pieces: Dict[str, Any]) -> str: return rendered -def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: +def render(pieces: dict[str, Any], style: str) -> dict[str, Any]: """Render the given version pieces into the requested style.""" if pieces["error"]: return {"version": "unknown", @@ -636,7 +636,7 @@ def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: "date": pieces.get("date")} -def get_versions() -> Dict[str, Any]: +def get_versions() -> dict[str, Any]: """Get version information or return default if unable to do so.""" # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have # __file__, we can work backwards from there to the root. Some diff --git a/tableauserverclient/models/collection_item.py b/tableauserverclient/models/collection_item.py index 4fdb61023..786e9492b 100644 --- a/tableauserverclient/models/collection_item.py +++ b/tableauserverclient/models/collection_item.py @@ -1,5 +1,4 @@ from datetime import datetime -from typing import Optional from xml.etree.ElementTree import Element from defusedxml.ElementTree import fromstring @@ -11,15 +10,15 @@ 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 + self.id: str | None = None + self.name: str | None = None + self.description: str | None = None + self.created_at: datetime | None = None + self.updated_at: datetime | None = None + self.owner: UserItem | None = None + self.total_item_count: int | None = None + self.permissioned_item_count: int | None = None + self.visibility: str | None = None # Assuming visibility is a string, adjust as necessary @classmethod def from_response(cls, response: bytes, ns) -> list[Self]: diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index e155a3e3a..9e2f008a0 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -1,5 +1,4 @@ import logging -from typing import Optional from defusedxml.ElementTree import fromstring @@ -51,42 +50,42 @@ class ConnectionItem: """ def __init__(self): - self._datasource_id: Optional[str] = None - self._datasource_name: Optional[str] = None - self._id: Optional[str] = None - self._connection_type: Optional[str] = None + self._datasource_id: str | None = None + self._datasource_name: str | None = None + self._id: str | None = None + self._connection_type: str | None = None self.embed_password: bool = None - self.password: Optional[str] = None - self.server_address: Optional[str] = None - self.server_port: Optional[str] = None - self.username: Optional[str] = None - self.connection_credentials: Optional[ConnectionCredentials] = None - self._query_tagging: Optional[bool] = None - self._auth_type: Optional[str] = None + self.password: str | None = None + self.server_address: str | None = None + self.server_port: str | None = None + self.username: str | None = None + self.connection_credentials: ConnectionCredentials | None = None + self._query_tagging: bool | None = None + self._auth_type: str | None = None @property - def datasource_id(self) -> Optional[str]: + def datasource_id(self) -> str | None: return self._datasource_id @property - def datasource_name(self) -> Optional[str]: + def datasource_name(self) -> str | None: return self._datasource_name @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @property - def connection_type(self) -> Optional[str]: + def connection_type(self) -> str | None: return self._connection_type @property - def query_tagging(self) -> Optional[bool]: + def query_tagging(self) -> bool | None: return self._query_tagging @query_tagging.setter @property_is_boolean - def query_tagging(self, value: Optional[bool]): + def query_tagging(self, value: bool | None): # if connection type = hyper, Snowflake, or Teradata, we can't change this value: it is always true if self._connection_type in ["hyper", "snowflake", "teradata"]: logger.debug( @@ -96,11 +95,11 @@ def query_tagging(self, value: Optional[bool]): self._query_tagging = value @property - def auth_type(self) -> Optional[str]: + def auth_type(self) -> str | None: return self._auth_type @auth_type.setter - def auth_type(self, value: Optional[str]): + def auth_type(self, value: str | None): self._auth_type = value def __repr__(self): diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index 92acb0e9b..d20df89e9 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -2,7 +2,7 @@ from defusedxml import ElementTree from defusedxml.ElementTree import fromstring, tostring -from typing import Callable, Optional +from typing import Callable from collections.abc import Iterator from tableauserverclient.models.exceptions import UnpopulatedPropertyError @@ -18,18 +18,18 @@ class CustomViewItem: Parameters ---------- - id : Optional[str] + id : str | None The ID of the Custom View item. - name : Optional[str] + name : str | None The name of the Custom View item. Attributes ---------- - content_url : Optional[str] + content_url : str | None The content URL of the Custom View item. - created_at : Optional[datetime] + created_at : datetime | None The date and time the Custom View item was created. image: bytes @@ -41,36 +41,36 @@ class CustomViewItem: csv: Iterator[bytes] The CSV of the Custom View item. Must be populated first. - shared : Optional[bool] + shared : bool | None Whether the Custom View item is shared. - updated_at : Optional[datetime] + updated_at : datetime | None The date and time the Custom View item was last updated. - owner : Optional[UserItem] + owner : UserItem | None The id of the owner of the Custom View item. - workbook : Optional[WorkbookItem] + workbook : WorkbookItem | None The id of the workbook the Custom View item belongs to. - view : Optional[ViewItem] + view : ViewItem | None The id of the view the Custom View item belongs to. """ - def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None: - self._content_url: Optional[str] = None # ? - self._created_at: Optional["datetime"] = None - self._id: Optional[str] = id - self._image: Optional[Callable[[], bytes]] = None - self._pdf: Optional[Callable[[], bytes]] = None - self._csv: Optional[Callable[[], Iterator[bytes]]] = None - self._name: Optional[str] = name - self._shared: Optional[bool] = False - self._updated_at: Optional["datetime"] = None - - self._owner: Optional[UserItem] = None - self._view: Optional[ViewItem] = None - self._workbook: Optional[WorkbookItem] = None + def __init__(self, id: str | None = None, name: str | None = None) -> None: + self._content_url: str | None = None # ? + self._created_at: "datetime | None" = None + self._id: str | None = id + self._image: Callable[[], bytes] | None = None + self._pdf: Callable[[], bytes] | None = None + self._csv: Callable[[], Iterator[bytes]] | None = None + self._name: str | None = name + self._shared: bool | None = False + self._updated_at: "datetime | None" = None + + self._owner: UserItem | None = None + self._view: ViewItem | None = None + self._workbook: WorkbookItem | None = None def __repr__(self: "CustomViewItem"): view_info = "" @@ -94,15 +94,15 @@ def _set_csv(self, csv): self._csv = csv @property - def content_url(self) -> Optional[str]: + def content_url(self) -> str | None: return self._content_url @property - def created_at(self) -> Optional["datetime"]: + def created_at(self) -> "datetime | None": return self._created_at @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @property @@ -127,7 +127,7 @@ def csv(self) -> Iterator[bytes]: return self._csv() @property - def name(self) -> Optional[str]: + def name(self) -> str | None: return self._name @name.setter @@ -135,7 +135,7 @@ def name(self, value: str): self._name = value @property - def shared(self) -> Optional[bool]: + def shared(self) -> bool | None: return self._shared @shared.setter @@ -143,11 +143,11 @@ def shared(self, value: bool): self._shared = value @property - def updated_at(self) -> Optional["datetime"]: + def updated_at(self) -> "datetime | None": return self._updated_at @property - def owner(self) -> Optional[UserItem]: + def owner(self) -> UserItem | None: return self._owner @owner.setter @@ -155,7 +155,7 @@ def owner(self, value: UserItem): self._owner = value @property - def workbook(self) -> Optional[WorkbookItem]: + def workbook(self) -> WorkbookItem | None: return self._workbook @workbook.setter @@ -163,7 +163,7 @@ def workbook(self, value: WorkbookItem) -> None: self._workbook = value @property - def view(self) -> Optional[ViewItem]: + def view(self) -> ViewItem | None: return self._view @view.setter @@ -171,7 +171,7 @@ def view(self, value: ViewItem) -> None: self._view = value @classmethod - def from_response(cls, resp, ns, workbook_id="") -> Optional["CustomViewItem"]: + def from_response(cls, resp, ns, workbook_id="") -> "CustomViewItem | None": item = cls.list_from_response(resp, ns, workbook_id) if not item or len(item) == 0: return None @@ -232,5 +232,5 @@ def from_xml_element(cls, parsed_response, ns, workbook_id="") -> list["CustomVi return all_view_items -def string_to_bool(s: Optional[str]) -> bool: +def string_to_bool(s: str | None) -> bool: return (s or "").lower() == "true" diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index 7285ee609..ff62c3ae8 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -1,5 +1,4 @@ from datetime import datetime -from typing import Optional from defusedxml.ElementTree import fromstring @@ -19,22 +18,22 @@ class Frequency: Weekly = "Weekly" def __init__(self): - self._id: Optional[str] = None - self._subject: Optional[str] = None - self._creatorId: Optional[str] = None - self._createdAt: Optional[datetime] = None - self._updatedAt: Optional[datetime] = None - self._frequency: Optional[str] = None - self._public: Optional[bool] = None - self._owner_id: Optional[str] = None - self._owner_name: Optional[str] = None - self._view_id: Optional[str] = None - self._view_name: Optional[str] = None - self._workbook_id: Optional[str] = None - self._workbook_name: Optional[str] = None - self._project_id: Optional[str] = None - self._project_name: Optional[str] = None - self._recipients: Optional[list[str]] = None + self._id: str | None = None + self._subject: str | None = None + self._creatorId: str | None = None + self._createdAt: datetime | None = None + self._updatedAt: datetime | None = None + self._frequency: str | None = None + self._public: bool | None = None + self._owner_id: str | None = None + self._owner_name: str | None = None + self._view_id: str | None = None + self._view_name: str | None = None + self._workbook_id: str | None = None + self._workbook_name: str | None = None + self._project_id: str | None = None + self._project_name: str | None = None + self._recipients: list[str] | None = None def __repr__(self) -> str: return " str: ) @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @property - def subject(self) -> Optional[str]: + def subject(self) -> str | None: return self._subject @subject.setter @@ -56,7 +55,7 @@ def subject(self, value): self._subject = value @property - def frequency(self) -> Optional[str]: + def frequency(self) -> str | None: return self._frequency @frequency.setter @@ -65,7 +64,7 @@ def frequency(self, value: str) -> None: self._frequency = value @property - def public(self) -> Optional[bool]: + def public(self) -> bool | None: return self._public @public.setter @@ -74,7 +73,7 @@ def public(self, value: bool) -> None: self._public = value @property - def creatorId(self) -> Optional[str]: + def creatorId(self) -> str | None: return self._creatorId @property @@ -82,43 +81,43 @@ def recipients(self) -> list[str]: return self._recipients or list() @property - def createdAt(self) -> Optional[datetime]: + def createdAt(self) -> datetime | None: return self._createdAt @property - def updatedAt(self) -> Optional[datetime]: + def updatedAt(self) -> datetime | None: return self._updatedAt @property - def owner_id(self) -> Optional[str]: + def owner_id(self) -> str | None: return self._owner_id @property - def owner_name(self) -> Optional[str]: + def owner_name(self) -> str | None: return self._owner_name @property - def view_id(self) -> Optional[str]: + def view_id(self) -> str | None: return self._view_id @property - def view_name(self) -> Optional[str]: + def view_name(self) -> str | None: return self._view_name @property - def workbook_id(self) -> Optional[str]: + def workbook_id(self) -> str | None: return self._workbook_id @property - def workbook_name(self) -> Optional[str]: + def workbook_name(self) -> str | None: return self._workbook_name @property - def project_id(self) -> Optional[str]: + def project_id(self) -> str | None: return self._project_id @property - def project_name(self) -> Optional[str]: + def project_name(self) -> str | None: return self._project_name def _set_values( diff --git a/tableauserverclient/models/data_freshness_policy_item.py b/tableauserverclient/models/data_freshness_policy_item.py index 209883e8c..439d53caa 100644 --- a/tableauserverclient/models/data_freshness_policy_item.py +++ b/tableauserverclient/models/data_freshness_policy_item.py @@ -1,6 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Optional from tableauserverclient.models.property_decorators import property_is_enum, property_not_nullable from .interval_item import IntervalItem @@ -50,11 +49,11 @@ class Frequency: Week = "Week" Month = "Month" - def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[list[str]] = None): + def __init__(self, frequency: str, time: str, timezone, interval_item: list[str] | None = None): self.frequency = frequency self.time = time self.timezone = timezone - self.interval_item: Optional[list[str]] = interval_item + self.interval_item: list[str] | None = interval_item def __repr__(self): return ( @@ -62,11 +61,11 @@ def __repr__(self): ).format(**vars(self)) @property - def interval_item(self) -> Optional[list[str]]: + def interval_item(self) -> list[str] | None: return self._interval_item @interval_item.setter - def interval_item(self, value: Optional[list[str]]): + def interval_item(self, value: list[str] | None): self._interval_item = value @property @@ -107,8 +106,8 @@ def from_xml_element(cls, fresh_at_schedule_elem: ET.Element, ns): def __init__(self, option: str): self.option = option - self.fresh_every_schedule: Optional[DataFreshnessPolicyItem.FreshEvery] = None - self.fresh_at_schedule: Optional[DataFreshnessPolicyItem.FreshAt] = None + self.fresh_every_schedule: DataFreshnessPolicyItem.FreshEvery | None = None + self.fresh_at_schedule: DataFreshnessPolicyItem.FreshAt | None = None def __repr__(self): return "".format(**vars(self)) @@ -123,19 +122,19 @@ def option(self, value: str): self._option = value @property - def fresh_every_schedule(self) -> Optional[FreshEvery]: + def fresh_every_schedule(self) -> FreshEvery | None: return self._fresh_every_schedule @fresh_every_schedule.setter - def fresh_every_schedule(self, value: Optional[FreshEvery]): + def fresh_every_schedule(self, value: FreshEvery | None): self._fresh_every_schedule = value @property - def fresh_at_schedule(self) -> Optional[FreshAt]: + def fresh_at_schedule(self) -> FreshAt | None: return self._fresh_at_schedule @fresh_at_schedule.setter - def fresh_at_schedule(self, value: Optional[FreshAt]): + def fresh_at_schedule(self, value: FreshAt | None): self._fresh_at_schedule = value @classmethod diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index f03a86355..6ed1fbdd4 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -1,7 +1,6 @@ import copy import datetime import xml.etree.ElementTree as ET -from typing import Optional from defusedxml.ElementTree import fromstring @@ -27,15 +26,15 @@ class DatasourceItem: Parameters ---------- - project_id : Optional[str] + project_id : str | None The project ID that the datasource belongs to. - name : Optional[str] + name : str | None The name of the datasource. Attributes ---------- - ask_data_enablement : Optional[str] + ask_data_enablement : str | None Determines if a data source allows use of Ask Data. The value can be TSC.DatasourceItem.AskDataEnablement.Enabled, TSC.DatasourceItem.AskDataEnablement.Disabled, or @@ -43,7 +42,7 @@ class DatasourceItem: specified, it will default to SiteDefault. See REST API Publish Datasource for more information about ask_data_enablement. - connected_workbooks_count : Optional[int] + connected_workbooks_count : int | None The number of workbooks connected to the datasource. connections : list[ConnectionItem] @@ -51,82 +50,82 @@ class DatasourceItem: source. You must first call the populate_connections method to access this data. See the ConnectionItem class. - content_url : Optional[str] + content_url : str | None The name of the data source as it would appear in a URL. - created_at : Optional[datetime.datetime] + created_at : datetime.datetime | None The time the data source was created. - certified : Optional[bool] + certified : bool | None A Boolean value that indicates whether the data source is certified. - certification_note : Optional[str] + certification_note : str | None The optional note that describes the certified data source. - datasource_type : Optional[str] + datasource_type : str | None The type of data source, for example, sqlserver or excel-direct. - description : Optional[str] + description : str | None The description for the data source. - encrypt_extracts : Optional[bool] + encrypt_extracts : bool | None A Boolean value to determine if a datasource should be encrypted or not. See Extract and Encryption Methods for more information. - favorites_total : Optional[int] + favorites_total : int | None The number of users who have marked the data source as a favorite. - has_alert : Optional[bool] + has_alert : bool | None A Boolean value that indicates whether the data source has an alert. - has_extracts : Optional[bool] + has_extracts : bool | None A Boolean value that indicates whether the datasource has extracts. - id : Optional[str] + id : str | None The identifier for the data source. You need this value to query a specific data source or to delete a data source with the get_by_id and delete methods. - is_published : Optional[bool] + is_published : bool | None A Boolean value that indicates whether the data source is published. - name : Optional[str] + name : str | None The name of the data source. If not specified, the name of the published data source file is used. - owner: Optional[UserItem] + owner: UserItem | None The owner of the data source. - owner_id : Optional[str] + owner_id : str | None The identifier of the owner of the data source. - project : Optional[ProjectItem] + project : ProjectItem | None The project that the data source belongs to. - project_id : Optional[str] + project_id : str | None The identifier of the project associated with the data source. You must provide this identifier when you create an instance of a DatasourceItem. - project_name : Optional[str] + project_name : str | None The name of the project associated with the data source. - server_name : Optional[str] + server_name : str | None The name of the server where the data source is published. - tags : Optional[set[str]] + tags : set[str] | None The tags (list of strings) that have been added to the data source. - updated_at : Optional[datetime.datetime] + updated_at : datetime.datetime | None The date and time when the data source was last updated. - use_remote_query_agent : Optional[bool] + use_remote_query_agent : bool | None A Boolean value that indicates whether to allow or disallow your Tableau Cloud site to use Tableau Bridge clients. Bridge allows you to maintain data sources with live connections to supported on-premises data sources. See Configure and Manage the Bridge Client Pool for more information. - webpage_url : Optional[str] + webpage_url : str | None The url of the datasource as displayed in browsers. """ @@ -143,37 +142,37 @@ def __repr__(self): self.project_id, ) - def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) -> None: - self._ask_data_enablement: Optional[str] = None - self._certified: Optional[bool] = None - self._certification_note: Optional[str] = None - self._connections: Optional[list[ConnectionItem]] = None - self._content_url: Optional[str] = None - self._created_at: Optional[datetime.datetime] = None - self._datasource_type: Optional[str] = None - self._description: Optional[str] = None - self._encrypt_extracts: Optional[bool] = None - self._has_extracts: Optional[bool] = None - self._id: Optional[str] = None + def __init__(self, project_id: str | None = None, name: str | None = None) -> None: + self._ask_data_enablement: str | None = None + self._certified: bool | None = None + self._certification_note: str | None = None + self._connections: list[ConnectionItem] | None = None + self._content_url: str | None = None + self._created_at: datetime.datetime | None = None + self._datasource_type: str | None = None + self._description: str | None = None + self._encrypt_extracts: bool | None = None + self._has_extracts: bool | None = None + self._id: str | None = None self._initial_tags: set = set() - self._project_name: Optional[str] = None + self._project_name: str | None = None self._revisions = None - self._size: Optional[int] = None - self._updated_at: Optional[datetime.datetime] = None - self._use_remote_query_agent: Optional[bool] = None - self._webpage_url: Optional[str] = None - self.description: Optional[str] = None - self.name: Optional[str] = name - self.owner_id: Optional[str] = None - self.project_id: Optional[str] = project_id + self._size: int | None = None + self._updated_at: datetime.datetime | None = None + self._use_remote_query_agent: bool | None = None + self._webpage_url: str | None = None + self.description: str | None = None + self.name: str | None = name + self.owner_id: str | None = None + self.project_id: str | None = project_id self.tags: set[str] = set() - self._connected_workbooks_count: Optional[int] = None - self._favorites_total: Optional[int] = None - self._has_alert: Optional[bool] = None - self._is_published: Optional[bool] = None - self._server_name: Optional[str] = None - self._project: Optional[ProjectItem] = None - self._owner: Optional[UserItem] = None + self._connected_workbooks_count: int | None = None + self._favorites_total: int | None = None + self._has_alert: bool | None = None + self._is_published: bool | None = None + self._server_name: str | None = None + self._project: ProjectItem | None = None + self._owner: UserItem | None = None self._permissions = None self._data_quality_warnings = None @@ -181,12 +180,12 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) return None @property - def ask_data_enablement(self) -> Optional[str]: + def ask_data_enablement(self) -> str | None: return self._ask_data_enablement @ask_data_enablement.setter @property_is_enum(AskDataEnablement) - def ask_data_enablement(self, value: Optional[str]): + def ask_data_enablement(self, value: str | None): self._ask_data_enablement = value @property @@ -197,45 +196,45 @@ def connections(self): return self._connections() @property - def permissions(self) -> Optional[list[PermissionsRule]]: + def permissions(self) -> list[PermissionsRule] | None: if self._permissions is None: error = "Project item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() @property - def content_url(self) -> Optional[str]: + def content_url(self) -> str | None: return self._content_url @property - def created_at(self) -> Optional[datetime.datetime]: + def created_at(self) -> datetime.datetime | None: return self._created_at @property - def certified(self) -> Optional[bool]: + def certified(self) -> bool | None: return self._certified @certified.setter @property_not_nullable @property_is_boolean - def certified(self, value: Optional[bool]): + def certified(self, value: bool | None): self._certified = value @property - def certification_note(self) -> Optional[str]: + def certification_note(self) -> str | None: return self._certification_note @certification_note.setter - def certification_note(self, value: Optional[str]): + def certification_note(self, value: str | None): self._certification_note = value @property - def encrypt_extracts(self) -> Optional[bool]: + def encrypt_extracts(self) -> bool | None: return self._encrypt_extracts @encrypt_extracts.setter @property_is_boolean - def encrypt_extracts(self, value: Optional[bool]): + def encrypt_extracts(self, value: bool | None): self._encrypt_extracts = value @property @@ -246,43 +245,43 @@ def dqws(self): return self._data_quality_warnings() @property - def has_extracts(self) -> Optional[bool]: + def has_extracts(self) -> bool | None: return self._has_extracts @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @property - def project_id(self) -> Optional[str]: + def project_id(self) -> str | None: return self._project_id @project_id.setter - def project_id(self, value: Optional[str]): + def project_id(self, value: str | None): self._project_id = value @property - def project_name(self) -> Optional[str]: + def project_name(self) -> str | None: return self._project_name @property - def datasource_type(self) -> Optional[str]: + def datasource_type(self) -> str | None: return self._datasource_type @property - def description(self) -> Optional[str]: + def description(self) -> str | None: return self._description @description.setter - def description(self, value: Optional[str]): + def description(self, value: str | None): self._description = value @property - def updated_at(self) -> Optional[datetime.datetime]: + def updated_at(self) -> datetime.datetime | None: return self._updated_at @property - def use_remote_query_agent(self) -> Optional[bool]: + def use_remote_query_agent(self) -> bool | None: return self._use_remote_query_agent @use_remote_query_agent.setter @@ -291,7 +290,7 @@ def use_remote_query_agent(self, value: bool): self._use_remote_query_agent = value @property - def webpage_url(self) -> Optional[str]: + def webpage_url(self) -> str | None: return self._webpage_url @property @@ -302,35 +301,35 @@ def revisions(self) -> list[RevisionItem]: return self._revisions() @property - def size(self) -> Optional[int]: + def size(self) -> int | None: return self._size @property - def connected_workbooks_count(self) -> Optional[int]: + def connected_workbooks_count(self) -> int | None: return self._connected_workbooks_count @property - def favorites_total(self) -> Optional[int]: + def favorites_total(self) -> int | None: return self._favorites_total @property - def has_alert(self) -> Optional[bool]: + def has_alert(self) -> bool | None: return self._has_alert @property - def is_published(self) -> Optional[bool]: + def is_published(self) -> bool | None: return self._is_published @property - def server_name(self) -> Optional[str]: + def server_name(self) -> str | None: return self._server_name @property - def project(self) -> Optional[ProjectItem]: + def project(self) -> ProjectItem | None: return self._project @property - def owner(self) -> Optional[UserItem]: + def owner(self) -> UserItem | None: return self._owner def _set_connections(self, connections) -> None: diff --git a/tableauserverclient/models/extract_item.py b/tableauserverclient/models/extract_item.py index 7562ffdde..d8a149ec4 100644 --- a/tableauserverclient/models/extract_item.py +++ b/tableauserverclient/models/extract_item.py @@ -1,4 +1,3 @@ -from typing import Optional, List from defusedxml.ElementTree import fromstring import xml.etree.ElementTree as ET @@ -22,16 +21,16 @@ class ExtractItem: """ def __init__( - self, priority: int, refresh_type: str, workbook_id: Optional[str] = None, datasource_id: Optional[str] = None + self, priority: int, refresh_type: str, workbook_id: str | None = None, datasource_id: str | None = None ): - self._id: Optional[str] = None + self._id: str | None = None self._priority = priority self._type = refresh_type self._workbook_id = workbook_id self._datasource_id = datasource_id @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @property @@ -43,21 +42,21 @@ def type(self) -> str: return self._type @property - def workbook_id(self) -> Optional[str]: + def workbook_id(self) -> str | None: return self._workbook_id @property - def datasource_id(self) -> Optional[str]: + def datasource_id(self) -> str | None: return self._datasource_id @classmethod - def from_response(cls, resp: str, ns: dict) -> List["ExtractItem"]: + def from_response(cls, resp: str, ns: dict) -> list["ExtractItem"]: """Create ExtractItem objects from XML response.""" parsed_response = fromstring(resp) return cls.from_xml_element(parsed_response, ns) @classmethod - def from_xml_element(cls, parsed_response: ET.Element, ns: dict) -> List["ExtractItem"]: + def from_xml_element(cls, parsed_response: ET.Element, ns: dict) -> list["ExtractItem"]: """Create ExtractItem objects from XML element.""" all_extract_items = [] all_extract_xml = parsed_response.findall(".//t:extract", namespaces=ns) diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index 1189efc31..b13997d99 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -25,7 +25,7 @@ class FavoriteType(TypedDict): class FavoriteItem: @classmethod - def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType: + def from_response(cls, xml: str | bytes, namespace: dict) -> FavoriteType: favorites: FavoriteType = { "collections": [], "datasources": [], diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index 0aed3d257..ede80915c 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -1,7 +1,7 @@ +from collections.abc import Iterable import copy import datetime import xml.etree.ElementTree as ET -from typing import Iterable, Optional from defusedxml.ElementTree import fromstring @@ -23,7 +23,7 @@ class FlowItem: project_id: str The ID of the project that the flow belongs to. - name: Optional[str] + name: str | None The name of the flow. Attributes @@ -33,10 +33,10 @@ class FlowItem: by default and must be populated by calling the `populate_connections` method. - created_at: Optional[datetime.datetime] + created_at: datetime.datetime | None The date and time when the flow was created. - description: Optional[str] + description: str | None The description of the flow. dqws: Iterable[DQWItem] @@ -44,16 +44,16 @@ class FlowItem: populated by default and must be populated by calling the `populate_dqws` method. - id: Optional[str] + id: str | None The ID of the flow. - name: Optional[str] + name: str | None The name of the flow. - owner_id: Optional[str] + owner_id: str | None The ID of the user who owns the flow. - project_name: Optional[str] + project_name: str | None The name of the project that the flow belongs to. tags: set[str] @@ -65,22 +65,22 @@ def __repr__(self): self._id, self.name, self.description, self.project_id, self.created_at ) - def __init__(self, project_id: str, name: Optional[str] = None) -> None: - self._webpage_url: Optional[str] = None - self._created_at: Optional[datetime.datetime] = None - self._id: Optional[str] = None + def __init__(self, project_id: str, name: str | None = None) -> None: + self._webpage_url: str | None = None + self._created_at: datetime.datetime | None = None + self._id: str | None = None self._initial_tags: set[str] = set() - self._project_name: Optional[str] = None - self._updated_at: Optional[datetime.datetime] = None - self.name: Optional[str] = name - self.owner_id: Optional[str] = None + self._project_name: str | None = None + self._updated_at: datetime.datetime | None = None + self.name: str | None = name + self.owner_id: str | None = None self.project_id: str = project_id self.tags: set[str] = set() - self.description: Optional[str] = None + self.description: str | None = None - self._connections: Optional[Iterable[ConnectionItem]] = None - self._permissions: Optional[Iterable[Permission]] = None - self._data_quality_warnings: Optional[Iterable[DQWItem]] = None + self._connections: Iterable[ConnectionItem] | None = None + self._permissions: Iterable[Permission] | None = None + self._data_quality_warnings: Iterable[DQWItem] | None = None @property def connections(self): @@ -97,11 +97,11 @@ def permissions(self): return self._permissions() @property - def webpage_url(self) -> Optional[str]: + def webpage_url(self) -> str | None: return self._webpage_url @property - def created_at(self) -> Optional[datetime.datetime]: + def created_at(self) -> datetime.datetime | None: return self._created_at @property @@ -112,7 +112,7 @@ def dqws(self): return self._data_quality_warnings() @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @property @@ -125,19 +125,19 @@ def project_id(self, value: str) -> None: self._project_id = value @property - def description(self) -> Optional[str]: + def description(self) -> str | None: return self._description @description.setter - def description(self, value: Optional[str]) -> None: + def description(self, value: str | None) -> None: self._description = value @property - def project_name(self) -> Optional[str]: + def project_name(self) -> str | None: return self._project_name @property - def updated_at(self) -> Optional[datetime.datetime]: + def updated_at(self) -> datetime.datetime | None: return self._updated_at def _set_connections(self, connections): diff --git a/tableauserverclient/models/flow_run_item.py b/tableauserverclient/models/flow_run_item.py index f2f1d561f..790d435a9 100644 --- a/tableauserverclient/models/flow_run_item.py +++ b/tableauserverclient/models/flow_run_item.py @@ -1,6 +1,5 @@ import itertools from datetime import datetime -from typing import Optional from defusedxml.ElementTree import fromstring @@ -10,39 +9,39 @@ class FlowRunItem: def __init__(self) -> None: self._id: str = "" - self._flow_id: Optional[str] = None - self._status: Optional[str] = None - self._started_at: Optional[datetime] = None - self._completed_at: Optional[datetime] = None - self._progress: Optional[str] = None - self._background_job_id: Optional[str] = None + self._flow_id: str | None = None + self._status: str | None = None + self._started_at: datetime | None = None + self._completed_at: datetime | None = None + self._progress: str | None = None + self._background_job_id: str | None = None @property def id(self) -> str: return self._id @property - def flow_id(self) -> Optional[str]: + def flow_id(self) -> str | None: return self._flow_id @property - def status(self) -> Optional[str]: + def status(self) -> str | None: return self._status @property - def started_at(self) -> Optional[datetime]: + def started_at(self) -> datetime | None: return self._started_at @property - def completed_at(self) -> Optional[datetime]: + def completed_at(self) -> datetime | None: return self._completed_at @property - def progress(self) -> Optional[str]: + def progress(self) -> str | None: return self._progress @property - def background_job_id(self) -> Optional[str]: + def background_job_id(self) -> str | None: return self._background_job_id def _set_values( @@ -71,7 +70,7 @@ def _set_values( self._background_job_id = background_job_id @classmethod - def from_response(cls: type["FlowRunItem"], resp: bytes, ns: Optional[dict]) -> list["FlowRunItem"]: + def from_response(cls: type["FlowRunItem"], resp: bytes, ns: dict | None) -> list["FlowRunItem"]: all_flowrun_items = list() parsed_response = fromstring(resp) all_flowrun_xml = itertools.chain( diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py index c368b51f7..03b9bc0fa 100644 --- a/tableauserverclient/models/group_item.py +++ b/tableauserverclient/models/group_item.py @@ -1,4 +1,4 @@ -from typing import Callable, Optional, TYPE_CHECKING +from typing import Callable, TYPE_CHECKING from defusedxml.ElementTree import fromstring from typing_extensions import Self @@ -47,7 +47,7 @@ class GroupItem: Attributes ---------- - user_count: Optional[int] + user_count: int | None The number of users in the group. Examples @@ -65,19 +65,19 @@ class LicenseMode: onSync: str = "onSync" def __init__(self, name=None, domain_name=None) -> None: - self._id: Optional[str] = None - self._license_mode: Optional[str] = None - self._minimum_site_role: Optional[str] = None - self._users: Optional[Callable[..., "Pager"]] = None - self.name: Optional[str] = name - self.domain_name: Optional[str] = domain_name - self._user_count: Optional[int] = None + self._id: str | None = None + self._license_mode: str | None = None + self._minimum_site_role: str | None = None + self._users: Callable[..., "Pager"] | None = None + self.name: str | None = name + self.domain_name: str | None = domain_name + self._user_count: int | None = None def __repr__(self): return f"{self.__class__.__name__}({self.__dict__!r})" @property - def domain_name(self) -> Optional[str]: + def domain_name(self) -> str | None: return self._domain_name @domain_name.setter @@ -85,19 +85,19 @@ def domain_name(self, value: str) -> None: self._domain_name = value @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @property - def name(self) -> Optional[str]: + def name(self) -> str | None: return self._name @name.setter - def name(self, value: Optional[str]) -> None: + def name(self, value: str | None) -> None: self._name = value @property - def license_mode(self) -> Optional[str]: + def license_mode(self) -> str | None: return self._license_mode @license_mode.setter @@ -106,7 +106,7 @@ def license_mode(self, value: str) -> None: self._license_mode = value @property - def minimum_site_role(self) -> Optional[str]: + def minimum_site_role(self) -> str | None: return self._minimum_site_role @minimum_site_role.setter @@ -126,7 +126,7 @@ def _set_users(self, users: Callable[..., "Pager"]) -> None: self._users = users @property - def user_count(self) -> Optional[int]: + def user_count(self) -> int | None: return self._user_count @classmethod diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py index ad00504cd..da1e88f8d 100644 --- a/tableauserverclient/models/groupset_item.py +++ b/tableauserverclient/models/groupset_item.py @@ -1,4 +1,3 @@ -from typing import Optional import xml.etree.ElementTree as ET from defusedxml.ElementTree import fromstring @@ -11,9 +10,9 @@ class GroupSetItem: tag_name: str = "groupSet" - def __init__(self, name: Optional[str] = None) -> None: + def __init__(self, name: str | None = None) -> None: self.name = name - self.id: Optional[str] = None + self.id: str | None = None self.groups: list["GroupItem"] = [] self.group_count: int = 0 @@ -26,11 +25,11 @@ def __repr__(self) -> str: return self.__str__() @property - def name(self) -> Optional[str]: + def name(self) -> str | None: return self._name @name.setter - def name(self, value: Optional[str]) -> None: + def name(self, value: str | None) -> None: self._name = value @classmethod diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index d650eb846..f684c22d4 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -1,5 +1,4 @@ import datetime -from typing import Optional from defusedxml.ElementTree import fromstring @@ -40,36 +39,36 @@ class JobItem: created_at : datetime.datetime The date and time the job was created. - started_at : Optional[datetime.datetime] + started_at : datetime.datetime | None The date and time the job was started. - completed_at : Optional[datetime.datetime] + completed_at : datetime.datetime | None The date and time the job was completed. finish_code : int The finish code of the job. 0 for success, 1 for failure, 2 for cancelled. - notes : Optional[list[str]] + notes : list[str] | None Contains detailed notes about the job. - mode : Optional[str] + mode : str | None - workbook_id : Optional[str] + workbook_id : str | None The identifier of the workbook associated with the job. - datasource_id : Optional[str] + datasource_id : str | None The identifier of the datasource associated with the job. - flow_run : Optional[FlowRunItem] + flow_run : FlowRunItem | None The flow run associated with the job. - updated_at : Optional[datetime.datetime] + updated_at : datetime.datetime | None The date and time the job was last updated. - workbook_name : Optional[str] + workbook_name : str | None The name of the workbook associated with the job. - datasource_name : Optional[str] + datasource_name : str | None The name of the datasource associated with the job. """ @@ -90,17 +89,17 @@ def __init__( job_type: str, progress: str, created_at: datetime.datetime, - started_at: Optional[datetime.datetime] = None, - completed_at: Optional[datetime.datetime] = None, + started_at: datetime.datetime | None = None, + completed_at: datetime.datetime | None = None, finish_code: int = 0, - notes: Optional[list[str]] = None, - mode: Optional[str] = None, - workbook_id: Optional[str] = None, - datasource_id: Optional[str] = None, - flow_run: Optional[FlowRunItem] = None, - updated_at: Optional[datetime.datetime] = None, - workbook_name: Optional[str] = None, - datasource_name: Optional[str] = None, + notes: list[str] | None = None, + mode: str | None = None, + workbook_id: str | None = None, + datasource_id: str | None = None, + flow_run: FlowRunItem | None = None, + updated_at: datetime.datetime | None = None, + workbook_name: str | None = None, + datasource_name: str | None = None, ): self._id = id_ self._type = job_type @@ -135,11 +134,11 @@ def created_at(self) -> datetime.datetime: return self._created_at @property - def started_at(self) -> Optional[datetime.datetime]: + def started_at(self) -> datetime.datetime | None: return self._started_at @property - def completed_at(self) -> Optional[datetime.datetime]: + def completed_at(self) -> datetime.datetime | None: return self._completed_at @property @@ -151,7 +150,7 @@ def notes(self) -> list[str]: return self._notes @property - def mode(self) -> Optional[str]: + def mode(self) -> str | None: return self._mode @mode.setter @@ -160,19 +159,19 @@ def mode(self, value: str) -> None: self._mode = value @property - def workbook_id(self) -> Optional[str]: + def workbook_id(self) -> str | None: return self._workbook_id @workbook_id.setter - def workbook_id(self, value: Optional[str]) -> None: + def workbook_id(self, value: str | None) -> None: self._workbook_id = value @property - def datasource_id(self) -> Optional[str]: + def datasource_id(self) -> str | None: return self._datasource_id @datasource_id.setter - def datasource_id(self, value: Optional[str]) -> None: + def datasource_id(self, value: str | None) -> None: self._datasource_id = value @property @@ -184,15 +183,15 @@ def flow_run(self, value): self._flow_run = value @property - def updated_at(self) -> Optional[datetime.datetime]: + def updated_at(self) -> datetime.datetime | None: return self._updated_at @property - def workbook_name(self) -> Optional[str]: + def workbook_name(self) -> str | None: return self._workbook_name @property - def datasource_name(self) -> Optional[str]: + def datasource_name(self) -> str | None: return self._datasource_name def __str__(self): @@ -272,10 +271,10 @@ def __init__( priority: int, job_type: str, status: str, - title: Optional[str] = None, - subtitle: Optional[str] = None, - started_at: Optional[datetime.datetime] = None, - ended_at: Optional[datetime.datetime] = None, + title: str | None = None, + subtitle: str | None = None, + started_at: datetime.datetime | None = None, + ended_at: datetime.datetime | None = None, ): self._id = id_ self._type = job_type @@ -298,7 +297,7 @@ def id(self) -> str: return self._id @property - def name(self) -> Optional[str]: + def name(self) -> str | None: """For API consistency - all other resource endpoints have a name attribute which is used to display what they are. Alias title as name to allow consistent handling of resources in the list sample.""" return self._title @@ -316,19 +315,19 @@ def created_at(self) -> datetime.datetime: return self._created_at @property - def started_at(self) -> Optional[datetime.datetime]: + def started_at(self) -> datetime.datetime | None: return self._started_at @property - def ended_at(self) -> Optional[datetime.datetime]: + def ended_at(self) -> datetime.datetime | None: return self._ended_at @property - def title(self) -> Optional[str]: + def title(self) -> str | None: return self._title @property - def subtitle(self) -> Optional[str]: + def subtitle(self) -> str | None: return self._subtitle @property diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py index 14a0e4978..f6fbeb1a9 100644 --- a/tableauserverclient/models/linked_tasks_item.py +++ b/tableauserverclient/models/linked_tasks_item.py @@ -1,5 +1,4 @@ import datetime as dt -from typing import Optional from defusedxml.ElementTree import fromstring @@ -9,9 +8,9 @@ class LinkedTaskItem: def __init__(self) -> None: - self.id: Optional[str] = None - self.num_steps: Optional[int] = None - self.schedule: Optional[ScheduleItem] = None + self.id: str | None = None + self.num_steps: int | None = None + self.schedule: ScheduleItem | None = None @classmethod def from_response(cls, resp: bytes, namespace) -> list["LinkedTaskItem"]: @@ -32,9 +31,9 @@ def _parse_element(cls, xml, namespace) -> "LinkedTaskItem": class LinkedTaskStepItem: def __init__(self) -> None: - self.id: Optional[str] = None - self.step_number: Optional[int] = None - self.stop_downstream_on_failure: Optional[bool] = None + self.id: str | None = None + self.step_number: int | None = None + self.stop_downstream_on_failure: bool | None = None self.task_details: list[LinkedTaskFlowRunItem] = [] @classmethod @@ -53,12 +52,12 @@ def _parse_element(cls, xml, namespace) -> "LinkedTaskStepItem": class LinkedTaskFlowRunItem: def __init__(self) -> None: - self.flow_run_id: Optional[str] = None - self.flow_run_priority: Optional[int] = None - self.flow_run_consecutive_failed_count: Optional[int] = None - self.flow_run_task_type: Optional[str] = None - self.flow_id: Optional[str] = None - self.flow_name: Optional[str] = None + self.flow_run_id: str | None = None + self.flow_run_priority: int | None = None + self.flow_run_consecutive_failed_count: int | None = None + self.flow_run_task_type: str | None = None + self.flow_id: str | None = None + self.flow_name: str | None = None @classmethod def _parse_element(cls, xml, namespace) -> list["LinkedTaskFlowRunItem"]: @@ -79,10 +78,10 @@ def _parse_element(cls, xml, namespace) -> list["LinkedTaskFlowRunItem"]: class LinkedTaskJobItem: def __init__(self) -> None: - self.id: Optional[str] = None - self.linked_task_id: Optional[str] = None - self.status: Optional[str] = None - self.created_at: Optional[dt.datetime] = None + self.id: str | None = None + self.linked_task_id: str | None = None + self.status: str | None = None + self.created_at: dt.datetime | None = None @classmethod def from_response(cls, resp: bytes, namespace) -> "LinkedTaskJobItem": diff --git a/tableauserverclient/models/location_item.py b/tableauserverclient/models/location_item.py index fa7c2ff2c..e1ecd0039 100644 --- a/tableauserverclient/models/location_item.py +++ b/tableauserverclient/models/location_item.py @@ -1,4 +1,3 @@ -from typing import Optional import xml.etree.ElementTree as ET @@ -23,27 +22,27 @@ class Type: Project = "Project" def __init__(self): - self._id: Optional[str] = None - self._type: Optional[str] = None - self._name: Optional[str] = None + self._id: str | None = None + self._type: str | None = None + self._name: str | None = None def __repr__(self): return f"{self.__class__.__name__}({self.__dict__!r})" @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @property - def type(self) -> Optional[str]: + def type(self) -> str | None: return self._type @property - def name(self) -> Optional[str]: + def name(self) -> str | None: return self._name @classmethod - def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "LocationItem": + def from_xml(cls, xml: ET.Element, ns: dict | None = None) -> "LocationItem": if ns is None: ns = {} location = cls() diff --git a/tableauserverclient/models/metric_item.py b/tableauserverclient/models/metric_item.py index 432fd861a..287ebc8a1 100644 --- a/tableauserverclient/models/metric_item.py +++ b/tableauserverclient/models/metric_item.py @@ -1,6 +1,5 @@ import xml.etree.ElementTree as ET from datetime import datetime -from typing import Optional from tableauserverclient.datetime_helpers import parse_datetime from .property_decorators import property_is_boolean, property_is_datetime @@ -9,52 +8,52 @@ class MetricItem: - def __init__(self, name: Optional[str] = None): - self._id: Optional[str] = None - self._name: Optional[str] = name - self._description: Optional[str] = None - self._webpage_url: Optional[str] = None - self._created_at: Optional[datetime] = None - self._updated_at: Optional[datetime] = None - self._suspended: Optional[bool] = None - self._project_id: Optional[str] = None - self._project_name: Optional[str] = None - self._owner_id: Optional[str] = None - self._view_id: Optional[str] = None + def __init__(self, name: str | None = None): + self._id: str | None = None + self._name: str | None = name + self._description: str | None = None + self._webpage_url: str | None = None + self._created_at: datetime | None = None + self._updated_at: datetime | None = None + self._suspended: bool | None = None + self._project_id: str | None = None + self._project_name: str | None = None + self._owner_id: str | None = None + self._view_id: str | None = None self._initial_tags: set[str] = set() self.tags: set[str] = set() - self._permissions: Optional[Permission] = None + self._permissions: Permission | None = None @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @id.setter - def id(self, value: Optional[str]) -> None: + def id(self, value: str | None) -> None: self._id = value @property - def name(self) -> Optional[str]: + def name(self) -> str | None: return self._name @name.setter - def name(self, value: Optional[str]) -> None: + def name(self, value: str | None) -> None: self._name = value @property - def description(self) -> Optional[str]: + def description(self) -> str | None: return self._description @description.setter - def description(self, value: Optional[str]) -> None: + def description(self, value: str | None) -> None: self._description = value @property - def webpage_url(self) -> Optional[str]: + def webpage_url(self) -> str | None: return self._webpage_url @property - def created_at(self) -> Optional[datetime]: + def created_at(self) -> datetime | None: return self._created_at @created_at.setter @@ -63,7 +62,7 @@ def created_at(self, value: "datetime") -> None: self._created_at = value @property - def updated_at(self) -> Optional[datetime]: + def updated_at(self) -> datetime | None: return self._updated_at @updated_at.setter @@ -72,7 +71,7 @@ def updated_at(self, value: "datetime") -> None: self._updated_at = value @property - def suspended(self) -> Optional[bool]: + def suspended(self) -> bool | None: return self._suspended @suspended.setter @@ -81,35 +80,35 @@ def suspended(self, value: bool) -> None: self._suspended = value @property - def project_id(self) -> Optional[str]: + def project_id(self) -> str | None: return self._project_id @project_id.setter - def project_id(self, value: Optional[str]) -> None: + def project_id(self, value: str | None) -> None: self._project_id = value @property - def project_name(self) -> Optional[str]: + def project_name(self) -> str | None: return self._project_name @project_name.setter - def project_name(self, value: Optional[str]) -> None: + def project_name(self, value: str | None) -> None: self._project_name = value @property - def owner_id(self) -> Optional[str]: + def owner_id(self) -> str | None: return self._owner_id @owner_id.setter - def owner_id(self, value: Optional[str]) -> None: + def owner_id(self, value: str | None) -> None: self._owner_id = value @property - def view_id(self) -> Optional[str]: + def view_id(self) -> str | None: return self._view_id @view_id.setter - def view_id(self, value: Optional[str]) -> None: + def view_id(self, value: str | None) -> None: self._view_id = value def _set_permissions(self, permissions): diff --git a/tableauserverclient/models/oidc_item.py b/tableauserverclient/models/oidc_item.py index 6e9626ed1..7c09eb513 100644 --- a/tableauserverclient/models/oidc_item.py +++ b/tableauserverclient/models/oidc_item.py @@ -1,32 +1,31 @@ -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.test_login_url: str | None = None + self.known_provider_alias: str | None = 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 + self.idp_configuration_name: str | None = None + self.idp_configuration_id: str | None = None + self.client_id: str | None = None + self.client_secret: str | None = None + self.authorization_endpoint: str | None = None + self.token_endpoint: str | None = None + self.userinfo_endpoint: str | None = None + self.jwks_uri: str | None = None + self.end_session_endpoint: str | None = None + self.custom_scope: str | None = None + self.essential_acr_values: str | None = None + self.email_mapping: str | None = None + self.first_name_mapping: str | None = None + self.last_name_mapping: str | None = None + self.full_name_mapping: str | None = None + self.prompt: str | None = None + self.client_authentication: str | None = None + self.voluntary_acr_values: str | None = None def __str__(self) -> str: return ( diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index bc29234c4..3603c37c4 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -1,5 +1,4 @@ import xml.etree.ElementTree as ET -from typing import Optional from defusedxml.ElementTree import fromstring @@ -131,7 +130,7 @@ def from_response(cls, resp, ns=None) -> list["PermissionsRule"]: return rules @staticmethod - def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[dict[str, str]]) -> ResourceReference: + def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: dict[str, str] | None) -> ResourceReference: """Use Xpath magic and some string splitting to get the right object type from the xml""" # Get the first element in the tree with an 'id' attribute diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index dd90f8bfb..70adc8a76 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Optional, overload +from typing import overload from defusedxml.ElementTree import fromstring @@ -45,7 +45,7 @@ class corresponds to the project resources you can access using the Tableau id : str The unique identifier for the project. - owner: Optional[UserItem] + owner: UserItem | None The UserItem owner of the project. owner_id : str @@ -81,23 +81,23 @@ def __repr__(self): def __init__( self, - name: Optional[str] = None, - description: Optional[str] = None, - content_permissions: Optional[str] = None, - parent_id: Optional[str] = None, - samples: Optional[bool] = None, - id: Optional[str] = None, + name: str | None = None, + description: str | None = None, + content_permissions: str | None = None, + parent_id: str | None = None, + samples: bool | None = None, + id: str | None = None, ) -> None: self._content_permissions = None - self._id: Optional[str] = id - self.description: Optional[str] = description + self._id: str | None = id + self.description: str | None = description self.name: str = name - self.content_permissions: Optional[str] = content_permissions - self.parent_id: Optional[str] = parent_id - self._samples: Optional[bool] = samples - self._owner_id: Optional[str] = None - self._top_level_project: Optional[bool] = None - self._writeable: Optional[bool] = None + self.content_permissions: str | None = content_permissions + self.parent_id: str | None = parent_id + self._samples: bool | None = samples + self._owner_id: str | None = None + self._top_level_project: bool | None = None + self._writeable: bool | None = None self._permissions = None self._default_workbook_permissions = None @@ -110,12 +110,12 @@ def __init__( self._default_database_permissions = None self._default_table_permissions = None - self._project_count: Optional[int] = None - self._workbook_count: Optional[int] = None - self._view_count: Optional[int] = None - self._datasource_count: Optional[int] = None + self._project_count: int | None = None + self._workbook_count: int | None = None + self._view_count: int | None = None + self._datasource_count: int | None = None - self._owner: Optional[UserItem] = None + self._owner: UserItem | None = None @property def content_permissions(self): @@ -123,7 +123,7 @@ def content_permissions(self): @content_permissions.setter @property_is_enum(ContentPermissions) - def content_permissions(self, value: Optional[str]) -> None: + def content_permissions(self, value: str | None) -> None: self._content_permissions = value @property @@ -187,19 +187,19 @@ def default_table_permissions(self): return self._default_table_permissions() @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @property - def name(self) -> Optional[str]: + def name(self) -> str | None: return self._name @name.setter - def name(self, value: Optional[str]) -> None: + def name(self, value: str | None) -> None: self._name = value @property - def owner_id(self) -> Optional[str]: + def owner_id(self) -> str | None: return self._owner_id @owner_id.setter @@ -207,31 +207,31 @@ def owner_id(self, value: str) -> None: self._owner_id = value @property - def top_level_project(self) -> Optional[bool]: + def top_level_project(self) -> bool | None: return self._top_level_project @property - def writeable(self) -> Optional[bool]: + def writeable(self) -> bool | None: return self._writeable @property - def project_count(self) -> Optional[int]: + def project_count(self) -> int | None: return self._project_count @property - def workbook_count(self) -> Optional[int]: + def workbook_count(self) -> int | None: return self._workbook_count @property - def view_count(self) -> Optional[int]: + def view_count(self) -> int | None: return self._view_count @property - def datasource_count(self) -> Optional[int]: + def datasource_count(self) -> int | None: return self._datasource_count @property - def owner(self) -> Optional[UserItem]: + def owner(self) -> UserItem | None: return self._owner def is_default(self): @@ -292,7 +292,7 @@ def _set_default_permissions(self, permissions, content_type): ) @classmethod - def from_response(cls, resp: bytes, ns: Optional[dict]) -> list["ProjectItem"]: + def from_response(cls, resp: bytes, ns: dict | None) -> list["ProjectItem"]: all_project_items = list() parsed_response = fromstring(resp) all_project_xml = parsed_response.findall(".//t:project", namespaces=ns) @@ -303,13 +303,13 @@ def from_response(cls, resp: bytes, ns: Optional[dict]) -> list["ProjectItem"]: return all_project_items @classmethod - def from_xml(cls, project_xml: ET.Element, namespace: Optional[dict] = None) -> "ProjectItem": + def from_xml(cls, project_xml: ET.Element, namespace: dict | None = None) -> "ProjectItem": project_item = cls() project_item._set_values(*cls._parse_element(project_xml, namespace)) return project_item @staticmethod - def _parse_element(project_xml: ET.Element, namespace: Optional[dict]) -> tuple: + def _parse_element(project_xml: ET.Element, namespace: dict | None) -> tuple: id = project_xml.get("id", None) name = project_xml.get("name", None) description = project_xml.get("description", None) diff --git a/tableauserverclient/models/revision_item.py b/tableauserverclient/models/revision_item.py index 1b4cc6249..d6ecd3681 100644 --- a/tableauserverclient/models/revision_item.py +++ b/tableauserverclient/models/revision_item.py @@ -1,5 +1,4 @@ from datetime import datetime -from typing import Optional from defusedxml.ElementTree import fromstring @@ -8,45 +7,45 @@ class RevisionItem: def __init__(self): - self._resource_id: Optional[str] = None - self._resource_name: Optional[str] = None - self._revision_number: Optional[str] = None - self._current: Optional[bool] = None - self._deleted: Optional[bool] = None - self._created_at: Optional[datetime] = None - self._user_id: Optional[str] = None - self._user_name: Optional[str] = None + self._resource_id: str | None = None + self._resource_name: str | None = None + self._revision_number: str | None = None + self._current: bool | None = None + self._deleted: bool | None = None + self._created_at: datetime | None = None + self._user_id: str | None = None + self._user_name: str | None = None @property - def resource_id(self) -> Optional[str]: + def resource_id(self) -> str | None: return self._resource_id @property - def resource_name(self) -> Optional[str]: + def resource_name(self) -> str | None: return self._resource_name @property - def revision_number(self) -> Optional[str]: + def revision_number(self) -> str | None: return self._revision_number @property - def current(self) -> Optional[bool]: + def current(self) -> bool | None: return self._current @property - def deleted(self) -> Optional[bool]: + def deleted(self) -> bool | None: return self._deleted @property - def created_at(self) -> Optional[datetime]: + def created_at(self) -> datetime | None: return self._created_at @property - def user_id(self) -> Optional[str]: + def user_id(self) -> str | None: return self._user_id @property - def user_name(self) -> Optional[str]: + def user_name(self) -> str | None: return self._user_name def __repr__(self): diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 794d9883b..d15a15345 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, TYPE_CHECKING +from typing import TYPE_CHECKING from defusedxml.ElementTree import fromstring @@ -20,7 +20,7 @@ from requests import Response -Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] +Interval = HourlyInterval | DailyInterval | WeeklyInterval | MonthlyInterval class ScheduleItem: @@ -98,12 +98,12 @@ class State: Suspended = "Suspended" def __init__(self, name: str, priority: int, schedule_type: str, execution_order: str, interval_item: Interval): - self._created_at: Optional[datetime] = None - self._end_schedule_at: Optional[datetime] = None - self._id: Optional[str] = None - self._next_run_at: Optional[datetime] = None - self._state: Optional[str] = None - self._updated_at: Optional[datetime] = None + self._created_at: datetime | None = None + self._end_schedule_at: datetime | None = None + self._id: str | None = None + self._next_run_at: datetime | None = None + self._state: str | None = None + self._updated_at: datetime | None = None self.interval_item: Interval = interval_item self.execution_order: str = execution_order self.name: str = name @@ -117,11 +117,11 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @property - def created_at(self) -> Optional[datetime]: + def created_at(self) -> datetime | None: return self._created_at @property - def end_schedule_at(self) -> Optional[datetime]: + def end_schedule_at(self) -> datetime | None: return self._end_schedule_at @property @@ -134,11 +134,11 @@ def execution_order(self, value: str): self._execution_order = value @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @property - def name(self) -> Optional[str]: + def name(self) -> str | None: return self._name @name.setter @@ -146,7 +146,7 @@ def name(self, value: str): self._name = value @property - def next_run_at(self) -> Optional[datetime]: + def next_run_at(self) -> datetime | None: return self._next_run_at @property @@ -167,7 +167,7 @@ def schedule_type(self, value: str): self._schedule_type = value @property - def state(self) -> Optional[str]: + def state(self) -> str | None: return self._state @state.setter @@ -176,7 +176,7 @@ def state(self, value: str): self._state = value @property - def updated_at(self) -> Optional[datetime]: + def updated_at(self) -> datetime | None: return self._updated_at @property diff --git a/tableauserverclient/models/site_item.py b/tableauserverclient/models/site_item.py index 9cda5c898..382ca63db 100644 --- a/tableauserverclient/models/site_item.py +++ b/tableauserverclient/models/site_item.py @@ -14,7 +14,7 @@ VALID_CONTENT_URL_RE = r"^[a-zA-Z0-9_\-]*$" -from typing import Optional, Union, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from tableauserverclient.server import Server @@ -85,15 +85,15 @@ class SiteItem: state: str Shows the current state of the site (Active or Suspended). - attribute_capture_enabled: Optional[str] + attribute_capture_enabled: str | None Enables user attributes for all Tableau Server embedding workflows. """ - _user_quota: Optional[int] = None - _tier_creator_capacity: Optional[int] = None - _tier_explorer_capacity: Optional[int] = None - _tier_viewer_capacity: Optional[int] = None + _user_quota: int | None = None + _tier_creator_capacity: int | None = None + _tier_explorer_capacity: int | None = None + _tier_viewer_capacity: int | None = None def __str__(self): return ( @@ -123,14 +123,14 @@ def __init__( self, name: str, content_url: str, - admin_mode: Optional[str] = None, - user_quota: Optional[int] = None, - storage_quota: Optional[int] = None, + admin_mode: str | None = None, + user_quota: int | None = None, + storage_quota: int | None = None, disable_subscriptions: bool = False, subscribe_others_enabled: bool = True, revision_history_enabled: bool = False, revision_limit: int = 25, - data_acceleration_mode: Optional[str] = None, + data_acceleration_mode: str | None = None, flows_enabled: bool = True, cataloging_enabled: bool = True, editing_flows_enabled: bool = True, @@ -139,12 +139,12 @@ def __init__( guest_access_enabled: bool = False, cache_warmup_enabled: bool = True, commenting_enabled: bool = True, - extract_encryption_mode: Optional[str] = None, + extract_encryption_mode: str | None = None, request_access_enabled: bool = False, run_now_enabled: bool = True, - tier_explorer_capacity: Optional[int] = None, - tier_creator_capacity: Optional[int] = None, - tier_viewer_capacity: Optional[int] = None, + tier_explorer_capacity: int | None = None, + tier_creator_capacity: int | None = None, + tier_viewer_capacity: int | None = None, data_alerts_enabled: bool = True, commenting_mentions_enabled: bool = True, catalog_obfuscation_enabled: bool = True, @@ -154,9 +154,9 @@ def __init__( notify_site_admins_on_throttle: bool = False, authoring_enabled: bool = True, custom_subscription_email_enabled: bool = False, - custom_subscription_email: Union[str, bool] = False, + custom_subscription_email: str | bool = False, custom_subscription_footer_enabled: bool = False, - custom_subscription_footer: Union[str, bool] = False, + custom_subscription_footer: str | bool = False, ask_data_mode: str = "EnabledByDefault", named_sharing_enabled: bool = True, mobile_biometrics_enabled: bool = False, @@ -167,14 +167,14 @@ def __init__( time_zone=None, auto_suspend_refresh_enabled: bool = True, auto_suspend_refresh_inactivity_window: int = 30, - attribute_capture_enabled: Optional[bool] = None, + attribute_capture_enabled: bool | None = None, ): self._admin_mode = None - self._id: Optional[str] = None + self._id: str | None = None self._num_users = None self._state = None self._status_reason = None - self._storage: Optional[str] = None + self._storage: str | None = None self.user_quota = user_quota self.storage_quota = storage_quota self.content_url = content_url @@ -224,12 +224,12 @@ def __init__( self.attribute_capture_enabled = attribute_capture_enabled @property - def admin_mode(self) -> Optional[str]: + def admin_mode(self) -> str | None: return self._admin_mode @admin_mode.setter @property_is_enum(AdminMode) - def admin_mode(self, value: Optional[str]) -> None: + def admin_mode(self, value: str | None) -> None: self._admin_mode = value @property @@ -255,7 +255,7 @@ def disable_subscriptions(self, value: bool): self._disable_subscriptions = value @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @property @@ -290,24 +290,24 @@ def revision_limit(self, value: int): self._revision_limit = value @property - def state(self) -> Optional[str]: + def state(self) -> str | None: return self._state @state.setter @property_is_enum(State) - def state(self, value: Optional[str]) -> None: + def state(self, value: str | None) -> None: self._state = value @property - def status_reason(self) -> Optional[str]: + def status_reason(self) -> str | None: return self._status_reason @property - def storage(self) -> Optional[str]: + def storage(self) -> str | None: return self._storage @property - def user_quota(self) -> Optional[int]: + def user_quota(self) -> int | None: if any((self.tier_creator_capacity, self.tier_explorer_capacity, self.tier_viewer_capacity)): warnings.warn("Tiered license level is set. Returning None for user_quota") return None @@ -315,7 +315,7 @@ def user_quota(self) -> Optional[int]: return self._user_quota @user_quota.setter - def user_quota(self, value: Optional[int]) -> None: + def user_quota(self, value: int | None) -> None: if value is not None and any( (self.tier_creator_capacity, self.tier_explorer_capacity, self.tier_viewer_capacity) ): @@ -336,11 +336,11 @@ def subscribe_others_enabled(self, value: bool) -> None: self._subscribe_others_enabled = value @property - def data_acceleration_mode(self) -> Optional[str]: + def data_acceleration_mode(self) -> str | None: return self._data_acceleration_mode @data_acceleration_mode.setter - def data_acceleration_mode(self, value: Optional[str]): + def data_acceleration_mode(self, value: str | None): self._data_acceleration_mode = value @property @@ -424,11 +424,11 @@ def commenting_enabled(self, value: bool): self._commenting_enabled = value @property - def extract_encryption_mode(self) -> Optional[str]: + def extract_encryption_mode(self) -> str | None: return self._extract_encryption_mode @extract_encryption_mode.setter - def extract_encryption_mode(self, value: Optional[str]): + def extract_encryption_mode(self, value: str | None): self._extract_encryption_mode = value @property @@ -450,27 +450,27 @@ def run_now_enabled(self, value: bool): self._run_now_enabled = value @property - def tier_explorer_capacity(self) -> Optional[int]: + def tier_explorer_capacity(self) -> int | None: return self._tier_explorer_capacity @tier_explorer_capacity.setter - def tier_explorer_capacity(self, value: Optional[int]) -> None: + def tier_explorer_capacity(self, value: int | None) -> None: self._tier_explorer_capacity = value @property - def tier_creator_capacity(self) -> Optional[int]: + def tier_creator_capacity(self) -> int | None: return self._tier_creator_capacity @tier_creator_capacity.setter - def tier_creator_capacity(self, value: Optional[int]) -> None: + def tier_creator_capacity(self, value: int | None) -> None: self._tier_creator_capacity = value @property - def tier_viewer_capacity(self) -> Optional[int]: + def tier_viewer_capacity(self) -> int | None: return self._tier_viewer_capacity @tier_viewer_capacity.setter - def tier_viewer_capacity(self, value: Optional[int]): + def tier_viewer_capacity(self, value: int | None): self._tier_viewer_capacity = value @property @@ -555,11 +555,11 @@ def custom_subscription_email_enabled(self, value: bool) -> None: self._custom_subscription_email_enabled = value @property - def custom_subscription_email(self) -> Union[str, bool]: + def custom_subscription_email(self) -> str | bool: return self._custom_subscription_email @custom_subscription_email.setter - def custom_subscription_email(self, value: Union[str, bool]): + def custom_subscription_email(self, value: str | bool): self._custom_subscription_email = value @property @@ -572,11 +572,11 @@ def custom_subscription_footer_enabled(self, value: bool) -> None: self._custom_subscription_footer_enabled = value @property - def custom_subscription_footer(self) -> Union[str, bool]: + def custom_subscription_footer(self) -> str | bool: return self._custom_subscription_footer @custom_subscription_footer.setter - def custom_subscription_footer(self, value: Union[str, bool]) -> None: + def custom_subscription_footer(self, value: str | bool) -> None: self._custom_subscription_footer = value @property @@ -1209,11 +1209,11 @@ class SiteAuthConfiguration: """ def __init__(self): - self.auth_setting: Optional[str] = None - self.enabled: Optional[bool] = None - self.idp_configuration_id: Optional[str] = None - self.idp_configuration_name: Optional[str] = None - self.known_provider_alias: Optional[str] = None + self.auth_setting: str | None = None + self.enabled: bool | None = None + self.idp_configuration_id: str | None = None + self.idp_configuration_name: str | None = None + self.known_provider_alias: str | None = None @classmethod def from_response(cls, resp: bytes, ns: dict) -> list["SiteAuthConfiguration"]: diff --git a/tableauserverclient/models/table_item.py b/tableauserverclient/models/table_item.py index 541f84360..c1cea0297 100644 --- a/tableauserverclient/models/table_item.py +++ b/tableauserverclient/models/table_item.py @@ -1,4 +1,4 @@ -from typing import Callable, Optional, TYPE_CHECKING +from typing import Callable, TYPE_CHECKING from defusedxml.ElementTree import fromstring from .exceptions import UnpopulatedPropertyError @@ -44,7 +44,7 @@ def dqws(self): return self._data_quality_warnings() @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @property diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 7922ff562..931d6758b 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,9 +1,8 @@ import abc -from typing import Optional class Credentials(abc.ABC): - def __init__(self, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: + def __init__(self, site_id: str | None = None, user_id_to_impersonate: str | None = None) -> None: self.site_id = site_id or "" self.user_id_to_impersonate = user_id_to_impersonate or None @@ -70,7 +69,7 @@ class TableauAuth(Credentials): """ def __init__( - self, username: str, password: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None + self, username: str, password: str, site_id: str | None = None, user_id_to_impersonate: str | None = None ) -> None: super().__init__(site_id, user_id_to_impersonate) if password is None: @@ -133,8 +132,8 @@ def __init__( self, token_name: str, personal_access_token: str, - site_id: Optional[str] = None, - user_id_to_impersonate: Optional[str] = None, + site_id: str | None = None, + user_id_to_impersonate: str | None = None, ) -> None: if personal_access_token is None or token_name is None: raise TabError("Must provide a token and token name when using PAT authentication") @@ -202,8 +201,8 @@ def __init__( self, jwt: str, isUat: bool = False, - site_id: Optional[str] = None, - user_id_to_impersonate: Optional[str] = None, + site_id: str | None = None, + user_id_to_impersonate: str | None = None, ) -> None: if jwt is None: raise TabError("Must provide a JWT token when using JWT authentication") diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index e69d02a06..9d7404612 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -40,7 +40,7 @@ class Resource: ] -def plural_type(content_type: Union[Resource, str]) -> str: +def plural_type(content_type: Resource | str) -> str: if content_type == Resource.Lens: return "lenses" else: diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 8d2492aed..b343f3c22 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,5 +1,4 @@ from datetime import datetime -from typing import Optional from defusedxml.ElementTree import fromstring @@ -57,10 +56,10 @@ def __init__( task_type: str, priority: int, consecutive_failed_count: int = 0, - schedule_id: Optional[str] = None, - schedule_item: Optional[ScheduleItem] = None, - last_run_at: Optional[datetime] = None, - target: Optional[Target] = None, + schedule_id: str | None = None, + schedule_item: ScheduleItem | None = None, + last_run_at: datetime | None = None, + target: Target | None = None, ): self.id = id_ self.task_type = task_type diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 9add6aec0..0ba1e8eb2 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -2,7 +2,7 @@ import xml.etree.ElementTree as ET from datetime import datetime from enum import IntEnum -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING from defusedxml.ElementTree import fromstring from typing_extensions import Self @@ -43,40 +43,40 @@ class UserItem: Attributes ---------- - domain_name: Optional[str] + domain_name: str | None The name of the Active Directory domain ("local" if local authentication is used). - email: Optional[str] + email: str | None The email address of the user. - external_auth_user_id: Optional[str] + external_auth_user_id: str | None The unique identifier for the user in the external authentication system. - id: Optional[str] + id: str | None The unique identifier for the user. favorites: dict[str, list] The favorites of the user. Must be populated with a call to `populate_favorites()`. - fullname: Optional[str] + fullname: str | None The full name of the user. groups: Pager The groups the user belongs to. Must be populated with a call to `populate_groups()`. - last_login: Optional[datetime] + last_login: datetime | None The last time the user logged in. - locale: Optional[str] + locale: str | None The locale of the user. - language: Optional[str] + language: str | None Language setting for the user. - idp_configuration_id: Optional[str] + idp_configuration_id: str | None The ID of the identity provider configuration. workbooks: Pager @@ -124,25 +124,23 @@ class Auth: TableauIDWithMFA = "TableauIDWithMFA" ServerDefault = "ServerDefault" - def __init__( - self, name: Optional[str] = None, site_role: Optional[str] = None, auth_setting: Optional[str] = None - ) -> None: - self._auth_setting: Optional[str] = None - self._domain_name: Optional[str] = None - self._external_auth_user_id: Optional[str] = None - self._id: Optional[str] = None - self._last_login: Optional[datetime] = None + def __init__(self, name: str | None = None, site_role: str | None = None, auth_setting: str | None = None) -> None: + self._auth_setting: str | None = None + self._domain_name: str | None = None + self._external_auth_user_id: str | None = None + self._id: str | None = None + self._last_login: datetime | None = None self._workbooks = None - self._favorites: Optional["FavoriteType"] = None + self._favorites: "FavoriteType | None" = None self._groups = None - self.email: Optional[str] = None - self.fullname: Optional[str] = None - self.name: Optional[str] = name - self.site_role: Optional[str] = site_role - self.auth_setting: Optional[str] = auth_setting - self._locale: Optional[str] = None - self._language: Optional[str] = None - self._idp_configuration_id: Optional[str] = None + self.email: str | None = None + self.fullname: str | None = None + self.name: str | None = name + self.site_role: str | None = site_role + self.auth_setting: str | None = auth_setting + self._locale: str | None = None + self._language: str | None = None + self._idp_configuration_id: str | None = None return None @@ -154,7 +152,7 @@ def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" @property - def auth_setting(self) -> Optional[str]: + def auth_setting(self) -> str | None: return self._auth_setting @auth_setting.setter @@ -163,15 +161,15 @@ def auth_setting(self, value): self._auth_setting = value @property - def domain_name(self) -> Optional[str]: + def domain_name(self) -> str | None: return self._domain_name @property - def external_auth_user_id(self) -> Optional[str]: + def external_auth_user_id(self) -> str | None: return self._external_auth_user_id @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @id.setter @@ -179,15 +177,15 @@ def id(self, value: str) -> None: self._id = value @property - def last_login(self) -> Optional[datetime]: + def last_login(self) -> datetime | None: return self._last_login @property - def name(self) -> Optional[str]: + def name(self) -> str | None: return self._name @name.setter - def name(self, value: Optional[str]): + def name(self, value: str | None): self._name = value # valid: username, domain/username, username@domain, domain/username@email @@ -204,7 +202,7 @@ def validate_username_or_throw(username) -> None: raise AttributeError("Username cannot repeat '@'") @property - def site_role(self) -> Optional[str]: + def site_role(self) -> str | None: return self._site_role @site_role.setter @@ -234,15 +232,15 @@ def groups(self) -> "Pager": return self._groups() @property - def locale(self) -> Optional[str]: + def locale(self) -> str | None: return self._locale @property - def language(self) -> Optional[str]: + def language(self) -> str | None: return self._language @property - def idp_configuration_id(self) -> Optional[str]: + def idp_configuration_id(self) -> str | None: """ IDP configuration id for the user. This is only available on Tableau Cloud, 3.24 or later @@ -331,7 +329,7 @@ def from_response_as_owner(cls, resp, ns) -> list["UserItem"]: return cls._parse_xml(element_name, resp, ns) @classmethod - def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "UserItem": + def from_xml(cls, xml: ET.Element, ns: dict | None = None) -> "UserItem": item = cls() item._set_values(*cls._parse_element(xml, ns)) return item diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index dc8eda9c8..3c08e5845 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -1,7 +1,7 @@ import copy from datetime import datetime from requests import Response -from typing import TYPE_CHECKING, Callable, Optional, overload +from typing import TYPE_CHECKING, Callable, overload from collections.abc import Iterator from defusedxml.ElementTree import fromstring @@ -27,92 +27,92 @@ class ViewItem: Attributes ---------- - content_url: Optional[str], default None + content_url: str | None, default None The name of the view as it would appear in a URL. - created_at: Optional[datetime], default None + created_at: datetime | None, default None The date and time when the view was created. - id: Optional[str], default None + id: str | None, default None The unique identifier for the view. - image: Optional[Callable[[], bytes]], default None + image: Callable[[], bytes] | None, default None The image of the view. You must first call the `views.populate_image` method to access the image. - location: Optional[LocationItem], default None + location: LocationItem | None, default None The location of the view. The location can be a personal space or a project. - name: Optional[str], default None + name: str | None, default None The name of the view. - owner: Optional[UserItem], default None + owner: UserItem | None, default None The owner of the view. - owner_id: Optional[str], default None + owner_id: str | None, default None The ID for the owner of the view. - pdf: Optional[Callable[[], bytes]], default None + pdf: Callable[[], bytes] | None, default None The PDF of the view. You must first call the `views.populate_pdf` method to access the PDF. - preview_image: Optional[Callable[[], bytes]], default None + preview_image: Callable[[], bytes] | None, default None The preview image of the view. You must first call the `views.populate_preview_image` method to access the preview image. - project: Optional[ProjectItem], default None + project: ProjectItem | None, default None The project that contains the view. - project_id: Optional[str], default None + project_id: str | None, default None The ID for the project that contains the view. tags: set[str], default set() The tags associated with the view. - total_views: Optional[int], default None + total_views: int | None, default None The total number of views for the view. - updated_at: Optional[datetime], default None + updated_at: datetime | None, default None The date and time when the view was last updated. - workbook: Optional[WorkbookItem], default None + workbook: WorkbookItem | None, default None The workbook that contains the view. - workbook_id: Optional[str], default None + workbook_id: str | None, default None The ID for the workbook that contains the view. """ def __init__(self) -> None: - self._content_url: Optional[str] = None - self._created_at: Optional[datetime] = None - self._id: Optional[str] = None - self._image: Optional[Callable[[], bytes]] = None + self._content_url: str | None = None + self._created_at: datetime | None = None + self._id: str | None = None + self._image: Callable[[], bytes] | None = None self._initial_tags: set[str] = set() - self._name: Optional[str] = None - self._owner_id: Optional[str] = None - self._preview_image: Optional[Callable[[], bytes]] = None - self._project_id: Optional[str] = None - self._pdf: Optional[Callable[[], bytes]] = None - self._csv: Optional[Callable[[], Iterator[bytes]]] = None - self._excel: Optional[Callable[[], Iterator[bytes]]] = None - self._total_views: Optional[int] = None - self._sheet_type: Optional[str] = None - self._updated_at: Optional[datetime] = None - self._workbook_id: Optional[str] = None - self._permissions: Optional[Callable[[], list[PermissionsRule]]] = None + self._name: str | None = None + self._owner_id: str | None = None + self._preview_image: Callable[[], bytes] | None = None + self._project_id: str | None = None + self._pdf: Callable[[], bytes] | None = None + self._csv: Callable[[], Iterator[bytes]] | None = None + self._excel: Callable[[], Iterator[bytes]] | None = None + self._total_views: int | None = None + self._sheet_type: str | None = None + self._updated_at: datetime | None = None + self._workbook_id: str | None = None + self._permissions: Callable[[], list[PermissionsRule]] | None = None self.tags: set[str] = set() - self._favorites_total: Optional[int] = None - self._view_url_name: Optional[str] = None + self._favorites_total: int | None = None + self._view_url_name: str | None = None self._data_acceleration_config = { "acceleration_enabled": None, "acceleration_status": None, } - self._owner: Optional[UserItem] = None - self._project: Optional[ProjectItem] = None - self._workbook: Optional["WorkbookItem"] = None - self._location: Optional[LocationItem] = None + self._owner: UserItem | None = None + self._project: ProjectItem | None = None + self._workbook: "WorkbookItem | None" = None + self._location: LocationItem | None = None def __str__(self): return "".format( @@ -138,15 +138,15 @@ def _set_excel(self, excel): self._excel = excel @property - def content_url(self) -> Optional[str]: + def content_url(self) -> str | None: return self._content_url @property - def created_at(self) -> Optional[datetime]: + def created_at(self) -> datetime | None: return self._created_at @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @property @@ -157,11 +157,11 @@ def image(self) -> bytes: return self._image() @property - def name(self) -> Optional[str]: + def name(self) -> str | None: return self._name @property - def owner_id(self) -> Optional[str]: + def owner_id(self) -> str | None: return self._owner_id @property @@ -172,7 +172,7 @@ def preview_image(self) -> bytes: return self._preview_image() @property - def project_id(self) -> Optional[str]: + def project_id(self) -> str | None: return self._project_id @property @@ -197,7 +197,7 @@ def excel(self) -> Iterator[bytes]: return self._excel() @property - def sheet_type(self) -> Optional[str]: + def sheet_type(self) -> str | None: return self._sheet_type @property @@ -208,19 +208,19 @@ def total_views(self): return self._total_views @property - def updated_at(self) -> Optional[datetime]: + def updated_at(self) -> datetime | None: return self._updated_at @property - def workbook_id(self) -> Optional[str]: + def workbook_id(self) -> str | None: return self._workbook_id @property - def view_url_name(self) -> Optional[str]: + def view_url_name(self) -> str | None: return self._view_url_name @property - def favorites_total(self) -> Optional[int]: + def favorites_total(self) -> int | None: return self._favorites_total @property @@ -232,19 +232,19 @@ def data_acceleration_config(self, value): self._data_acceleration_config = value @property - def project(self) -> Optional["ProjectItem"]: + def project(self) -> "ProjectItem | None": return self._project @property - def workbook(self) -> Optional["WorkbookItem"]: + def workbook(self) -> "WorkbookItem | None": return self._workbook @property - def owner(self) -> Optional[UserItem]: + def owner(self) -> UserItem | None: return self._owner @property - def location(self) -> Optional[LocationItem]: + def location(self) -> LocationItem | None: return self._location @property diff --git a/tableauserverclient/models/virtual_connection_item.py b/tableauserverclient/models/virtual_connection_item.py index e9e22be1e..7ec2efe36 100644 --- a/tableauserverclient/models/virtual_connection_item.py +++ b/tableauserverclient/models/virtual_connection_item.py @@ -1,6 +1,6 @@ import datetime as dt import json -from typing import Callable, Optional +from typing import Callable from collections.abc import Iterable from xml.etree.ElementTree import Element @@ -15,17 +15,17 @@ class VirtualConnectionItem: def __init__(self, name: str) -> None: self.name = name - self.created_at: Optional[dt.datetime] = None - self.has_extracts: Optional[bool] = None - self._id: Optional[str] = None - self.is_certified: Optional[bool] = None - self.updated_at: Optional[dt.datetime] = None - self.webpage_url: Optional[str] = None - self._connections: Optional[Callable[[], Iterable[ConnectionItem]]] = None - self.project_id: Optional[str] = None - self.owner_id: Optional[str] = None - self.content: Optional[dict[str, dict]] = None - self.certification_note: Optional[str] = None + self.created_at: dt.datetime | None = None + self.has_extracts: bool | None = None + self._id: str | None = None + self.is_certified: bool | None = None + self.updated_at: dt.datetime | None = None + self.webpage_url: str | None = None + self._connections: Callable[[], Iterable[ConnectionItem]] | None = None + self.project_id: str | None = None + self.owner_id: str | None = None + self.content: dict[str, dict] | None = None + self.certification_note: str | None = None def __str__(self) -> str: return f"{self.__class__.__qualname__}(name={self.name})" @@ -37,7 +37,7 @@ def _set_permissions(self, permissions): self._permissions = permissions @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @property diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index 8b551dea4..c8857bb75 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -1,6 +1,5 @@ import re import xml.etree.ElementTree as ET -from typing import Optional from defusedxml.ElementTree import fromstring @@ -21,21 +20,21 @@ class WebhookItem: Attributes ---------- - id : Optional[str] + id : str | None The identifier (luid) for the webhook. You need this value to query a specific webhook with the get_by_id method or to delete a webhook with the delete method. - name : Optional[str] + name : str | None The name of the webhook. You must specify this when you create an instance of the WebhookItem. - url : Optional[str] + url : str | None The destination URL for the webhook. The webhook destination URL must be https and have a valid certificate. You must specify this when you create an instance of the WebhookItem. - event : Optional[str] + event : str | None The name of the Tableau event that triggers your webhook.This is either api-event-name or webhook-source-api-event-name: one of these is required to create an instance of the WebhookItem. We recommend using @@ -43,16 +42,16 @@ class WebhookItem: listed in the Trigger Events table. https://help.tableau.com/current/developer/webhooks/en-us/docs/webhooks-events-payload.html - owner_id : Optional[str] + owner_id : str | None The identifier (luid) of the user who owns the webhook. """ def __init__(self): - self._id: Optional[str] = None - self.name: Optional[str] = None - self.url: Optional[str] = None - self._event: Optional[str] = None - self.owner_id: Optional[str] = None + self._id: str | None = None + self.name: str | None = None + self.url: str | None = None + self._event: str | None = None + self.owner_id: str | None = None def _set_values(self, id, name, url, event, owner_id): if id is not None: @@ -67,11 +66,11 @@ def _set_values(self, id, name, url, event, owner_id): self.owner_id = owner_id @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @property - def event(self) -> Optional[str]: + def event(self) -> str | None: if self._event: return self._event.replace("webhook-source-event-", "") return None diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index df70df390..352923389 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -2,7 +2,7 @@ import datetime import uuid import xml.etree.ElementTree as ET -from typing import Callable, Optional, overload +from typing import Callable, overload from defusedxml.ElementTree import fromstring @@ -32,10 +32,10 @@ class as arguments. The workbook item specifies the project. Parameters ---------- - project_id : Optional[str], optional + project_id : str | None, optional The project ID for the workbook, by default None. - name : Optional[str], optional + name : str | None, optional The name of the workbook, by default None. show_tabs : bool, optional @@ -48,61 +48,61 @@ class as arguments. The workbook item specifies the project. by the workbook. You must first call the workbooks.populate_connections method to access this data. See the ConnectionItem class. - content_url : Optional[str] + content_url : str | None The name of the workbook as it appears in the URL. - created_at : Optional[datetime.datetime] + created_at : datetime.datetime | None The date and time the workbook was created. - default_view_id : Optional[str] + default_view_id : str | None The identifier for the default view of the workbook. - description : Optional[str] + description : str | None User-defined description of the workbook. - encrypt_extracts : Optional[bool] + encrypt_extracts : bool | None Indicates whether extracts are encrypted. - has_extracts : Optional[bool] + has_extracts : bool | None Indicates whether the workbook has extracts. - id : Optional[str] + id : str | None The identifier for the workbook. You need this value to query a specific workbook or to delete a workbook with the get_by_id and delete methods. - last_published_at : Optional[datetime.datetime] + last_published_at : datetime.datetime | None The date and time the workbook was last published. - location : Optional[LocationItem] + location : LocationItem | None The location of the workbook, such as a personal space or project. - owner : Optional[UserItem] + owner : UserItem | None The owner of the workbook. - owner_id : Optional[str] + owner_id : str | None The identifier for the owner (UserItem) of the workbook. preview_image : bytes The thumbnail image for the view. You must first call the workbooks.populate_preview_image method to access this data. - project: Optional[ProjectItem] + project: ProjectItem | None The project that contains the workbook. - project_name : Optional[str] + project_name : str | None The name of the project that contains the workbook. size: int The size of the workbook in megabytes. - hidden_views: Optional[list[str]] + hidden_views: list[str] | None List of string names of views that need to be hidden when the workbook is published. tags: set[str] The set of tags associated with the workbook. - updated_at : Optional[datetime.datetime] + updated_at : datetime.datetime | None The date and time the workbook was last updated. views : list[ViewItem] @@ -110,7 +110,7 @@ class as arguments. The workbook item specifies the project. workbooks.populate_views method to access this data. See the ViewItem class. - web_page_url : Optional[str] + web_page_url : str | None The full URL for the workbook. Examples @@ -125,17 +125,17 @@ class as arguments. The workbook item specifies the project. def __init__( self, - project_id: Optional[str] = None, - name: Optional[str] = None, + project_id: str | None = None, + name: str | None = None, show_tabs: bool = False, - thumbnails_user_id: Optional[str] = None, - thumbnails_group_id: Optional[str] = None, + thumbnails_user_id: str | None = None, + thumbnails_group_id: str | None = None, ) -> None: self._connections = None self._content_url = None self._webpage_url = None self._created_at = None - self._id: Optional[str] = None + self._id: str | None = None self._initial_tags: set = set() self._pdf = None self._powerpoint = None @@ -144,14 +144,14 @@ def __init__( self._revisions = None self._size = None self._updated_at = None - self._views: Optional[Callable[[], list[ViewItem]]] = None + self._views: Callable[[], list[ViewItem]] | None = None self.name = name self._description = None - self.owner_id: Optional[str] = None + self.owner_id: str | None = None # workaround for Personal Space workbooks without a project - self.project_id: Optional[str] = project_id or uuid.uuid4().__str__() + self.project_id: str | None = project_id or uuid.uuid4().__str__() self.show_tabs = show_tabs - self.hidden_views: Optional[list[str]] = None + self.hidden_views: list[str] | None = None self.tags: set[str] = set() self.data_acceleration_config = { "acceleration_enabled": None, @@ -163,15 +163,15 @@ def __init__( self._permissions = None self.thumbnails_user_id = thumbnails_user_id self.thumbnails_group_id = thumbnails_group_id - self._sheet_count: Optional[int] = None - self._has_extracts: Optional[bool] = None - self._project: Optional[ProjectItem] = None - self._owner: Optional[UserItem] = None - self._location: Optional[LocationItem] = None - self._encrypt_extracts: Optional[bool] = None - self._default_view_id: Optional[str] = None - self._share_description: Optional[str] = None - self._last_published_at: Optional[datetime.datetime] = None + self._sheet_count: int | None = None + self._has_extracts: bool | None = None + self._project: ProjectItem | None = None + self._owner: UserItem | None = None + self._location: LocationItem | None = None + self._encrypt_extracts: bool | None = None + self._default_view_id: str | None = None + self._share_description: str | None = None + self._last_published_at: datetime.datetime | None = None return None @@ -198,19 +198,19 @@ def permissions(self) -> list[PermissionsRule]: return self._permissions() @property - def content_url(self) -> Optional[str]: + def content_url(self) -> str | None: return self._content_url @property - def webpage_url(self) -> Optional[str]: + def webpage_url(self) -> str | None: return self._webpage_url @property - def created_at(self) -> Optional[datetime.datetime]: + def created_at(self) -> datetime.datetime | None: return self._created_at @property - def description(self) -> Optional[str]: + def description(self) -> str | None: return self._description @description.setter @@ -218,7 +218,7 @@ def description(self, value: str): self._description = value @property - def id(self) -> Optional[str]: + def id(self) -> str | None: return self._id @property @@ -243,7 +243,7 @@ def preview_image(self) -> bytes: return self._preview_image() @property - def project_id(self) -> Optional[str]: + def project_id(self) -> str | None: return self._project_id @project_id.setter @@ -251,7 +251,7 @@ def project_id(self, value: str): self._project_id = value @property - def project_name(self) -> Optional[str]: + def project_name(self) -> str | None: return self._project_name @property @@ -268,15 +268,15 @@ def size(self): return self._size @property - def sheet_count(self) -> Optional[int]: + def sheet_count(self) -> int | None: return self._sheet_count @property - def has_extracts(self) -> Optional[bool]: + def has_extracts(self) -> bool | None: return self._has_extracts @property - def updated_at(self) -> Optional[datetime.datetime]: + def updated_at(self) -> datetime.datetime | None: return self._updated_at @property @@ -326,47 +326,47 @@ def revisions(self) -> list[RevisionItem]: return self._revisions() @property - def thumbnails_user_id(self) -> Optional[str]: + def thumbnails_user_id(self) -> str | None: return self._thumbnails_user_id @thumbnails_user_id.setter - def thumbnails_user_id(self, value: Optional[str]): + def thumbnails_user_id(self, value: str | None): self._thumbnails_user_id = value @property - def thumbnails_group_id(self) -> Optional[str]: + def thumbnails_group_id(self) -> str | None: return self._thumbnails_group_id @thumbnails_group_id.setter - def thumbnails_group_id(self, value: Optional[str]): + def thumbnails_group_id(self, value: str | None): self._thumbnails_group_id = value @property - def project(self) -> Optional[ProjectItem]: + def project(self) -> ProjectItem | None: return self._project @property - def owner(self) -> Optional[UserItem]: + def owner(self) -> UserItem | None: return self._owner @property - def location(self) -> Optional[LocationItem]: + def location(self) -> LocationItem | None: return self._location @property - def encrypt_extracts(self) -> Optional[bool]: + def encrypt_extracts(self) -> bool | None: return self._encrypt_extracts @property - def default_view_id(self) -> Optional[str]: + def default_view_id(self) -> str | None: return self._default_view_id @property - def share_description(self) -> Optional[str]: + def share_description(self) -> str | None: return self._share_description @property - def last_published_at(self) -> Optional[datetime.datetime]: + def last_published_at(self) -> datetime.datetime | None: return self._last_published_at def _set_connections(self, connections): diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index a82f1fa67..55f0c7ea2 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -3,7 +3,7 @@ import os from contextlib import closing from pathlib import Path -from typing import Optional, Union, TYPE_CHECKING +from typing import TYPE_CHECKING from collections.abc import Iterator from tableauserverclient.config import BYTES_PER_MB, config @@ -32,12 +32,12 @@ update the name or owner of a custom view. """ -FilePath = Union[str, os.PathLike] -FileObject = Union[io.BufferedReader, io.BytesIO] -FileObjectR = Union[io.BufferedReader, io.BytesIO] -FileObjectW = Union[io.BufferedWriter, io.BytesIO] -PathOrFileR = Union[FilePath, FileObjectR] -PathOrFileW = Union[FilePath, FileObjectW] +FilePath = str | os.PathLike +FileObject = io.BufferedReader | io.BytesIO +FileObjectR = io.BufferedReader | io.BytesIO +FileObjectW = io.BufferedWriter | io.BytesIO +PathOrFileR = FilePath | FileObjectR +PathOrFileW = FilePath | FileObjectW io_types_r = (io.BufferedReader, io.BytesIO) io_types_w = (io.BufferedWriter, io.BytesIO) @@ -55,7 +55,7 @@ def expurl(self) -> str: return f"{self.parent_srv._server_address}/api/exp/sites/{self.parent_srv.site_id}/customviews" @api(version="3.18") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[CustomViewItem], PaginationItem]: + def get(self, req_options: "RequestOptions | None" = None) -> tuple[list[CustomViewItem], PaginationItem]: """ Get a list of custom views on a site. @@ -87,7 +87,7 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Cust return all_view_items, pagination_item @api(version="3.18") - def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: + def get_by_id(self, view_id: str) -> CustomViewItem | None: """ Get the details of a specific custom view. @@ -99,7 +99,7 @@ def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: Returns ------- - Optional[CustomViewItem] + CustomViewItem | None """ if not view_id: error = "Custom view item missing ID." @@ -110,7 +110,7 @@ def get_by_id(self, view_id: str) -> Optional[CustomViewItem]: return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) @api(version="3.18") - def populate_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"] = None) -> None: + def populate_image(self, view_item: CustomViewItem, req_options: "ImageRequestOptions | None" = None) -> None: """ Populate the image of a custom view. @@ -149,14 +149,14 @@ def image_fetcher(): view_item._set_image(image_fetcher) logger.info(f"Populated image for custom view (ID: {view_item.id})") - def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: + def _get_view_image(self, view_item: CustomViewItem, req_options: "ImageRequestOptions | None") -> bytes: url = f"{self.baseurl}/{view_item.id}/image" server_response = self.get_request(url, req_options) image = server_response.content return image @api(version="3.23") - def populate_pdf(self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None: + def populate_pdf(self, custom_view_item: CustomViewItem, req_options: "PDFRequestOptions | None" = None) -> None: """ Populate the PDF of a custom view. @@ -187,16 +187,14 @@ def pdf_fetcher(): custom_view_item._set_pdf(pdf_fetcher) logger.info(f"Populated pdf for custom view (ID: {custom_view_item.id})") - def _get_custom_view_pdf( - self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] - ) -> bytes: + def _get_custom_view_pdf(self, custom_view_item: CustomViewItem, req_options: "PDFRequestOptions | None") -> bytes: url = f"{self.baseurl}/{custom_view_item.id}/pdf" server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @api(version="3.23") - def populate_csv(self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: + def populate_csv(self, custom_view_item: CustomViewItem, req_options: "CSVRequestOptions | None" = None) -> None: """ Populate the CSV of a custom view. @@ -228,7 +226,7 @@ def csv_fetcher(): logger.info(f"Populated csv for custom view (ID: {custom_view_item.id})") def _get_custom_view_csv( - self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] + self, custom_view_item: CustomViewItem, req_options: "CSVRequestOptions | None" ) -> Iterator[bytes]: url = f"{self.baseurl}/{custom_view_item.id}/data" @@ -236,7 +234,7 @@ def _get_custom_view_csv( yield from server_response.iter_content(1024) @api(version="3.18") - def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: + def update(self, view_item: CustomViewItem) -> CustomViewItem | None: """ Updates the name, owner, or shared status of a custom view. @@ -249,7 +247,7 @@ def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: Returns ------- - Optional[CustomViewItem] + CustomViewItem | None The updated custom view item. """ if not view_item.id: @@ -330,7 +328,7 @@ def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: return file @api(version="3.21") - def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[CustomViewItem]: + def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> CustomViewItem | None: """ Publish a custom view to Tableau Server. The file parameter can be a file path or a file object. If a file path is provided, the file will be @@ -347,7 +345,7 @@ def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[Cust Returns ------- - Optional[CustomViewItem] + CustomViewItem | None The published custom view item. """ url = self.expurl @@ -378,7 +376,7 @@ def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[Cust server_response = self.post_request(url, xml_request, content_type) return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) - def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> "QuerySet[CustomViewItem]": + def filter(self, *invalid, page_size: int | None = None, **kwargs) -> "QuerySet[CustomViewItem]": """ Queries the Tableau Server for items using the specified filters. Page size can be specified to limit the number of items returned in a single diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index ba3ecd74f..ec7d76c9c 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING, Union +from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -24,7 +24,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/dataAlerts" @api(version="3.2") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[DataAlertItem], PaginationItem]: + def get(self, req_options: "RequestOptions | None" = None) -> tuple[list[DataAlertItem], PaginationItem]: logger.info("Querying all dataAlerts on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -44,7 +44,7 @@ def get_by_id(self, dataAlert_id: str) -> DataAlertItem: return DataAlertItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.2") - def delete(self, dataAlert: Union[DataAlertItem, str]) -> None: + def delete(self, dataAlert: DataAlertItem | str) -> None: if isinstance(dataAlert, DataAlertItem): dataAlert_id = dataAlert.id elif isinstance(dataAlert, str): @@ -60,7 +60,7 @@ def delete(self, dataAlert: Union[DataAlertItem, str]) -> None: logger.info(f"Deleted single dataAlert (ID: {dataAlert_id})") @api(version="3.2") - def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Union[UserItem, str]) -> None: + def delete_user_from_alert(self, dataAlert: DataAlertItem | str, user: UserItem | str) -> None: if isinstance(dataAlert, DataAlertItem): dataAlert_id = dataAlert.id elif isinstance(dataAlert, str): @@ -85,7 +85,7 @@ def delete_user_from_alert(self, dataAlert: Union[DataAlertItem, str], user: Uni logger.info(f"Deleted User (ID {user_id}) from dataAlert (ID: {dataAlert_id})") @api(version="3.2") - def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: Union[UserItem, str]) -> UserItem: + def add_user_to_alert(self, dataAlert_item: DataAlertItem, user: UserItem | str) -> UserItem: if isinstance(user, UserItem): user_id = user.id elif isinstance(user, str): diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index dc88ceaa5..caf9a985f 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING from collections.abc import Iterable from tableauserverclient.models.permissions_item import PermissionsRule @@ -32,7 +32,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/databases" @api(version="3.5") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[DatabaseItem], PaginationItem]: + def get(self, req_options: "RequestOptions | None" = None) -> tuple[list[DatabaseItem], PaginationItem]: """ Get information about all databases on the site. Endpoint is paginated, and will return a default of 100 items per page. Use the `req_options` @@ -211,11 +211,11 @@ def delete_dqw(self, item: DatabaseItem) -> None: self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> set[str]: + def add_tags(self, item: DatabaseItem | str, tags: Iterable[str]) -> set[str]: return super().add_tags(item, tags) @api(version="3.9") - def delete_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> None: + def delete_tags(self, item: DatabaseItem | str, tags: Iterable[str]) -> None: super().delete_tags(item, tags) @api(version="3.9") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index a346c2730..0fb5c7dc9 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,7 +6,7 @@ from contextlib import closing from pathlib import Path -from typing import Literal, Optional, TYPE_CHECKING, TypedDict, TypeVar, Union, overload +from typing import Literal, TYPE_CHECKING, TypedDict, TypeVar, overload from collections.abc import Iterable, Sequence from tableauserverclient.models.dqw_item import DQWItem @@ -45,14 +45,14 @@ io_types_r = (io.BytesIO, io.BufferedReader) io_types_w = (io.BytesIO, io.BufferedWriter) -FilePath = Union[str, os.PathLike] -FileObject = Union[io.BufferedReader, io.BytesIO] -PathOrFile = Union[FilePath, FileObject] +FilePath = str | os.PathLike +FileObject = io.BufferedReader | io.BytesIO +PathOrFile = FilePath | FileObject -FileObjectR = Union[io.BufferedReader, io.BytesIO] -FileObjectW = Union[io.BufferedWriter, io.BytesIO] -PathOrFileR = Union[FilePath, FileObjectR] -PathOrFileW = Union[FilePath, FileObjectW] +FileObjectR = io.BufferedReader | io.BytesIO +FileObjectW = io.BufferedWriter | io.BytesIO +PathOrFileR = FilePath | FileObjectR +PathOrFileW = FilePath | FileObjectW HyperActionCondition = TypedDict( @@ -90,7 +90,7 @@ }, ) -HyperAction = Union[HyperActionTable, HyperActionRow] +HyperAction = HyperActionTable | HyperActionRow class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]): @@ -107,7 +107,7 @@ def baseurl(self) -> str: # Get all datasources @api(version="2.0") - def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[DatasourceItem], PaginationItem]: + def get(self, req_options: RequestOptions | None = None) -> tuple[list[DatasourceItem], PaginationItem]: """ Returns a list of published data sources on the specified site, with optional parameters for specifying the paging of large results. To get @@ -120,7 +120,7 @@ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[Dataso Parameters ---------- - req_options : Optional[RequestOptions] + req_options : RequestOptions | None Optional parameters for the request, such as filters, sorting, page size, and page number. @@ -191,7 +191,7 @@ def connections_fetcher(): logger.info(f"Populated connections for datasource (ID: {datasource_item.id})") def _get_datasource_connections( - self, datasource_item: DatasourceItem, req_options: Optional[RequestOptions] = None + self, datasource_item: DatasourceItem, req_options: RequestOptions | None = None ) -> list[ConnectionItem]: url = f"{self.baseurl}/{datasource_item.id}/connections" server_response = self.get_request(url, req_options) @@ -241,7 +241,7 @@ def download( def download( self, datasource_id: str, - filepath: Optional[FilePath] = None, + filepath: FilePath | None = None, include_extract: bool = True, ) -> str: ... @@ -266,7 +266,7 @@ def download( datasource_id : str The unique ID of the datasource to download. - filepath : Optional[PathOrFileW] + filepath : PathOrFileW | None The file path to save the downloaded datasource to. If not specified, the file will be saved to the current working directory. @@ -337,7 +337,7 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: @api(version="2.3") def update_connection( self, datasource_item: DatasourceItem, connection_item: ConnectionItem - ) -> Optional[ConnectionItem]: + ) -> ConnectionItem | None: """ Updates the server address, port, username, or password for the specified data source connection. @@ -354,7 +354,7 @@ def update_connection( Returns ------- - Optional[ConnectionItem] + ConnectionItem | None An object containing information about the updated connection. """ @@ -378,10 +378,10 @@ def update_connections( self, datasource_item: DatasourceItem, connection_luids: Iterable[str], - authentication_type: Optional[str] = None, - username: Optional[str] = None, - password: Optional[str] = None, - embed_password: Optional[bool] = None, + authentication_type: str | None = None, + username: str | None = None, + password: str | None = None, + embed_password: bool | None = None, ) -> list[ConnectionItem]: """ Bulk updates one or more datasource connections by LUID. @@ -433,7 +433,7 @@ def update_connections( return connection_items @api(version="2.8") - def refresh(self, datasource_item: Union[DatasourceItem, str], incremental: bool = False) -> JobItem: + def refresh(self, datasource_item: DatasourceItem | str, incremental: bool = False) -> JobItem: """ Refreshes the extract of an existing workbook. @@ -513,8 +513,8 @@ def publish( datasource_item: DatasourceItem, file: PathOrFileR, mode: str, - connection_credentials: Optional[ConnectionCredentials] = None, - connections: Optional[Sequence[ConnectionItem]] = None, + connection_credentials: ConnectionCredentials | None = None, + connections: Sequence[ConnectionItem] | None = None, as_job: Literal[False] = False, ) -> DatasourceItem: pass @@ -525,8 +525,8 @@ def publish( datasource_item: DatasourceItem, file: PathOrFileR, mode: str, - connection_credentials: Optional[ConnectionCredentials] = None, - connections: Optional[Sequence[ConnectionItem]] = None, + connection_credentials: ConnectionCredentials | None = None, + connections: Sequence[ConnectionItem] | None = None, as_job: Literal[True] = True, ) -> JobItem: pass @@ -545,47 +545,47 @@ def publish( as_job=False, ): """ - Publishes a data source to a server, or appends data to an existing - data source. + Publishes a data source to a server, or appends data to an existing + data source. - This method checks the size of the data source and automatically - determines whether the publish the data source in multiple parts or in - one operation. + This method checks the size of the data source and automatically + determines whether the publish the data source in multiple parts or in + one operation. - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#publish_data_source + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#publish_data_source - Parameters - ---------- - datasource_item : DatasourceItem - The datasource item to publish. The fields for name and project_id - are required. + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item to publish. The fields for name and project_id + are required. - file : PathOrFileR - The file path or file object to publish. + file : PathOrFileR + The file path or file object to publish. - mode : str - Specifies whether you are publishing a new datasource (CreateNew), - overwriting an existing datasource (Overwrite), or add to an - existing datasource (Append). You can also use the publish mode - attributes, for example: TSC.Server.PublishMode.Overwrite. + mode : str + Specifies whether you are publishing a new datasource (CreateNew), + overwriting an existing datasource (Overwrite), or add to an + existing datasource (Append). You can also use the publish mode + attributes, for example: TSC.Server.PublishMode.Overwrite. - connection_credentials : Optional[ConnectionCredentials] - The connection credentials to use when publishing the datasource. - Mutually exclusive with the connections parameter. + connection_credentials : ConnectionCredentials | None + The connection credentials to use when publishing the datasource. + Mutually exclusive with the connections parameter. - connections : Optional[Sequence[ConnectionItem]] - The connections to use when publishing the datasource. Mutually - exclusive with the connection_credentials parameter. + connections : Sequence[ConnectionItem] | None + The connections to use when publishing the datasource. Mutually + exclusive with the connection_credentials parameter. - as_job : bool, default False - If True, the publish operation is asynchronous and returns a job - item. If False, the publish operation is synchronous and returns a - datasource item. + as_job : bool, default False + If True, the publish operation is asynchronous and returns a job + item. If False, the publish operation is synchronous and returns a + datasource item. - Returns - ------- - Union[DatasourceItem, JobItem] - The datasource item or job item. + Returns + ------- + DatasourceItem | JobItem + The datasource item or job item. """ if isinstance(file, (os.PathLike, str)): @@ -686,11 +686,11 @@ def publish( @api(version="3.13") def update_hyper_data( self, - datasource_or_connection_item: Union[DatasourceItem, ConnectionItem, str], + datasource_or_connection_item: DatasourceItem | ConnectionItem | str, *, request_id: str, actions: Sequence[HyperAction], - payload: Optional[FilePath] = None, + payload: FilePath | None = None, ) -> JobItem: """ Incrementally updates data (insert, update, upsert, replace and delete) @@ -708,7 +708,7 @@ def update_hyper_data( Parameters ---------- - datasource_or_connection_item : Union[DatasourceItem, ConnectionItem, str] + datasource_or_connection_item : DatasourceItem | ConnectionItem | str The datasource item, connection item, or datasource ID. Either a DataSourceItem or a ConnectionItem. If the datasource only contains a single connection, the DataSourceItem is sufficient to identify @@ -726,7 +726,7 @@ def update_hyper_data( modify the data within the published datasource. For more information on the actions, see: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_how_to_update_data_to_hyper.htm#action-batch-descriptions - payload : Optional[FilePath] + payload : FilePath | None A Hyper file containing tuples to be inserted/deleted/updated or other payload data used by the actions. Hyper files can be created using the Tableau Hyper API or pantab. @@ -949,7 +949,7 @@ def revisions_fetcher(): logger.info(f"Populated revisions for datasource (ID: {datasource_item.id})") def _get_datasource_revisions( - self, datasource_item: DatasourceItem, req_options: Optional["RequestOptions"] = None + self, datasource_item: DatasourceItem, req_options: "RequestOptions | None" = None ) -> list[RevisionItem]: url = f"{self.baseurl}/{datasource_item.id}/revisions" server_response = self.get_request(url, req_options) @@ -962,7 +962,7 @@ def _get_datasource_revisions( def download_revision( self, datasource_id: str, - revision_number: Optional[str], + revision_number: str | None, filepath: T, include_extract: bool = True, ) -> T: ... @@ -971,8 +971,8 @@ def download_revision( def download_revision( self, datasource_id: str, - revision_number: Optional[str], - filepath: Optional[FilePath] = None, + revision_number: str | None, + filepath: FilePath | None = None, include_extract: bool = True, ) -> str: ... @@ -997,12 +997,12 @@ def download_revision( datasource_id : str The unique ID of the datasource to download. - revision_number : Optional[str] + revision_number : str | None The revision number of the data source to download. To determine what versions are available, call the `populate_revisions` method. Pass None to download the current version. - filepath : Optional[PathOrFileW] + filepath : PathOrFileW | None The file path to save the downloaded datasource to. If not specified, the file will be saved to the current working directory. @@ -1101,7 +1101,7 @@ def schedule_extract_refresh( return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) @api(version="1.0") - def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> set[str]: + def add_tags(self, item: DatasourceItem | str, tags: Iterable[str] | str) -> set[str]: """ Adds one or more tags to the specified datasource item. @@ -1109,10 +1109,10 @@ def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], Parameters ---------- - item : Union[DatasourceItem, str] + item : DatasourceItem | str The datasource item or ID to add tags to. - tags : Union[Iterable[str], str] + tags : Iterable[str] | str The tag or tags to add to the datasource item. Returns @@ -1123,7 +1123,7 @@ def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], return super().add_tags(item, tags) @api(version="1.0") - def delete_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> None: + def delete_tags(self, item: DatasourceItem | str, tags: Iterable[str] | str) -> None: """ Deletes one or more tags from the specified datasource item. @@ -1131,10 +1131,10 @@ def delete_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str Parameters ---------- - item : Union[DatasourceItem, str] + item : DatasourceItem | str The datasource item or ID to delete tags from. - tags : Union[Iterable[str], str] + tags : Iterable[str] | str The tag or tags to delete from the datasource item. Returns @@ -1159,7 +1159,7 @@ def update_tags(self, item: DatasourceItem) -> None: """ return super().update_tags(item) - def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[DatasourceItem]: + def filter(self, *invalid, page_size: int | None = None, **kwargs) -> QuerySet[DatasourceItem]: """ Queries the Tableau Server for items using the specified filters. Page size can be specified to limit the number of items returned in a single diff --git a/tableauserverclient/server/endpoint/default_permissions_endpoint.py b/tableauserverclient/server/endpoint/default_permissions_endpoint.py index 499324e8e..d788ac9f0 100644 --- a/tableauserverclient/server/endpoint/default_permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/default_permissions_endpoint.py @@ -4,7 +4,7 @@ from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory from tableauserverclient.models import DatabaseItem, PermissionsRule, ProjectItem, plural_type, Resource -from typing import TYPE_CHECKING, Callable, Optional, Union +from typing import TYPE_CHECKING, Callable from collections.abc import Sequence if TYPE_CHECKING: @@ -14,7 +14,7 @@ from tableauserverclient.helpers.logging import logger # these are the only two items that can hold default permissions for another type -BaseItem = Union[DatabaseItem, ProjectItem] +BaseItem = DatabaseItem | ProjectItem class _DefaultPermissionsEndpoint(Endpoint): @@ -39,7 +39,7 @@ def __str__(self): __repr__ = __str__ def update_default_permissions( - self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Union[Resource, str] + self, resource: BaseItem, permissions: Sequence[PermissionsRule], content_type: Resource | str ) -> list[PermissionsRule]: url = f"{self.owner_baseurl()}/{resource.id}/default-permissions/{plural_type(content_type)}" update_req = RequestFactory.Permission.add_req(permissions) @@ -51,7 +51,7 @@ def update_default_permissions( return permissions def delete_default_permission( - self, resource: BaseItem, rule: PermissionsRule, content_type: Union[Resource, str] + self, resource: BaseItem, rule: PermissionsRule, content_type: Resource | str ) -> None: for capability, mode in rule.capabilities.items(): # Made readability better but line is too long, will make this look better @@ -74,7 +74,7 @@ def delete_default_permission( logger.info(f"Deleted permission for {rule.grantee.tag_name} {rule.grantee.id} item {resource.id}") - def populate_default_permissions(self, item: BaseItem, content_type: Union[Resource, str]) -> None: + def populate_default_permissions(self, item: BaseItem, content_type: Resource | str) -> None: if not item.id: error = "Server item is missing ID. Item must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -86,7 +86,7 @@ def permission_fetcher() -> list[PermissionsRule]: logger.info(f"Populated default {content_type} permissions for item (ID: {item.id})") def _get_default_permissions( - self, item: BaseItem, content_type: Union[Resource, str], req_options: Optional["RequestOptions"] = None + self, item: BaseItem, content_type: Resource | str, req_options: "RequestOptions | None" = None ) -> list[PermissionsRule]: url = f"{self.owner_baseurl()}/{item.id}/default-permissions/{plural_type(content_type)}" server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/dqw_endpoint.py b/tableauserverclient/server/endpoint/dqw_endpoint.py index d2ad517ee..ae7d116b2 100644 --- a/tableauserverclient/server/endpoint/dqw_endpoint.py +++ b/tableauserverclient/server/endpoint/dqw_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import Callable, Optional, Protocol, TYPE_CHECKING +from typing import Callable, Protocol, TYPE_CHECKING from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError @@ -14,7 +14,7 @@ class HasId(Protocol): @property - def id(self) -> Optional[str]: ... + def id(self) -> str | None: ... def _set_data_quality_warnings(self, dqw: Callable[[], list[DQWItem]]): ... @@ -62,7 +62,7 @@ def dqw_fetcher(): item._set_data_quality_warnings(dqw_fetcher) logger.info(f"Populated permissions for item (ID: {item.id})") - def _get_data_quality_warnings(self, item: HasId, req_options: Optional["RequestOptions"] = None) -> list[DQWItem]: + def _get_data_quality_warnings(self, item: HasId, req_options: "RequestOptions | None" = None) -> list[DQWItem]: url = f"{self.baseurl}/{item.id}" server_response = self.get_request(url, req_options) dqws = DQWItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 21462af5f..149aab5ec 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -80,14 +80,14 @@ def set_user_agent(parameters): parameters["headers"][USER_AGENT_HEADER] = parameters[USER_AGENT_HEADER] else: # only set the TSC user agent if not already populated - _client_version: Optional[str] = get_versions()["version"] + _client_version: str | None = get_versions()["version"] parameters["headers"][USER_AGENT_HEADER] = f"Tableau Server Client/{_client_version}" # result: parameters["headers"]["User-Agent"] is set # return explicitly for testing only return parameters - def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Response", Exception]]: + def _blocking_request(self, method, url, parameters={}) -> "Response | Exception | None": response = None logger.debug(f"[{datetime.timestamp()}] Begin blocking request to {url}") try: @@ -100,17 +100,17 @@ def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Respo def send_request_while_show_progress_threaded( self, method, url, parameters={}, request_timeout=None - ) -> Optional[Union["Response", Exception]]: + ) -> "Response | Exception | None": return self._blocking_request(method, url, parameters) def _make_request( self, method: Callable[..., "Response"], url: str, - content: Optional[bytes] = None, - auth_token: Optional[str] = None, - content_type: Optional[str] = None, - parameters: Optional[dict[str, Any]] = None, + content: bytes | None = None, + auth_token: str | None = None, + content_type: str | None = None, + parameters: dict[str, Any] | None = None, ) -> "Response": parameters = Endpoint.set_parameters( self.parent_srv.http_options, auth_token, content, content_type, parameters @@ -125,7 +125,7 @@ def _make_request( # a request can, for stuff like publishing, spin for ages waiting for a response. # we need some user-facing activity so they know it's not dead. request_timeout = self.parent_srv.http_options.get("timeout") or 0 - server_response: Optional[Union["Response", Exception]] = self.send_request_while_show_progress_threaded( + server_response: "Response | Exception | None" = self.send_request_while_show_progress_threaded( method, url, parameters, request_timeout ) logger.debug(f"[{datetime.timestamp()}] Async request returned: received {server_response}") @@ -152,7 +152,7 @@ def _make_request( return server_response - def _check_status(self, server_response: "Response", url: Optional[str] = None): + def _check_status(self, server_response: "Response", url: str | None = None): logger.debug(f"Response status: {server_response}") if not hasattr(server_response, "status_code"): raise OSError("Response is not a http response?") @@ -326,14 +326,14 @@ def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R: class QuerysetEndpoint(Endpoint, Generic[T]): @api(version="2.0") - def all(self, *args, page_size: Optional[int] = None, **kwargs) -> QuerySet[T]: + def all(self, *args, page_size: int | None = None, **kwargs) -> QuerySet[T]: if args or kwargs: raise ValueError(".all method takes no arguments.") queryset = QuerySet(self, page_size=page_size) return queryset @api(version="2.0") - def filter(self, *_, page_size: Optional[int] = None, **kwargs) -> QuerySet[T]: + def filter(self, *_, page_size: int | None = None, **kwargs) -> QuerySet[T]: if _: raise RuntimeError("Only keyword arguments accepted.") queryset = QuerySet(self, page_size=page_size).filter(**kwargs) @@ -352,7 +352,7 @@ def paginate(self, **kwargs) -> QuerySet[T]: return queryset @abc.abstractmethod - def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]: + def get(self, request_options: RequestOptions | None = None) -> tuple[list[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") def fields(self: Self, *fields: str) -> QuerySet: diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index ee931c910..9d30ce331 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,5 +1,5 @@ from defusedxml.ElementTree import fromstring -from typing import Mapping, Optional, TypeVar +from typing import TypeVar def split_pascal_case(s: str) -> str: @@ -14,7 +14,7 @@ class TableauError(Exception): class XMLError(TableauError): - def __init__(self, code: str, summary: str, detail: str, url: Optional[str] = None) -> None: + def __init__(self, code: str, summary: str, detail: str, url: str | None = None) -> None: self.code = code self.summary = summary self.detail = detail @@ -45,7 +45,7 @@ class ServerResponseError(XMLError): class InternalServerError(TableauError): - def __init__(self, server_response, request_url: Optional[str] = None): + def __init__(self, server_response, request_url: str | None = None): self.code = server_response.status_code self.content = server_response.content self.url = request_url or "server" diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 8330e6d2c..228792e14 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -14,7 +14,6 @@ WorkbookItem, ) from tableauserverclient.server import RequestFactory, RequestOptions -from typing import Optional class Favorites(Endpoint): @@ -24,7 +23,7 @@ def baseurl(self) -> str: # Gets all favorites @api(version="2.5") - def get(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + def get(self, user_item: UserItem, req_options: RequestOptions | None = None) -> None: logger.info(f"Querying all favorites for user {user_item.name}") url = f"{self.baseurl}/{user_item.id}" server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 2c3bb84bc..db6aacab9 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, TYPE_CHECKING, Union +from typing import TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException, FlowRunCancelledException @@ -28,7 +28,7 @@ def baseurl(self) -> str: # QuerysetEndpoint expects a PaginationItem to be returned, but FlowRuns # does not return a PaginationItem. Suppressing the mypy error because the # changes to the QuerySet class should permit this to function regardless. - def get(self, req_options: Optional["RequestOptions"] = None) -> list[FlowRunItem]: # type: ignore[override] + def get(self, req_options: "RequestOptions | None" = None) -> list[FlowRunItem]: # type: ignore[override] logger.info("Querying all flow runs on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -48,7 +48,7 @@ def get_by_id(self, flow_run_id: str) -> FlowRunItem: # Cancel 1 flow run by id @api(version="3.10") - def cancel(self, flow_run_id: Union[str, FlowRunItem]) -> None: + def cancel(self, flow_run_id: str | FlowRunItem) -> None: if not flow_run_id: error = "Flow ID undefined." raise ValueError(error) @@ -58,7 +58,7 @@ def cancel(self, flow_run_id: Union[str, FlowRunItem]) -> None: logger.info(f"Deleted single flow (ID: {id_})") @api(version="3.10") - def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> FlowRunItem: + def wait_for_job(self, flow_run_id: str, *, timeout: int | None = None) -> FlowRunItem: if isinstance(flow_run_id, FlowRunItem): flow_run_id = flow_run_id.id assert isinstance(flow_run_id, str) @@ -82,7 +82,7 @@ def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> Fl else: raise AssertionError("Unexpected status in flow_run", flow_run) - def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowRunItem]: + def filter(self, *invalid, page_size: int | None = None, **kwargs) -> QuerySet[FlowRunItem]: """ Queries the Tableau Server for items using the specified filters. Page size can be specified to limit the number of items returned in a single diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 23e67f5de..2d480e883 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -5,7 +5,7 @@ import os from contextlib import closing from pathlib import Path -from typing import Optional, TYPE_CHECKING, Union +from typing import TYPE_CHECKING from collections.abc import Iterable from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint @@ -43,11 +43,11 @@ from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse -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] +FilePath = str | os.PathLike +FileObjectR = io.BufferedReader | io.BytesIO +FileObjectW = io.BufferedWriter | io.BytesIO +PathOrFileR = FilePath | FileObjectR +PathOrFileW = FilePath | FileObjectW class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem]): @@ -63,7 +63,7 @@ def baseurl(self) -> str: # Get all flows @api(version="3.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[FlowItem], PaginationItem]: + def get(self, req_options: "RequestOptions | None" = None) -> tuple[list[FlowItem], PaginationItem]: """ Get all flows on site. Returns a tuple of all flow items and pagination item. This method is paginated, and returns one page of items per call. The @@ -74,7 +74,7 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Flow Parameters ---------- - req_options: Optional[RequestOptions] + req_options: RequestOptions | None An optional Request Options object that can be used to specify sorting, filtering, and pagination options. @@ -150,7 +150,7 @@ def connections_fetcher(): flow_item._set_connections(connections_fetcher) logger.info(f"Populated connections for flow (ID: {flow_item.id})") - def _get_flow_connections(self, flow_item, req_options: Optional["RequestOptions"] = None) -> list[ConnectionItem]: + def _get_flow_connections(self, flow_item, req_options: "RequestOptions | None" = None) -> list[ConnectionItem]: url = f"{self.baseurl}/{flow_item.id}/connections" server_response = self.get_request(url, req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) @@ -187,7 +187,7 @@ def delete(self, flow_id: str) -> None: # Download 1 flow by id @api(version="3.3") - def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> PathOrFileW: + def download(self, flow_id: str, filepath: PathOrFileW | None = None) -> PathOrFileW: """ Download a single flow by id. The flow will be downloaded to the specified file path. If no file path is specified, the flow will be @@ -200,7 +200,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path flow_id: str The id of the flow to download. - filepath: Optional[PathOrFileW] + filepath: PathOrFileW | None The file path to download the flow to. This can be a file path or a file object. If a file object is passed, the flow will be written to the file object. If a file path is passed, the flow will be @@ -305,7 +305,7 @@ def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem return connection @api(version="3.3") - def refresh(self, flow_item: Union[FlowItem, str]) -> JobItem: + def refresh(self, flow_item: FlowItem | str) -> JobItem: """ Runs the flow to refresh the data. @@ -331,7 +331,7 @@ def refresh(self, flow_item: Union[FlowItem, str]) -> JobItem: # Publish flow @api(version="3.3") def publish( - self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: Optional[list[ConnectionItem]] = None + self, flow_item: FlowItem, file: PathOrFileR, mode: str, connections: list[ConnectionItem] | None = None ) -> FlowItem: """ Publishes a flow to the Tableau Server. @@ -353,7 +353,7 @@ def publish( exists. If the mode is "CreateNew", a new flow will be created with the same name as the flow item. - connections: Optional[list[ConnectionItem]] + connections: list[ConnectionItem] | None A list of connection items to publish with the flow. If the flow contains connections, they must be included in this list. @@ -610,7 +610,7 @@ def schedule_flow_run( """ return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) - def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: + def filter(self, *invalid, page_size: int | None = None, **kwargs) -> QuerySet[FlowItem]: """ Queries the Tableau Server for items using the specified filters. Page size can be specified to limit the number of items returned in a single diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 4e9af4076..eb4cb10bc 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Literal, Optional, TYPE_CHECKING, Union, overload +from typing import Literal, TYPE_CHECKING, overload from collections.abc import Iterable from tableauserverclient.server.query import QuerySet @@ -28,7 +28,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groups" @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[GroupItem], PaginationItem]: + def get(self, req_options: "RequestOptions | None" = None) -> tuple[list[GroupItem], PaginationItem]: """ Returns information about the groups on the site. @@ -40,7 +40,7 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Grou Parameters ---------- - req_options : Optional[RequestOptions] + req_options : RequestOptions | None (Optional) You can pass the method a request object that contains additional parameters to filter the request. For example, if you were searching for a specific group, you could specify the name of @@ -76,7 +76,7 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Grou return all_group_items, pagination_item @api(version="2.0") - def populate_users(self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None) -> None: + def populate_users(self, group_item: GroupItem, req_options: "RequestOptions | None" = None) -> None: """ Populates the group_item with the list of users. @@ -87,7 +87,7 @@ def populate_users(self, group_item: GroupItem, req_options: Optional["RequestOp group_item : GroupItem The group item to populate with user information. - req_options : Optional[RequestOptions] + req_options : RequestOptions | None (Optional) You can pass the method a request object that contains page size and page number. @@ -128,7 +128,7 @@ def user_pager(): group_item._set_users(user_pager) def _get_users_for_group( - self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None + self, group_item: GroupItem, req_options: "RequestOptions | None" = None ) -> tuple[list[UserItem], PaginationItem]: url = f"{self.baseurl}/{group_item.id}/users" server_response = self.get_request(url, req_options) @@ -197,7 +197,7 @@ def update(self, group_item, as_job=False): Returns ------- - Union[GroupItem, JobItem] + GroupItem | JobItem Raises ------ @@ -284,7 +284,7 @@ def create_AD_group(self, group_item, asJob=False): Returns ------- - Union[GroupItem, JobItem] + GroupItem | JobItem Examples -------- @@ -347,7 +347,7 @@ def remove_user(self, group_item: GroupItem, user_id: str) -> None: logger.info(f"Removed user (id: {user_id}) from group (ID: {group_item.id})") @api(version="3.21") - def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> None: + def remove_users(self, group_item: GroupItem, users: Iterable[str | UserItem]) -> None: """ Removes multiple users from 1 group. This makes a single API call to remove the provided users. @@ -359,7 +359,7 @@ def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserIte group_item : GroupItem The group item from which to remove the user. - users : Iterable[Union[str, UserItem]] + users : Iterable[str | UserItem] The IDs or UserItems with IDs of the users to remove from the group. Returns @@ -436,7 +436,7 @@ def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: return user @api(version="3.21") - def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> list[UserItem]: + def add_users(self, group_item: GroupItem, users: Iterable[str | UserItem]) -> list[UserItem]: """ Adds 1 or more user to 1 group @@ -447,7 +447,7 @@ def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]] group_item : GroupItem The group item to which to add the user. - user_id : Iterable[Union[str, UserItem]] + user_id : Iterable[str | UserItem] User IDs or UserItems with IDs to add to the group. Returns @@ -480,7 +480,7 @@ def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]] logger.info(f"Added users to group (ID: {group_item.id})") return users - def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupItem]: + def filter(self, *invalid, page_size: int | None = None, **kwargs) -> QuerySet[GroupItem]: """ Queries the Tableau Server for items using the specified filters. Page size can be specified to limit the number of items returned in a single diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py index 8c0ef64f3..a5bafc82e 100644 --- a/tableauserverclient/server/endpoint/groupsets_endpoint.py +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, TYPE_CHECKING, Union +from typing import Literal, TYPE_CHECKING from tableauserverclient.helpers.logging import logger from tableauserverclient.models.group_item import GroupItem @@ -25,8 +25,8 @@ def baseurl(self) -> str: @api(version="3.22") def get( self, - req_options: Optional[RequestOptions] = None, - result_level: Optional[Literal["members", "local"]] = None, + req_options: RequestOptions | None = None, + result_level: Literal["members", "local"] | None = None, ) -> tuple[list[GroupSetItem], PaginationItem]: logger.info("Querying all group sets on site") url = self.baseurl @@ -55,7 +55,7 @@ def create(self, groupset_item: GroupSetItem) -> GroupSetItem: return created_groupset[0] @api(version="3.22") - def add_group(self, groupset_item: GroupSetItem, group: Union[GroupItem, str]) -> None: + def add_group(self, groupset_item: GroupSetItem, group: GroupItem | str) -> None: group_id = group.id if isinstance(group, GroupItem) else group logger.info(f"Adding group (ID: {group_id}) to group set (ID: {groupset_item.id})") url = f"{self.baseurl}/{groupset_item.id}/groups/{group_id}" @@ -63,7 +63,7 @@ def add_group(self, groupset_item: GroupSetItem, group: Union[GroupItem, str]) - return None @api(version="3.22") - def remove_group(self, groupset_item: GroupSetItem, group: Union[GroupItem, str]) -> None: + def remove_group(self, groupset_item: GroupSetItem, group: GroupItem | str) -> None: group_id = group.id if isinstance(group, GroupItem) else group logger.info(f"Removing group (ID: {group_id}) from group set (ID: {groupset_item.id})") url = f"{self.baseurl}/{groupset_item.id}/groups/{group_id}" @@ -71,7 +71,7 @@ def remove_group(self, groupset_item: GroupSetItem, group: Union[GroupItem, str] return None @api(version="3.22") - def delete(self, groupset: Union[GroupSetItem, str]) -> None: + def delete(self, groupset: GroupSetItem | str) -> None: groupset_id = groupset.id if isinstance(groupset, GroupSetItem) else groupset logger.info(f"Deleting group set (ID: {groupset_id})") url = f"{self.baseurl}/{groupset_id}" @@ -87,7 +87,7 @@ def update(self, groupset: GroupSetItem) -> GroupSetItem: updated_groupset = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) return updated_groupset[0] - def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupSetItem]: + def filter(self, *invalid, page_size: int | None = None, **kwargs) -> QuerySet[GroupSetItem]: """ Queries the Tableau Server for items using the specified filters. Page size can be specified to limit the number of items returned in a single diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 48e91bd74..655b7cc6f 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -11,8 +11,6 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, Union - class Jobs(QuerysetEndpoint[BackgroundJobItem]): @property @@ -20,7 +18,7 @@ def baseurl(self): return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/jobs" @overload # type: ignore[override] - def get(self: Self, job_id: str, req_options: Optional[RequestOptionsBase] = None) -> JobItem: # type: ignore[override] + def get(self: Self, job_id: str, req_options: RequestOptionsBase | None = None) -> JobItem: # type: ignore[override] ... @overload # type: ignore[override] @@ -28,7 +26,7 @@ def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> tuple[list ... @overload # type: ignore[override] - def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] + def get(self: Self, job_id: None, req_options: RequestOptionsBase | None) -> tuple[list[BackgroundJobItem], PaginationItem]: # type: ignore[override] ... @api(version="2.6") @@ -75,7 +73,7 @@ def get(self, job_id=None, req_options=None): return jobs, pagination_item @api(version="3.1") - def cancel(self, job_id: Union[str, JobItem]): + def cancel(self, job_id: str | JobItem): """ Cancels a job specified by job ID. To get a list of job IDs for jobs that are currently queued or in-progress, use the Query Jobs method. @@ -143,7 +141,7 @@ def get_by_id(self, job_id: str) -> JobItem: new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job - def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] = None) -> JobItem: + def wait_for_job(self, job_id: str | JobItem, *, timeout: float | None = None) -> JobItem: """ Waits for a job to complete. The method will poll the server for the job status until the job is completed. If the job is successful, the method @@ -197,7 +195,7 @@ def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] else: raise AssertionError("Unexpected finish_code in job", job) - def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[BackgroundJobItem]: + def filter(self, *invalid, page_size: int | None = None, **kwargs) -> QuerySet[BackgroundJobItem]: """ Queries the Tableau Server for items using the specified filters. Page size can be specified to limit the number of items returned in a single diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py index ede4d38e3..5bad618ce 100644 --- a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -1,5 +1,3 @@ -from typing import Optional, Union - from tableauserverclient.helpers.logging import logger from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskJobItem from tableauserverclient.models.pagination_item import PaginationItem @@ -18,7 +16,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/linked" @api(version="3.15") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[LinkedTaskItem], PaginationItem]: + def get(self, req_options: "RequestOptions | None" = None) -> tuple[list[LinkedTaskItem], PaginationItem]: logger.info("Querying all linked tasks on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -27,7 +25,7 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Link return all_group_items, pagination_item @api(version="3.15") - def get_by_id(self, linked_task: Union[LinkedTaskItem, str]) -> LinkedTaskItem: + def get_by_id(self, linked_task: LinkedTaskItem | str) -> LinkedTaskItem: task_id = getattr(linked_task, "id", linked_task) logger.info("Querying all linked tasks on site") url = f"{self.baseurl}/{task_id}" @@ -36,7 +34,7 @@ def get_by_id(self, linked_task: Union[LinkedTaskItem, str]) -> LinkedTaskItem: return all_group_items[0] @api(version="3.15") - def run_now(self, linked_task: Union[LinkedTaskItem, str]) -> LinkedTaskJobItem: + def run_now(self, linked_task: LinkedTaskItem | str) -> LinkedTaskJobItem: task_id = getattr(linked_task, "id", linked_task) logger.info(f"Running linked task {task_id} now") url = f"{self.baseurl}/{task_id}/runNow" diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index 3fea1f5b6..5c7d03c89 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -8,7 +8,7 @@ import logging -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from ..request_options import RequestOptions @@ -31,7 +31,7 @@ def baseurl(self) -> str: # Get all metrics @api(version="3.9") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[MetricItem], PaginationItem]: + def get(self, req_options: "RequestOptions | None" = None) -> tuple[list[MetricItem], PaginationItem]: logger.info("Querying all metrics on site") url = self.baseurl server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/oidc_endpoint.py b/tableauserverclient/server/endpoint/oidc_endpoint.py index d16008095..1e6abd559 100644 --- a/tableauserverclient/server/endpoint/oidc_endpoint.py +++ b/tableauserverclient/server/endpoint/oidc_endpoint.py @@ -18,7 +18,7 @@ class IDPProperty(Protocol): def idp_configuration_id(self) -> str: ... -HasIdpConfigurationID = Union[str, IDPAttributes] +HasIdpConfigurationID = str | IDPAttributes class OIDC(Endpoint): @@ -45,7 +45,7 @@ def get(self) -> list["SiteAuthConfiguration"]: return self.parent_srv.sites.list_auth_configurations() @api(version="3.24") - def get_by_id(self, id: Union[str, HasIdpConfigurationID]) -> SiteOIDCConfiguration: + def get_by_id(self, id: str | HasIdpConfigurationID) -> SiteOIDCConfiguration: """ Get details about a specific OpenID Connect (OIDC) configuration on the current Tableau Cloud site. Only retrieves configurations for the @@ -55,7 +55,7 @@ def get_by_id(self, id: Union[str, HasIdpConfigurationID]) -> SiteOIDCConfigurat Parameters ---------- - id : Union[str, HasID] + id : str | HasIdpConfigurationID The ID of the OIDC configuration to retrieve. Can be either the ID string or an object with an id attribute. @@ -105,7 +105,7 @@ def create(self, config_item: SiteOIDCConfiguration) -> SiteOIDCConfiguration: return SiteOIDCConfiguration.from_response(response.content, self.parent_srv.namespace) @api(version="3.24") - def delete_configuration(self, config: Union[str, HasIdpConfigurationID]) -> None: + def delete_configuration(self, config: str | HasIdpConfigurationID) -> None: """ Delete the OpenID Connect (OIDC) configuration for the currently authenticated Tableau Cloud site. The config parameter can be either @@ -122,7 +122,7 @@ def delete_configuration(self, config: Union[str, HasIdpConfigurationID]) -> Non Parameters ---------- - config : Union[str, HasID] + config : str | HasIdpConfigurationID The OIDC configuration to delete. Can be either the ID of the configuration or the configuration object itself. """ diff --git a/tableauserverclient/server/endpoint/permissions_endpoint.py b/tableauserverclient/server/endpoint/permissions_endpoint.py index 10d420ff7..b31437a70 100644 --- a/tableauserverclient/server/endpoint/permissions_endpoint.py +++ b/tableauserverclient/server/endpoint/permissions_endpoint.py @@ -6,7 +6,7 @@ from .endpoint import Endpoint from .exceptions import MissingRequiredFieldError -from typing import Callable, TYPE_CHECKING, Optional, Union +from typing import Callable, TYPE_CHECKING from tableauserverclient.helpers.logging import logger @@ -44,7 +44,7 @@ def update(self, resource: TableauItem, permissions: list[PermissionsRule]) -> l return permissions - def delete(self, resource: TableauItem, rules: Union[PermissionsRule, list[PermissionsRule]]): + def delete(self, resource: TableauItem, rules: PermissionsRule | list[PermissionsRule]): # Delete is the only endpoint that doesn't take a list of rules # so let's fake it to keep it consistent # TODO that means we need error handling around the call @@ -80,7 +80,7 @@ def permission_fetcher(): item._set_permissions(permission_fetcher) logger.info(f"Populated permissions for item (ID: {item.id})") - def _get_permissions(self, item: TableauItem, req_options: Optional["RequestOptions"] = None): + def _get_permissions(self, item: TableauItem, req_options: "RequestOptions | None" = None): url = f"{self.owner_baseurl()}/{item.id}/permissions" server_response = self.get_request(url, req_options) permissions = PermissionsRule.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index c7a834802..f63110592 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.models import ProjectItem, PaginationItem, Resource -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING from tableauserverclient.server.query import QuerySet @@ -36,7 +36,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/projects" @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ProjectItem], PaginationItem]: + def get(self, req_options: "RequestOptions | None" = None) -> tuple[list[ProjectItem], PaginationItem]: """ Retrieves all projects on the site. The endpoint is paginated and can be filtered using the req_options parameter. @@ -820,7 +820,7 @@ def delete_table_default_permissions(self, item: ProjectItem, rule: PermissionsR """ self._default_permissions.delete_default_permission(item, rule, Resource.Table) - def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ProjectItem]: + def filter(self, *invalid, page_size: int | None = None, **kwargs) -> QuerySet[ProjectItem]: """ Queries the Tableau Server for items using the specified filters. Page size can be specified to limit the number of items returned in a single diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 63c03b3e3..705d33441 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,6 +1,6 @@ import abc import copy -from typing import Generic, Optional, Protocol, TypeVar, Union, TYPE_CHECKING, runtime_checkable +from typing import Generic, Protocol, TypeVar, TYPE_CHECKING, runtime_checkable from collections.abc import Iterable import urllib.parse @@ -73,7 +73,7 @@ class Taggable(Protocol): _initial_tags: set[str] @property - def id(self) -> Optional[str]: + def id(self) -> str | None: pass @@ -96,7 +96,7 @@ def put_request(self, url, request) -> Response: def delete_request(self, url) -> None: pass - def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> set[str]: + def add_tags(self, item: T | str, tags: Iterable[str] | str) -> set[str]: item_id = getattr(item, "id", item) if not isinstance(item_id, str): @@ -112,7 +112,7 @@ def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> set[ server_response = self.put_request(url, add_req) return TagItem.from_response(server_response.content, self.parent_srv.namespace) - def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> None: + def delete_tags(self, item: T | str, tags: Iterable[str] | str) -> None: item_id = getattr(item, "id", item) if not isinstance(item_id, str): @@ -147,7 +147,7 @@ def update_tags(self, item: T) -> None: logger.info(f"Updated tags to {tags}") -content = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] +content = Iterable["ColumnItem | DatabaseItem | DatasourceItem | FlowItem | TableItem | WorkbookItem"] class Tags(Endpoint): @@ -159,7 +159,7 @@ def baseurl(self): return f"{self.parent_srv.baseurl}/tags" @api(version="3.9") - def batch_add(self, tags: Union[Iterable[str], str], content: content) -> set[str]: + def batch_add(self, tags: Iterable[str] | str, content: content) -> set[str]: if isinstance(tags, str): tag_set = {tags} else: @@ -171,7 +171,7 @@ def batch_add(self, tags: Union[Iterable[str], str], content: content) -> set[st return TagItem.from_response(server_response.content, self.parent_srv.namespace) @api(version="3.9") - def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> set[str]: + def batch_delete(self, tags: Iterable[str] | str, content: content) -> set[str]: if isinstance(tags, str): tag_set = {tags} else: diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 99b83e646..941b6eee6 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -3,7 +3,7 @@ import logging import warnings from collections import namedtuple -from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Union, overload +from typing import TYPE_CHECKING, Any, Callable, Literal, overload from .endpoint import Endpoint, api, parameter_added_in from .exceptions import MissingRequiredFieldError @@ -31,7 +31,7 @@ def siteurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/schedules" @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[ScheduleItem], PaginationItem]: + def get(self, req_options: "RequestOptions | None" = None) -> tuple[list[ScheduleItem], PaginationItem]: """ Returns a list of flows, extract, and subscription server schedules on Tableau Server. For each schedule, the API returns name, frequency, @@ -41,7 +41,7 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Sche Parameters ---------- - req_options : Optional[RequestOptions] + req_options : RequestOptions | None Filtering and paginating options for request. Returns @@ -166,10 +166,10 @@ def create(self, schedule_item: ScheduleItem) -> ScheduleItem: def add_to_schedule( self, schedule_id: str, - workbook: Optional["WorkbookItem"] = None, - datasource: Optional["DatasourceItem"] = None, - flow: Optional["FlowItem"] = None, - task_type: Optional[str] = None, + workbook: "WorkbookItem | None" = None, + datasource: "DatasourceItem | None" = None, + flow: "FlowItem | None" = None, + task_type: str | None = None, ) -> list[AddResponse]: """ Adds a workbook, datasource, or flow to a schedule on Tableau Server. @@ -187,16 +187,16 @@ def add_to_schedule( schedule_id : str The ID of the schedule to add the item to. - workbook : Optional[WorkbookItem] + workbook : WorkbookItem | None The workbook to add to the schedule. - datasource : Optional[DatasourceItem] + datasource : DatasourceItem | None The datasource to add to the schedule. - flow : Optional[FlowItem] + flow : FlowItem | None The flow to add to the schedule. - task_type : Optional[str] + task_type : str | None The type of task to add to the schedule. If not provided, it will default to ExtractRefresh if a workbook or datasource is passed in, and RunFlow if a flow is passed in. @@ -210,7 +210,7 @@ def add_to_schedule( if workbook and datasource: warnings.warn("Passing in multiple items for add_to_schedule will be deprecated", PendingDeprecationWarning) items: list[ - tuple[str, Union[WorkbookItem, FlowItem, DatasourceItem], str, Callable[[Optional[str], str], bytes], str] + tuple[str, WorkbookItem | FlowItem | DatasourceItem, str, Callable[[str | None, str], bytes], str] ] = [] if workbook is not None: @@ -234,7 +234,7 @@ def add_to_schedule( def _add_to( self, schedule_id, - resource: Union["DatasourceItem", "WorkbookItem", "FlowItem"], + resource: "DatasourceItem | WorkbookItem | FlowItem", type_: str, req_factory: Callable[ [ @@ -266,7 +266,7 @@ def _add_to( @api(version="2.3") def get_extract_refresh_tasks( - self, schedule_id: str, req_options: Optional["RequestOptions"] = None + self, schedule_id: str, req_options: "RequestOptions | None" = None ) -> tuple[list["ExtractItem"], "PaginationItem"]: """Get all extract refresh tasks for the specified schedule.""" if not schedule_id: diff --git a/tableauserverclient/server/endpoint/server_info_endpoint.py b/tableauserverclient/server/endpoint/server_info_endpoint.py index dc934496a..b725118ab 100644 --- a/tableauserverclient/server/endpoint/server_info_endpoint.py +++ b/tableauserverclient/server/endpoint/server_info_endpoint.py @@ -29,7 +29,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/serverInfo" @api(version="2.4") - def get(self) -> Union[ServerInfoItem, None]: + def get(self) -> ServerInfoItem | None: """ Retrieve the build and version information for the server. diff --git a/tableauserverclient/server/endpoint/sites_endpoint.py b/tableauserverclient/server/endpoint/sites_endpoint.py index e2316fbb8..3b0586fad 100644 --- a/tableauserverclient/server/endpoint/sites_endpoint.py +++ b/tableauserverclient/server/endpoint/sites_endpoint.py @@ -8,7 +8,7 @@ from tableauserverclient.helpers.logging import logger -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING if TYPE_CHECKING: from ..request_options import RequestOptions @@ -31,7 +31,7 @@ def baseurl(self) -> str: # Gets all sites @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SiteItem], PaginationItem]: + def get(self, req_options: "RequestOptions | None" = None) -> tuple[list[SiteItem], PaginationItem]: """ Query all sites on the server. This method requires server admin permissions. This endpoint is paginated, meaning that the server will diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index c9abc9b06..d69424e44 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -7,7 +7,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from ..request_options import RequestOptions @@ -19,7 +19,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/subscriptions" @api(version="2.3") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[SubscriptionItem], PaginationItem]: + def get(self, req_options: "RequestOptions | None" = None) -> tuple[list[SubscriptionItem], PaginationItem]: logger.info("Querying all subscriptions for the site") url = self.baseurl server_response = self.get_request(url, req_options) diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index ad80e7d0e..f50f0df0c 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, Union, TYPE_CHECKING +from typing import TYPE_CHECKING from collections.abc import Iterable from tableauserverclient.models.permissions_item import PermissionsRule @@ -31,7 +31,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tables" @api(version="3.5") - def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[TableItem], PaginationItem]: + def get(self, req_options: RequestOptions | None = None) -> tuple[list[TableItem], PaginationItem]: """ Get information about all tables on the site. Endpoint is paginated, and will return a default of 100 items per page. Use the `req_options` @@ -149,7 +149,7 @@ def update(self, table_item: TableItem) -> TableItem: # Get all columns of the table @api(version="3.5") - def populate_columns(self, table_item: TableItem, req_options: Optional[RequestOptions] = None) -> None: + def populate_columns(self, table_item: TableItem, req_options: RequestOptions | None = None) -> None: """ Populate the columns of a table item. Sets a fetcher function to retrieve the columns when needed. @@ -187,7 +187,7 @@ def column_fetcher(): logger.info(f"Populated columns for table (ID: {table_item.id}") def _get_columns_for_table( - self, table_item: TableItem, req_options: Optional[RequestOptions] = None + self, table_item: TableItem, req_options: RequestOptions | None = None ) -> tuple[list[ColumnItem], PaginationItem]: url = f"{self.baseurl}/{table_item.id}/columns" server_response = self.get_request(url, req_options) @@ -252,11 +252,11 @@ def delete_dqw(self, item: TableItem) -> None: self._data_quality_warnings.clear(item) @api(version="3.9") - def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> set[str]: + def add_tags(self, item: TableItem | str, tags: Iterable[str] | str) -> set[str]: return super().add_tags(item, tags) @api(version="3.9") - def delete_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> None: + def delete_tags(self, item: TableItem | str, tags: Iterable[str] | str) -> None: return super().delete_tags(item, tags) def update_tags(self, item: TableItem) -> None: # type: ignore diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index e1e95041d..d8f645af0 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING from tableauserverclient.server.endpoint.endpoint import Endpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError @@ -29,7 +29,7 @@ def __normalize_task_type(self, task_type: str) -> str: @api(version="2.6") def get( - self, req_options: Optional["RequestOptions"] = None, task_type: str = TaskItem.Type.ExtractRefresh + self, req_options: "RequestOptions | None" = None, task_type: str = TaskItem.Type.ExtractRefresh ) -> tuple[list[TaskItem], PaginationItem]: """ Returns information about tasks on the specified site. diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 6deb76716..966ebd0a8 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -4,7 +4,6 @@ import io import itertools import logging -from typing import Optional import warnings from tableauserverclient.server.query import QuerySet @@ -33,14 +32,14 @@ def baseurl(self) -> str: # Gets all users @api(version="2.0") - def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserItem], PaginationItem]: + def get(self, req_options: RequestOptions | None = None) -> tuple[list[UserItem], PaginationItem]: """ Query all users on the site. Request is paginated and returns a subset of users. By default, the request returns the first 100 users on the site. Parameters ---------- - req_options : Optional[RequestOptions] + req_options : RequestOptions | None Optional request options to filter and sort the results. Returns @@ -156,7 +155,7 @@ def get_by_id(self, user_id: str) -> UserItem: # Update user @api(version="2.0") - def update(self, user_item: UserItem, password: Optional[str] = None) -> UserItem: + def update(self, user_item: UserItem, password: str | None = None) -> UserItem: """ Modifies information about the specified user. @@ -178,7 +177,7 @@ def update(self, user_item: UserItem, password: Optional[str] = None) -> UserIte user_item : UserItem The user item to update. - password : Optional[str] + password : str | None The new password for the user. Returns @@ -211,7 +210,7 @@ def update(self, user_item: UserItem, password: Optional[str] = None) -> UserIte # Delete 1 user by id @api(version="2.0") - def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: + def remove(self, user_id: str, map_assets_to: str | None = None) -> None: """ Removes a user from the site. You can also specify a user to map the assets to when you remove the user. @@ -221,7 +220,7 @@ def remove(self, user_id: str, map_assets_to: Optional[str] = None) -> None: user_id : str The ID of the user to remove. - map_assets_to : Optional[str] + map_assets_to : str | None The ID of the user to map the assets to when you remove the user. Returns @@ -549,7 +548,7 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us # Get workbooks for user @api(version="2.0") def populate_workbooks( - self, user_item: UserItem, req_options: Optional[RequestOptions] = None, owned_only: bool = False + self, user_item: UserItem, req_options: RequestOptions | None = None, owned_only: bool = False ) -> None: """ Returns information about the workbooks that the specified user owns @@ -571,7 +570,7 @@ def populate_workbooks( user_item : UserItem The user item to populate workbooks for. - req_options : Optional[RequestOptions] + req_options : RequestOptions | None Optional request options to filter and sort the results. owned_only : bool, default=False @@ -609,7 +608,7 @@ def func(req_options): def _get_wbs_for_user( self, user_item: UserItem, - req_options: Optional[RequestOptions] = None, + req_options: RequestOptions | None = None, owned_only: bool = False, ) -> tuple[list[WorkbookItem], PaginationItem]: url = f"{self.baseurl}/{user_item.id}/workbooks" @@ -651,7 +650,7 @@ def populate_favorites(self, user_item: UserItem) -> None: # Get groups for user @api(version="3.7") - def populate_groups(self, user_item: UserItem, req_options: Optional[RequestOptions] = None) -> None: + def populate_groups(self, user_item: UserItem, req_options: RequestOptions | None = None) -> None: """ Populate the groups for the user. @@ -660,7 +659,7 @@ def populate_groups(self, user_item: UserItem, req_options: Optional[RequestOpti user_item : UserItem The user item to populate groups for. - req_options : Optional[RequestOptions] + req_options : RequestOptions | None Optional request options to filter and sort the results. Returns @@ -691,7 +690,7 @@ def groups_for_user_pager(): user_item._set_groups(groups_for_user_pager) def _get_groups_for_user( - self, user_item: UserItem, req_options: Optional[RequestOptions] = None + self, user_item: UserItem, req_options: RequestOptions | None = None ) -> tuple[list[GroupItem], PaginationItem]: url = f"{self.baseurl}/{user_item.id}/groups" server_response = self.get_request(url, req_options) @@ -700,7 +699,7 @@ def _get_groups_for_user( pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return group_item, pagination_item - def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[UserItem]: + def filter(self, *invalid, page_size: int | None = None, **kwargs) -> QuerySet[UserItem]: """ Queries the Tableau Server for items using the specified filters. Page size can be specified to limit the number of items returned in a single diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index b95f3be0a..19a30849a 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -12,7 +12,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING, Union +from typing import TYPE_CHECKING from collections.abc import Iterable, Iterator if TYPE_CHECKING: @@ -47,7 +47,7 @@ def baseurl(self) -> str: @api(version="2.2") def get( - self, req_options: Optional["RequestOptions"] = None, usage: bool = False + self, req_options: "RequestOptions | None" = None, usage: bool = False ) -> tuple[list[ViewItem], PaginationItem]: """ Returns the list of views on the site. Paginated endpoint. @@ -56,7 +56,7 @@ def get( Parameters ---------- - req_options: Optional[RequestOptions], default None + req_options: RequestOptions | None, default None The request options for the request. These options can include parameters such as page size and sorting. @@ -142,7 +142,7 @@ def _get_preview_for_view(self, view_item: ViewItem) -> bytes: return image @api(version="2.5") - def populate_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"] = None) -> None: + def populate_image(self, view_item: ViewItem, req_options: "ImageRequestOptions | None" = None) -> None: """ Populates the image of the specified view. @@ -156,7 +156,7 @@ def populate_image(self, view_item: ViewItem, req_options: Optional["ImageReques view_item: ViewItem The view item for which to populate the image. - req_options: Optional[ImageRequestOptions], default None + req_options: ImageRequestOptions | None, default None Optional request options for the request. These options can include parameters such as image resolution, max age, and format (PNG or SVG). @@ -182,14 +182,14 @@ def image_fetcher(): view_item._set_image(image_fetcher) logger.info(f"Populated image for view (ID: {view_item.id})") - def _get_view_image(self, view_item: ViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes: + def _get_view_image(self, view_item: ViewItem, req_options: "ImageRequestOptions | None") -> bytes: url = f"{self.baseurl}/{view_item.id}/image" server_response = self.get_request(url, req_options) image = server_response.content return image @api(version="2.7") - def populate_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None: + def populate_pdf(self, view_item: ViewItem, req_options: "PDFRequestOptions | None" = None) -> None: """ Populates the PDF content of the specified view. @@ -202,7 +202,7 @@ def populate_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOpt view_item: ViewItem The view item for which to populate the PDF. - req_options: Optional[PDFRequestOptions], default None + req_options: PDFRequestOptions | None, default None Optional request options for the request. These options can include parameters such as orientation and paper size. @@ -220,14 +220,14 @@ def pdf_fetcher(): view_item._set_pdf(pdf_fetcher) logger.info(f"Populated pdf for view (ID: {view_item.id})") - def _get_view_pdf(self, view_item: ViewItem, req_options: Optional["PDFRequestOptions"]) -> bytes: + def _get_view_pdf(self, view_item: ViewItem, req_options: "PDFRequestOptions | None") -> bytes: url = f"{self.baseurl}/{view_item.id}/pdf" server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @api(version="2.7") - def populate_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: + def populate_csv(self, view_item: ViewItem, req_options: "CSVRequestOptions | None" = None) -> None: """ Populates the CSV data of the specified view. @@ -241,7 +241,7 @@ def populate_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOpt view_item: ViewItem The view item for which to populate the CSV data. - req_options: Optional[CSVRequestOptions], default None + req_options: CSVRequestOptions | None, default None Optional request options for the request. These options can include parameters such as view filters and max age. @@ -259,14 +259,14 @@ def csv_fetcher(): view_item._set_csv(csv_fetcher) logger.info(f"Populated csv for view (ID: {view_item.id})") - def _get_view_csv(self, view_item: ViewItem, req_options: Optional["CSVRequestOptions"]) -> Iterator[bytes]: + def _get_view_csv(self, view_item: ViewItem, req_options: "CSVRequestOptions | None") -> Iterator[bytes]: url = f"{self.baseurl}/{view_item.id}/data" with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: yield from server_response.iter_content(1024) @api(version="3.8") - def populate_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"] = None) -> None: + def populate_excel(self, view_item: ViewItem, req_options: "ExcelRequestOptions | None" = None) -> None: """ Populates the Excel data of the specified view. @@ -280,7 +280,7 @@ def populate_excel(self, view_item: ViewItem, req_options: Optional["ExcelReques view_item: ViewItem The view item for which to populate the Excel data. - req_options: Optional[ExcelRequestOptions], default None + req_options: ExcelRequestOptions | None, default None Optional request options for the request. These options can include parameters such as view filters and max age. @@ -298,7 +298,7 @@ def excel_fetcher(): view_item._set_excel(excel_fetcher) logger.info(f"Populated excel for view (ID: {view_item.id})") - def _get_view_excel(self, view_item: ViewItem, req_options: Optional["ExcelRequestOptions"]) -> Iterator[bytes]: + def _get_view_excel(self, view_item: ViewItem, req_options: "ExcelRequestOptions | None") -> Iterator[bytes]: url = f"{self.baseurl}/{view_item.id}/crosstab/excel" with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: @@ -399,7 +399,7 @@ def delete(self, view: ViewItem | str) -> None: return None @api(version="1.0") - def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> set[str]: + def add_tags(self, item: ViewItem | str, tags: Iterable[str] | str) -> set[str]: """ Adds tags to the specified view. @@ -407,10 +407,10 @@ def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) Parameters ---------- - item: Union[ViewItem, str] + item: ViewItem | str The view item or view ID to which to add tags. - tags: Union[Iterable[str], str] + tags: Iterable[str] | str The tags to add to the view. Returns @@ -421,7 +421,7 @@ def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) return super().add_tags(item, tags) @api(version="1.0") - def delete_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> None: + def delete_tags(self, item: ViewItem | str, tags: Iterable[str] | str) -> None: """ Deletes tags from the specified view. @@ -429,10 +429,10 @@ def delete_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str Parameters ---------- - item: Union[ViewItem, str] + item: ViewItem | str The view item or view ID from which to delete tags. - tags: Union[Iterable[str], str] + tags: Iterable[str] | str The tags to delete from the view. Returns @@ -460,7 +460,7 @@ def update_tags(self, item: ViewItem) -> None: """ return super().update_tags(item) - def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ViewItem]: + def filter(self, *invalid, page_size: int | None = None, **kwargs) -> QuerySet[ViewItem]: """ Queries the Tableau Server for items using the specified filters. Page size can be specified to limit the number of items returned in a single diff --git a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py index 944b72502..fc00dac1e 100644 --- a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py +++ b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py @@ -1,7 +1,7 @@ from functools import partial import json from pathlib import Path -from typing import Optional, TYPE_CHECKING, Union +from typing import TYPE_CHECKING from collections.abc import Iterable from tableauserverclient.models.connection_item import ConnectionItem @@ -29,7 +29,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/virtualConnections" @api(version="3.18") - def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[VirtualConnectionItem], PaginationItem]: + def get(self, req_options: RequestOptions | None = None) -> tuple[list[VirtualConnectionItem], PaginationItem]: server_response = self.get_request(self.baseurl, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) virtual_connections = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace) @@ -44,7 +44,7 @@ def _connection_fetcher(): return virtual_connection def _get_virtual_database_connections( - self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None + self, virtual_connection: VirtualConnectionItem, req_options: RequestOptions | None = None ) -> tuple[list[ConnectionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/connections", req_options) connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) @@ -54,7 +54,7 @@ def _get_virtual_database_connections( @api(version="3.18") def update_connection_db_connection( - self, virtual_connection: Union[str, VirtualConnectionItem], connection: ConnectionItem + self, virtual_connection: str | VirtualConnectionItem, connection: ConnectionItem ) -> ConnectionItem: vconn_id = getattr(virtual_connection, "id", virtual_connection) url = f"{self.baseurl}/{vconn_id}/connections/{connection.id}/modify" @@ -63,14 +63,14 @@ def update_connection_db_connection( return ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.23") - def get_by_id(self, virtual_connection: Union[str, VirtualConnectionItem]) -> VirtualConnectionItem: + def get_by_id(self, virtual_connection: str | VirtualConnectionItem) -> VirtualConnectionItem: vconn_id = getattr(virtual_connection, "id", virtual_connection) url = f"{self.baseurl}/{vconn_id}" server_response = self.get_request(url) return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.23") - def download(self, virtual_connection: Union[str, VirtualConnectionItem]) -> str: + def download(self, virtual_connection: str | VirtualConnectionItem) -> str: v_conn = self.get_by_id(virtual_connection) return json.dumps(v_conn.content) @@ -83,7 +83,7 @@ def update(self, virtual_connection: VirtualConnectionItem) -> VirtualConnection @api(version="3.23") def get_revisions( - self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None + self, virtual_connection: VirtualConnectionItem, req_options: RequestOptions | None = None ) -> tuple[list[RevisionItem], PaginationItem]: server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/revisions", req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) @@ -98,7 +98,7 @@ def download_revision(self, virtual_connection: VirtualConnectionItem, revision_ return json.dumps(virtual_connection.content) @api(version="3.23") - def delete(self, virtual_connection: Union[VirtualConnectionItem, str]) -> None: + def delete(self, virtual_connection: VirtualConnectionItem | str) -> None: vconn_id = getattr(virtual_connection, "id", virtual_connection) self.delete_request(f"{self.baseurl}/{vconn_id}") @@ -158,15 +158,11 @@ def delete_permission(self, item, capability_item): return self._permissions.delete(item, capability_item) @api(version="3.23") - def add_tags( - self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str] - ) -> set[str]: + def add_tags(self, virtual_connection: VirtualConnectionItem | str, tags: Iterable[str] | str) -> set[str]: return super().add_tags(virtual_connection, tags) @api(version="3.23") - def delete_tags( - self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str] - ) -> None: + def delete_tags(self, virtual_connection: VirtualConnectionItem | str, tags: Iterable[str] | str) -> None: return super().delete_tags(virtual_connection, tags) @api(version="3.23") diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index e5c7b5897..449fdbdf5 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -6,7 +6,7 @@ from tableauserverclient.helpers.logging import logger -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from ..server import Server @@ -22,7 +22,7 @@ def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/webhooks" @api(version="3.6") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WebhookItem], PaginationItem]: + def get(self, req_options: "RequestOptions | None" = None) -> tuple[list[WebhookItem], PaginationItem]: """ Returns a list of all webhooks on the site. @@ -30,7 +30,7 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[Webh Parameters ---------- - req_options : Optional[RequestOptions] + req_options : RequestOptions | None Filter and sorting options for the request. Returns diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 14297518a..ce605806f 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -55,12 +55,12 @@ from tableauserverclient.helpers.logging import logger -FilePath = Union[str, os.PathLike] -FileObject = Union[io.BufferedReader, io.BytesIO] -FileObjectR = Union[io.BufferedReader, io.BytesIO] -FileObjectW = Union[io.BufferedWriter, io.BytesIO] -PathOrFileR = Union[FilePath, FileObjectR] -PathOrFileW = Union[FilePath, FileObjectW] +FilePath = str | os.PathLike +FileObject = io.BufferedReader | io.BytesIO +FileObjectR = io.BufferedReader | io.BytesIO +FileObjectW = io.BufferedWriter | io.BytesIO +PathOrFileR = FilePath | FileObjectR +PathOrFileW = FilePath | FileObjectW class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): @@ -76,7 +76,7 @@ def baseurl(self) -> str: # Get all workbooks on site @api(version="2.0") - def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[WorkbookItem], PaginationItem]: + def get(self, req_options: "RequestOptions | None" = None) -> tuple[list[WorkbookItem], PaginationItem]: """ Queries the server and returns information about the workbooks the site. @@ -125,7 +125,7 @@ def get_by_id(self, workbook_id: str) -> WorkbookItem: return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.8") - def refresh(self, workbook_item: Union[WorkbookItem, str], incremental: bool = False) -> JobItem | None: + def refresh(self, workbook_item: WorkbookItem | str, incremental: bool = False) -> JobItem | None: """ Refreshes the extract of an existing workbook. @@ -161,7 +161,7 @@ def create_extract( workbook_item: WorkbookItem, encrypt: bool = False, includeAll: bool = True, - datasources: Optional[list["DatasourceItem"]] = None, + datasources: list["DatasourceItem"] | None = None, ) -> JobItem: """ Create one or more extracts on 1 workbook, optionally encrypted. @@ -340,10 +340,10 @@ def update_connections( self, workbook_item: WorkbookItem, connection_luids: Iterable[str], - authentication_type: Optional[str] = None, - username: Optional[str] = None, - password: Optional[str] = None, - embed_password: Optional[bool] = None, + authentication_type: str | None = None, + username: str | None = None, + password: str | None = None, + embed_password: bool | None = None, ) -> list[ConnectionItem]: """ Bulk updates one or more workbook connections by LUID. @@ -410,7 +410,7 @@ def download( def download( self, workbook_id: str, - filepath: Optional[FilePath] = None, + filepath: FilePath | None = None, include_extract: bool = True, ) -> str: ... @@ -558,7 +558,7 @@ def connection_fetcher(): logger.info(f"Populated connections for workbook (ID: {workbook_item.id})") def _get_workbook_connections( - self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None + self, workbook_item: WorkbookItem, req_options: "RequestOptions | None" = None ) -> list[ConnectionItem]: url = f"{self.baseurl}/{workbook_item.id}/connections" server_response = self.get_request(url, req_options) @@ -566,7 +566,7 @@ def _get_workbook_connections( return connections @api(version="3.4") - def populate_pdf(self, workbook_item: WorkbookItem, req_options: Optional["PDFRequestOptions"] = None) -> None: + def populate_pdf(self, workbook_item: WorkbookItem, req_options: "PDFRequestOptions | None" = None) -> None: """ Populates the PDF for the specified workbook item. Get the pdf of the entire workbook if its tabs are enabled, pdf of the default view if its @@ -614,16 +614,14 @@ def pdf_fetcher() -> bytes: workbook_item._set_pdf(pdf_fetcher) logger.info(f"Populated pdf for workbook (ID: {workbook_item.id})") - def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: Optional["PDFRequestOptions"]) -> bytes: + def _get_wb_pdf(self, workbook_item: WorkbookItem, req_options: "PDFRequestOptions | None") -> bytes: url = f"{self.baseurl}/{workbook_item.id}/pdf" server_response = self.get_request(url, req_options) pdf = server_response.content return pdf @api(version="3.8") - def populate_powerpoint( - self, workbook_item: WorkbookItem, req_options: Optional["PPTXRequestOptions"] = None - ) -> None: + def populate_powerpoint(self, workbook_item: WorkbookItem, req_options: "PPTXRequestOptions | None" = None) -> None: """ Populates the PowerPoint for the specified workbook item. @@ -664,7 +662,7 @@ def pptx_fetcher() -> bytes: workbook_item._set_powerpoint(pptx_fetcher) logger.info(f"Populated powerpoint for workbook (ID: {workbook_item.id})") - def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: Optional["PPTXRequestOptions"]) -> bytes: + def _get_wb_pptx(self, workbook_item: WorkbookItem, req_options: "PPTXRequestOptions | None") -> bytes: url = f"{self.baseurl}/{workbook_item.id}/powerpoint" server_response = self.get_request(url, req_options) pptx = server_response.content @@ -778,7 +776,7 @@ def publish( workbook_item: WorkbookItem, file: PathOrFileR, mode: str, - connections: Optional[Sequence[ConnectionItem]], + connections: Sequence[ConnectionItem] | None, as_job: Literal[False], skip_connection_check: bool, parameters=None, @@ -790,7 +788,7 @@ def publish( workbook_item: WorkbookItem, file: PathOrFileR, mode: str, - connections: Optional[Sequence[ConnectionItem]], + connections: Sequence[ConnectionItem] | None, as_job: Literal[True], skip_connection_check: bool, parameters=None, @@ -804,7 +802,7 @@ def publish( workbook_item: WorkbookItem, file: PathOrFileR, mode: str, - connections: Optional[Sequence[ConnectionItem]] = None, + connections: Sequence[ConnectionItem] | None = None, as_job: bool = False, skip_connection_check: bool = False, parameters=None, @@ -1025,7 +1023,7 @@ def revisions_fetcher(): logger.info(f"Populated revisions for workbook (ID: {workbook_item.id})") def _get_workbook_revisions( - self, workbook_item: WorkbookItem, req_options: Optional["RequestOptions"] = None + self, workbook_item: WorkbookItem, req_options: "RequestOptions | None" = None ) -> list[RevisionItem]: url = f"{self.baseurl}/{workbook_item.id}/revisions" server_response = self.get_request(url, req_options) @@ -1036,12 +1034,12 @@ def _get_workbook_revisions( @overload def download_revision( - self, workbook_id: str, revision_number: Optional[str], filepath: T, include_extract: bool + self, workbook_id: str, revision_number: str | None, filepath: T, include_extract: bool ) -> T: ... @overload def download_revision( - self, workbook_id: str, revision_number: Optional[str], filepath: Optional[FilePath], include_extract: bool + self, workbook_id: str, revision_number: str | None, filepath: FilePath | None, include_extract: bool ) -> str: ... # Download 1 workbook revision by revision number @@ -1174,7 +1172,7 @@ def schedule_extract_refresh( return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) @api(version="1.0") - def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> set[str]: + def add_tags(self, item: WorkbookItem | str, tags: Iterable[str] | str) -> set[str]: """ Adds tags to a workbook. One or more tags may be added at a time. If a tag already exists on the workbook, it will not be duplicated. @@ -1198,7 +1196,7 @@ def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], st return super().add_tags(item, tags) @api(version="1.0") - def delete_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> None: + def delete_tags(self, item: WorkbookItem | str, tags: Iterable[str] | str) -> None: """ Deletes tags from a workbook. One or more tags may be deleted at a time. @@ -1239,7 +1237,7 @@ def update_tags(self, item: WorkbookItem) -> None: """ return super().update_tags(item) - def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[WorkbookItem]: + def filter(self, *invalid, page_size: int | None = None, **kwargs) -> QuerySet[WorkbookItem]: """ Queries the Tableau Server for items using the specified filters. Page size can be specified to limit the number of items returned in a single diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index 3c7e60f74..ec1425d6f 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,6 +1,6 @@ import copy from functools import partial -from typing import Optional, Protocol, TypeVar, Union, runtime_checkable +from typing import Protocol, TypeVar, runtime_checkable from collections.abc import Iterable, Iterator from tableauserverclient.models.pagination_item import PaginationItem @@ -12,12 +12,12 @@ @runtime_checkable class Endpoint(Protocol[T]): - def get(self, req_options: Optional[RequestOptions]) -> tuple[list[T], PaginationItem]: ... + def get(self, req_options: RequestOptions | None) -> tuple[list[T], PaginationItem]: ... @runtime_checkable class CallableEndpoint(Protocol[T]): - def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> tuple[list[T], PaginationItem]: ... + def __call__(self, __req_options: RequestOptions | None, **kwargs) -> tuple[list[T], PaginationItem]: ... class Pager(Iterable[T]): @@ -55,8 +55,8 @@ class Pager(Iterable[T]): def __init__( self, - endpoint: Union[CallableEndpoint[T], Endpoint[T]], - request_opts: Optional[RequestOptions] = None, + endpoint: CallableEndpoint[T] | Endpoint[T], + request_opts: RequestOptions | None = None, **kwargs, ) -> None: if isinstance(endpoint, Endpoint): diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 5137cee52..6027a914e 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,6 +1,6 @@ from collections.abc import Iterable, Iterator, Sized from itertools import count -from typing import Optional, Protocol, TYPE_CHECKING, TypeVar, overload +from typing import Protocol, TYPE_CHECKING, TypeVar, overload import sys from tableauserverclient.config import config from tableauserverclient.models.pagination_item import PaginationItem @@ -19,9 +19,9 @@ class Slice(Protocol): - start: Optional[int] - step: Optional[int] - stop: Optional[int] + start: int | None + step: int | None + stop: int | None def to_camel_case(word: str) -> str: @@ -62,7 +62,7 @@ class QuerySet(Iterable[T], Sized): contains the item you are looking for. """ - def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None: + def __init__(self, model: "QuerysetEndpoint[T]", page_size: int | None = None) -> None: self.model = model self.request_options = RequestOptions(pagesize=page_size or config.PAGE_SIZE) self._result_cache: list[T] = [] @@ -184,7 +184,7 @@ def page_size(self: Self) -> int: # pagesize from the RequestOptions. return self._pagination_item.page_size or self.request_options.pagesize - def filter(self: Self, *invalid, page_size: Optional[int] = None, **kwargs) -> Self: + def filter(self: Self, *invalid, page_size: int | None = None, **kwargs) -> Self: if invalid: raise RuntimeError("Only accepts keyword arguments.") for kwarg_key, value in kwargs.items(): diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 589b6beb4..f2e1db382 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,5 +1,5 @@ import xml.etree.ElementTree as ET -from typing import Any, Callable, Optional, TypeVar, TYPE_CHECKING, Union +from typing import Any, Callable, TypeVar, TYPE_CHECKING from collections.abc import Iterable from typing_extensions import ParamSpec @@ -254,10 +254,10 @@ def update_connections_req( self, element: ET.Element, connection_luids: Iterable[str], - authentication_type: Optional[str] = None, - username: Optional[str] = None, - password: Optional[str] = None, - embed_password: Optional[bool] = None, + authentication_type: str | None = None, + username: str | None = None, + password: str | None = None, + embed_password: bool | None = None, ): conn_luids_elem = ET.SubElement(element, "connectionLUIDs") for luid in connection_luids: @@ -308,7 +308,7 @@ def update_req(self, dqw_item): class FavoriteRequest: - def add_request(self, id_: Optional[str], target_type: str, label: Optional[str]) -> bytes: + def add_request(self, id_: str | None, target_type: str, label: str | None) -> bytes: """ @@ -326,35 +326,35 @@ def add_request(self, id_: Optional[str], target_type: str, label: Optional[str] return ET.tostring(xml_request) - def add_datasource_req(self, id_: Optional[str], name: Optional[str]) -> bytes: + def add_datasource_req(self, id_: str | None, name: str | None) -> bytes: if id_ is None: raise ValueError("id must exist to add to favorites") if name is None: raise ValueError("Name must exist to add to favorites.") return self.add_request(id_, Resource.Datasource, name) - def add_flow_req(self, id_: Optional[str], name: Optional[str]) -> bytes: + def add_flow_req(self, id_: str | None, name: str | None) -> bytes: if id_ is None: raise ValueError("id must exist to add to favorites") if name is None: raise ValueError("Name must exist to add to favorites.") return self.add_request(id_, Resource.Flow, name) - def add_project_req(self, id_: Optional[str], name: Optional[str]) -> bytes: + def add_project_req(self, id_: str | None, name: str | None) -> bytes: if id_ is None: raise ValueError("id must exist to add to favorites") if name is None: raise ValueError("Name must exist to add to favorites.") return self.add_request(id_, Resource.Project, name) - def add_view_req(self, id_: Optional[str], name: Optional[str]) -> bytes: + def add_view_req(self, id_: str | None, name: str | None) -> bytes: if id_ is None: raise ValueError("id must exist to add to favorites") if name is None: raise ValueError("Name must exist to add to favorites.") return self.add_request(id_, Resource.View, name) - def add_workbook_req(self, id_: Optional[str], name: Optional[str]) -> bytes: + def add_workbook_req(self, id_: str | None, name: str | None) -> bytes: if id_ is None: raise ValueError("id must exist to add to favorites") if name is None: @@ -372,7 +372,7 @@ def chunk_req(self, chunk): class FlowRequest: - def _generate_xml(self, flow_item: "FlowItem", connections: Optional[list["ConnectionItem"]] = None) -> bytes: + def _generate_xml(self, flow_item: "FlowItem", connections: list["ConnectionItem"] | None = None) -> bytes: xml_request = ET.Element("tsRequest") flow_element = ET.SubElement(xml_request, "flow") if flow_item.name is not None: @@ -403,7 +403,7 @@ def publish_req( flow_item: "FlowItem", filename: str, file_contents: bytes, - connections: Optional[list["ConnectionItem"]] = None, + connections: list["ConnectionItem"] | None = None, ) -> tuple[Any, str]: xml_request = self._generate_xml(flow_item, connections) @@ -428,7 +428,7 @@ def add_user_req(self, user_id: str) -> bytes: return ET.tostring(xml_request) @_tsrequest_wrapped - def add_users_req(self, xml_request: ET.Element, users: Iterable[Union[str, UserItem]]) -> bytes: + def add_users_req(self, xml_request: ET.Element, users: Iterable[str | UserItem]) -> bytes: users_element = ET.SubElement(xml_request, "users") for user in users: user_element = ET.SubElement(users_element, "user") @@ -439,7 +439,7 @@ def add_users_req(self, xml_request: ET.Element, users: Iterable[Union[str, User return ET.tostring(xml_request) @_tsrequest_wrapped - def remove_users_req(self, xml_request: ET.Element, users: Iterable[Union[str, UserItem]]) -> bytes: + def remove_users_req(self, xml_request: ET.Element, users: Iterable[str | UserItem]) -> bytes: users_element = ET.SubElement(xml_request, "users") for user in users: user_element = ET.SubElement(users_element, "user") @@ -616,7 +616,7 @@ def update_req(self, schedule_item): single_interval_element.attrib[expression] = value return ET.tostring(xml_request) - def _add_to_req(self, id_: Optional[str], target_type: str, task_type: str = TaskItem.Type.ExtractRefresh) -> bytes: + def _add_to_req(self, id_: str | None, target_type: str, task_type: str = TaskItem.Type.ExtractRefresh) -> bytes: """ @@ -635,13 +635,13 @@ def _add_to_req(self, id_: Optional[str], target_type: str, task_type: str = Tas return ET.tostring(xml_request) - def add_workbook_req(self, id_: Optional[str], task_type: str = TaskItem.Type.ExtractRefresh) -> bytes: + def add_workbook_req(self, id_: str | None, task_type: str = TaskItem.Type.ExtractRefresh) -> bytes: return self._add_to_req(id_, "workbook", task_type) - def add_datasource_req(self, id_: Optional[str], task_type: str = TaskItem.Type.ExtractRefresh) -> bytes: + def add_datasource_req(self, id_: str | None, task_type: str = TaskItem.Type.ExtractRefresh) -> bytes: return self._add_to_req(id_, "datasource", task_type) - def add_flow_req(self, id_: Optional[str], task_type: str = TaskItem.Type.RunFlow) -> bytes: + def add_flow_req(self, id_: str | None, task_type: str = TaskItem.Type.RunFlow) -> bytes: return self._add_to_req(id_, "flow", task_type) @_tsrequest_wrapped @@ -656,7 +656,7 @@ def batch_update_state(self, xml: ET.Element, schedules: Iterable[ScheduleItem | class SiteRequest: - def update_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None): + def update_req(self, site_item: "SiteItem", parent_srv: "Server | None" = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") if site_item.name: @@ -766,7 +766,7 @@ def update_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = Non return ET.tostring(xml_request) # server: the site request model changes based on api version - def create_req(self, site_item: "SiteItem", parent_srv: Optional["Server"] = None): + def create_req(self, site_item: "SiteItem", parent_srv: "Server | None" = None): xml_request = ET.Element("tsRequest") site_element = ET.SubElement(xml_request, "site") site_element.attrib["name"] = site_item.name @@ -918,7 +918,7 @@ def update_req(self, table_item): return ET.tostring(xml_request) -content_types = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] +content_types = Iterable["ColumnItem | DatabaseItem | DatasourceItem | FlowItem | TableItem | WorkbookItem"] class TagRequest: @@ -949,7 +949,7 @@ def batch_create(self, element: ET.Element, tags: set[str], content: content_typ class UserRequest: - def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: + def update_req(self, user_item: UserItem, password: str | None) -> bytes: xml_request = ET.Element("tsRequest") user_element = ET.SubElement(xml_request, "user") if user_item.fullname: @@ -1047,7 +1047,7 @@ def _generate_xml( return ET.tostring(xml_request) - def update_req(self, workbook_item, parent_srv: Optional["Server"] = None): + def update_req(self, workbook_item, parent_srv: "Server | None" = None): xml_request = ET.Element("tsRequest") workbook_element = ET.SubElement(xml_request, "workbook") if workbook_item.name: @@ -1157,7 +1157,7 @@ def publish_req_chunked( @_tsrequest_wrapped def embedded_extract_req( - self, xml_request: ET.Element, include_all: bool = True, datasources: Optional[Iterable[DatasourceItem]] = None + self, xml_request: ET.Element, include_all: bool = True, datasources: Iterable[DatasourceItem] | None = None ) -> None: list_element = ET.SubElement(xml_request, "datasources") if include_all: @@ -1173,10 +1173,10 @@ def update_connections_req( self, element: ET.Element, connection_luids: Iterable[str], - authentication_type: Optional[str] = None, - username: Optional[str] = None, - password: Optional[str] = None, - embed_password: Optional[bool] = None, + authentication_type: str | None = None, + username: str | None = None, + password: str | None = None, + embed_password: bool | None = None, ): conn_luids_elem = ET.SubElement(element, "connectionLUIDs") for luid in connection_luids: @@ -1229,8 +1229,8 @@ def run_req(self, xml_request: ET.Element, task_item: Any) -> None: @_tsrequest_wrapped def refresh_req( - self, xml_request: ET.Element, incremental: bool = False, parent_srv: Optional["Server"] = None - ) -> Optional[bytes]: + self, xml_request: ET.Element, incremental: bool = False, parent_srv: "Server | None" = None + ) -> bytes | None: if parent_srv is not None and parent_srv.check_at_least_version("3.25"): task_element = ET.SubElement(xml_request, "extractRefresh") if incremental: diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 870435eb0..15cf2e0f4 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,5 +1,4 @@ import sys -from typing import Optional import warnings from typing_extensions import Self @@ -317,25 +316,26 @@ class Workbook: class _DataExportOptions(RequestOptionsBase): - def __init__(self, maxage: int = -1): + def __init__(self, maxage: int | None = -1): super().__init__() self.view_filters: list[tuple[str, str]] = [] self.view_parameters: list[tuple[str, str]] = [] - self.max_age: Optional[int] = maxage + self._max_age: int | None = None + self.max_age: int | None = maxage """ This setting will affect the contents of the workbook as they are exported. Valid language values are tableau-supported languages like de, es, en If no locale is specified, the default locale for that language will be used """ - self.language: Optional[str] = None + self.language: str | None = None @property - def max_age(self) -> int: + def max_age(self) -> int | None: return self._max_age @max_age.setter - @property_is_int(range=(0, 240), allowed=[-1]) - def max_age(self, value): + @property_is_int(range=(0, 240), allowed=[-1, None]) + def max_age(self, value: int | None) -> None: self._max_age = value def get_query_params(self): diff --git a/test/_utils.py b/test/_utils.py index a23f37b57..debdf2c38 100644 --- a/test/_utils.py +++ b/test/_utils.py @@ -1,6 +1,5 @@ import os.path import unittest -from typing import Optional from xml.etree import ElementTree as ET from contextlib import contextmanager diff --git a/test/test_datasource.py b/test/test_datasource.py index a0890f3a5..cb1157bc5 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -2,7 +2,6 @@ import os from pathlib import Path import tempfile -from typing import Optional import unittest from zipfile import ZipFile @@ -188,7 +187,7 @@ def test_populate_connections(server) -> None: 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 + connections: list[ConnectionItem] | None = single_datasource.connections assert connections is not None ds1, ds2 = connections diff --git a/test/test_tagging.py b/test/test_tagging.py index 8bfc90386..842e27c12 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -1,7 +1,7 @@ from contextlib import ExitStack import re from collections.abc import Iterable -from typing import Optional, Protocol +from typing import Protocol import uuid from xml.etree import ElementTree as ET @@ -201,7 +201,7 @@ def test_update_tags(get_server, endpoint_type, item, tags) -> None: class HasID(Protocol): @property - def id(self) -> Optional[str]: ... + def id(self) -> str | None: ... def test_tags_batch_add(get_server) -> None: From 387d0434014c9466795dbe0f6a9febf5e2fd9a84 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:59:44 -0700 Subject: [PATCH 165/219] chore(deps-dev): bump black from 24.10 to 26.3.1 (#1780) * chore(deps-dev): bump black from 24.10 to 26.3.1 Bumps [black](https://github.com/psf/black) from 24.10 to 26.3.1. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/24.10.0...26.3.1) --- updated-dependencies: - dependency-name: black dependency-version: 26.3.1 dependency-type: direct:development ... Signed-off-by: dependabot[bot] * style: apply black 26.3.1 formatting Co-Authored-By: Claude Sonnet 4.6 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jac Fitzgerald Co-authored-by: Claude Sonnet 4.6 --- .gitignore | 320 +++++++++--------- pyproject.toml | 2 +- samples/export.py | 214 ++++++------ samples/smoke_test.py | 31 +- tableauserverclient/datetime_helpers.py | 89 +++-- tableauserverclient/helpers/strings.py | 133 ++++---- tableauserverclient/models/column_item.py | 142 ++++---- tableauserverclient/models/data_alert_item.py | 4 +- .../models/subscription_item.py | 284 ++++++++-------- .../server/endpoint/data_alert_endpoint.py | 1 - .../server/endpoint/schedules_endpoint.py | 2 +- tableauserverclient/server/pager.py | 1 - tableauserverclient/server/request_factory.py | 2 +- tableauserverclient/server/server.py | 1 - test/test_extensions.py | 1 - test/test_job.py | 1 - 16 files changed, 608 insertions(+), 620 deletions(-) diff --git a/.gitignore b/.gitignore index b3b3ff80f..6f8a86128 100644 --- a/.gitignore +++ b/.gitignore @@ -1,159 +1,161 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class -test.junit.xml - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -*.egg-info/ -.installed.cfg -*.egg -pip-wheel-metadata/ - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*,cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# PyCharm stuff -.idea/ - -# IPython Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# poetry -poetry.lock - -# celery beat schedule file -celerybeat-schedule - -# dotenv -.env -env.py - -# virtualenv -venv/ -ENV/ -.venv/ - -# Spyder project settings -.spyderproject - -# Rope project settings -.ropeproject - -# VSCode project settings -.vscode/ - -# macOS.gitignore from https://github.com/github/gitignore -*.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - - - -# Windows.gitignore from https://github.com/github/gitignore -# Windows image file caches -Thumbs.db -ehthumbs.db - -# Folder config file -Desktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msm -*.msp - -# Windows shortcuts -*.lnk - -# Documentation -docs/_site/ -docs/.jekyll-metadata -docs/Gemfile.lock -samples/credentials -.venv/ +.claude/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +test.junit.xml + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg +pip-wheel-metadata/ + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# PyCharm stuff +.idea/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# poetry +poetry.lock + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env +env.py + +# virtualenv +venv/ +ENV/ +.venv/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +# VSCode project settings +.vscode/ + +# macOS.gitignore from https://github.com/github/gitignore +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + + + +# Windows.gitignore from https://github.com/github/gitignore +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Documentation +docs/_site/ +docs/.jekyll-metadata +docs/Gemfile.lock +samples/credentials +.venv/ diff --git a/pyproject.toml b/pyproject.toml index c832ffe71..ebb4fe8be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ 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", +test = ["black==26.3.1", "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.package-data] diff --git a/samples/export.py b/samples/export.py index b2506cf46..c7f1cdb06 100644 --- a/samples/export.py +++ b/samples/export.py @@ -1,107 +1,107 @@ -#### -# This script demonstrates how to export a view using the Tableau -# Server Client. -# -# To run the script, you must have installed Python 3.7 or later. -#### - -import argparse -import logging - -import tableauserverclient as TSC - - -def main(): - parser = argparse.ArgumentParser(description="Export a view as an image, PDF, or CSV") - # 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 - group = parser.add_mutually_exclusive_group(required=True) - group.add_argument( - "--pdf", dest="type", action="store_const", const=("populate_pdf", "PDFRequestOptions", "pdf", "pdf") - ) - group.add_argument( - "--png", dest="type", action="store_const", const=("populate_image", "ImageRequestOptions", "image", "png") - ) - group.add_argument( - "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") - ) - # other options shown in explore_workbooks: workbook.download, workbook.preview_image - parser.add_argument( - "--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en" - ) - parser.add_argument("--workbook", action="store_true") - parser.add_argument("--custom_view", action="store_true") - - parser.add_argument("--file", "-f", help="filename to store the exported data") - parser.add_argument("--filter", "-vf", metavar="COLUMN:VALUE", help="View filter to apply to the view") - parser.add_argument("resource_id", help="LUID for the view or workbook") - - 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) - - 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): - print("Connected") - if args.workbook: - item = server.workbooks.get_by_id(args.resource_id) - elif args.custom_view: - item = server.custom_views.get_by_id(args.resource_id) - else: - item = server.views.get_by_id(args.resource_id) - - if not item: - print(f"No item found for id {args.resource_id}") - exit(1) - - print(f"Item found: {item.name}") - # We have a number of different types and functions for each different export type. - # We encode that information above in the const=(...) parameter to the add_argument function to make - # the code automatically adapt for the type of export the user is doing. - # We unroll that information into methods we can call, or objects we can create by using getattr() - (populate_func_name, option_factory_name, member_name, extension) = args.type - populate = getattr(server.views, populate_func_name) - if args.workbook: - populate = getattr(server.workbooks, populate_func_name) - elif args.custom_view: - populate = getattr(server.custom_views, populate_func_name) - - option_factory = getattr(TSC, option_factory_name) - options: TSC.PDFRequestOptions = option_factory() - - if args.filter: - options = options.vf(*args.filter.split(":")) - - if args.language: - options.language = args.language - - if args.file: - filename = args.file - else: - filename = f"out-{options.language}.{extension}" - - populate(item, options) - with open(filename, "wb") as f: - if member_name == "csv": - f.writelines(getattr(item, member_name)) - else: - f.write(getattr(item, member_name)) - print("saved to " + filename) - - -if __name__ == "__main__": - main() +#### +# This script demonstrates how to export a view using the Tableau +# Server Client. +# +# To run the script, you must have installed Python 3.7 or later. +#### + +import argparse +import logging + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description="Export a view as an image, PDF, or CSV") + # 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 + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + "--pdf", dest="type", action="store_const", const=("populate_pdf", "PDFRequestOptions", "pdf", "pdf") + ) + group.add_argument( + "--png", dest="type", action="store_const", const=("populate_image", "ImageRequestOptions", "image", "png") + ) + group.add_argument( + "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") + ) + # other options shown in explore_workbooks: workbook.download, workbook.preview_image + parser.add_argument( + "--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en" + ) + parser.add_argument("--workbook", action="store_true") + parser.add_argument("--custom_view", action="store_true") + + parser.add_argument("--file", "-f", help="filename to store the exported data") + parser.add_argument("--filter", "-vf", metavar="COLUMN:VALUE", help="View filter to apply to the view") + parser.add_argument("resource_id", help="LUID for the view or workbook") + + 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) + + 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): + print("Connected") + if args.workbook: + item = server.workbooks.get_by_id(args.resource_id) + elif args.custom_view: + item = server.custom_views.get_by_id(args.resource_id) + else: + item = server.views.get_by_id(args.resource_id) + + if not item: + print(f"No item found for id {args.resource_id}") + exit(1) + + print(f"Item found: {item.name}") + # We have a number of different types and functions for each different export type. + # We encode that information above in the const=(...) parameter to the add_argument function to make + # the code automatically adapt for the type of export the user is doing. + # We unroll that information into methods we can call, or objects we can create by using getattr() + populate_func_name, option_factory_name, member_name, extension = args.type + populate = getattr(server.views, populate_func_name) + if args.workbook: + populate = getattr(server.workbooks, populate_func_name) + elif args.custom_view: + populate = getattr(server.custom_views, populate_func_name) + + option_factory = getattr(TSC, option_factory_name) + options: TSC.PDFRequestOptions = option_factory() + + if args.filter: + options = options.vf(*args.filter.split(":")) + + if args.language: + options.language = args.language + + if args.file: + filename = args.file + else: + filename = f"out-{options.language}.{extension}" + + populate(item, options) + with open(filename, "wb") as f: + if member_name == "csv": + f.writelines(getattr(item, member_name)) + else: + f.write(getattr(item, member_name)) + print("saved to " + filename) + + +if __name__ == "__main__": + main() diff --git a/samples/smoke_test.py b/samples/smoke_test.py index b23eacdb8..237257014 100644 --- a/samples/smoke_test.py +++ b/samples/smoke_test.py @@ -1,16 +1,15 @@ -# This sample verifies that tableau server client is installed -# and you can run it. It also shows the version of the client. - -import logging -import tableauserverclient as TSC - - -logger = logging.getLogger("Sample") -logger.setLevel(logging.DEBUG) -logger.addHandler(logging.StreamHandler()) - - -server = TSC.Server("Fake-Server-Url", use_server_version=False) -print("Client details:") -logger.info(server.server_address) -logger.debug(TSC.server.endpoint.Endpoint.set_user_agent({})) +# This sample verifies that tableau server client is installed +# and you can run it. It also shows the version of the client. + +import logging +import tableauserverclient as TSC + +logger = logging.getLogger("Sample") +logger.setLevel(logging.DEBUG) +logger.addHandler(logging.StreamHandler()) + + +server = TSC.Server("Fake-Server-Url", use_server_version=False) +print("Client details:") +logger.info(server.server_address) +logger.debug(TSC.server.endpoint.Endpoint.set_user_agent({})) diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index 00f62faf8..e18db4c78 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -1,45 +1,44 @@ -import datetime - - -ZERO = datetime.timedelta(0) -HOUR = datetime.timedelta(hours=1) - - -def timestamp(): - return datetime.datetime.now().strftime("%H:%M:%S") - - -# This class is a concrete implementation of the abstract base class tzinfo -# docs: https://docs.python.org/2.3/lib/datetime-tzinfo.html -class UTC(datetime.tzinfo): - """UTC""" - - def utcoffset(self, dt): - return ZERO - - def tzname(self, dt): - return "UTC" - - def dst(self, dt): - return ZERO - - -utc = UTC() -TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" - - -def parse_datetime(date): - if date is None: - return None - - try: - return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc) - except ValueError: - return None - - -def format_datetime(date): - if date is None: - return None - - return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT) +import datetime + +ZERO = datetime.timedelta(0) +HOUR = datetime.timedelta(hours=1) + + +def timestamp(): + return datetime.datetime.now().strftime("%H:%M:%S") + + +# This class is a concrete implementation of the abstract base class tzinfo +# docs: https://docs.python.org/2.3/lib/datetime-tzinfo.html +class UTC(datetime.tzinfo): + """UTC""" + + def utcoffset(self, dt): + return ZERO + + def tzname(self, dt): + return "UTC" + + def dst(self, dt): + return ZERO + + +utc = UTC() +TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + + +def parse_datetime(date): + if date is None: + return None + + try: + return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc) + except ValueError: + return None + + +def format_datetime(date): + if date is None: + return None + + return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT) diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py index 6ba4e48d9..7a0eaddc7 100644 --- a/tableauserverclient/helpers/strings.py +++ b/tableauserverclient/helpers/strings.py @@ -1,67 +1,66 @@ -from defusedxml.ElementTree import fromstring, tostring -from functools import singledispatch -from typing import TypeVar, overload - - -# the redact method can handle either strings or bytes, but it can't mix them. -# Generic type so we can write the actual logic once, then use singledispatch to -# create the replacement text with the correct type -T = TypeVar("T", str, bytes) - - -def _redact_any_type(xml: T, sensitive_word: T, replacement: T, encoding=None) -> T: - try: - root = fromstring(xml) - matches = root.findall(".//*[@password]") - for item in matches: - item.attrib["password"] = "********" - matches = root.findall(".//password") - for item in matches: - item.text = "********" - # tostring returns bytes unless an encoding value is passed - return tostring(root, encoding=encoding) - except Exception: - # something about the xml handling failed. Just cut off the text at the first occurrence of "password" - location = xml.find(sensitive_word) - return xml[:location] + replacement - - -@singledispatch -def redact_xml(content): - # this will only be called if it didn't get directed to the str or bytes overloads - raise TypeError("Redaction only works on xml saved as str or bytes") - - -@redact_xml.register -def _(xml: str) -> str: - out = _redact_any_type(xml, "password", "...[redacted]", encoding="unicode") - return out - - -@redact_xml.register # type: ignore[no-redef] -def _(xml: bytes) -> bytes: - return _redact_any_type(bytearray(xml), b"password", b"..[redacted]") - - -@overload -def nullable_str_to_int(value: None) -> None: ... - - -@overload -def nullable_str_to_int(value: str) -> int: ... - - -def nullable_str_to_int(value): - return int(value) if value is not None else None - - -@overload -def nullable_str_to_bool(value: None) -> None: ... - - -@overload -def nullable_str_to_bool(value: str) -> bool: ... - - -def nullable_str_to_bool(value): - return str(value).lower() == "true" if value is not None else None +from defusedxml.ElementTree import fromstring, tostring +from functools import singledispatch +from typing import TypeVar, overload + +# the redact method can handle either strings or bytes, but it can't mix them. +# Generic type so we can write the actual logic once, then use singledispatch to +# create the replacement text with the correct type +T = TypeVar("T", str, bytes) + + +def _redact_any_type(xml: T, sensitive_word: T, replacement: T, encoding=None) -> T: + try: + root = fromstring(xml) + matches = root.findall(".//*[@password]") + for item in matches: + item.attrib["password"] = "********" + matches = root.findall(".//password") + for item in matches: + item.text = "********" + # tostring returns bytes unless an encoding value is passed + return tostring(root, encoding=encoding) + except Exception: + # something about the xml handling failed. Just cut off the text at the first occurrence of "password" + location = xml.find(sensitive_word) + return xml[:location] + replacement + + +@singledispatch +def redact_xml(content): + # this will only be called if it didn't get directed to the str or bytes overloads + raise TypeError("Redaction only works on xml saved as str or bytes") + + +@redact_xml.register +def _(xml: str) -> str: + out = _redact_any_type(xml, "password", "...[redacted]", encoding="unicode") + return out + + +@redact_xml.register # type: ignore[no-redef] +def _(xml: bytes) -> bytes: + return _redact_any_type(bytearray(xml), b"password", b"..[redacted]") + + +@overload +def nullable_str_to_int(value: None) -> None: ... + + +@overload +def nullable_str_to_int(value: str) -> int: ... + + +def nullable_str_to_int(value): + return int(value) if value is not None else None + + +@overload +def nullable_str_to_bool(value: None) -> None: ... + + +@overload +def nullable_str_to_bool(value: str) -> bool: ... + + +def nullable_str_to_bool(value): + return str(value).lower() == "true" if value is not None else None diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index 3a7416e28..739b7e8ef 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -1,71 +1,71 @@ -from defusedxml.ElementTree import fromstring - -from .property_decorators import property_not_empty - - -class ColumnItem: - def __init__(self, name, description=None): - self._id = None - self.description = description - self.name = name - - def __repr__(self): - return f"<{self.__class__.__name__} {self._id} {self.name} {self.description}>" - - @property - def id(self): - return self._id - - @property - def name(self): - return self._name - - @name.setter - @property_not_empty - def name(self, value): - self._name = value - - @property - def description(self): - return self._description - - @description.setter - def description(self, value): - self._description = value - - @property - def remote_type(self): - return self._remote_type - - def _set_values(self, id, name, description, remote_type): - if id is not None: - self._id = id - if name: - self._name = name - if description: - self.description = description - if remote_type: - self._remote_type = remote_type - - @classmethod - def from_response(cls, resp, ns): - all_column_items = list() - parsed_response = fromstring(resp) - all_column_xml = parsed_response.findall(".//t:column", namespaces=ns) - - for column_xml in all_column_xml: - (id, name, description, remote_type) = cls._parse_element(column_xml, ns) - column_item = cls(name) - column_item._set_values(id, name, description, remote_type) - all_column_items.append(column_item) - - return all_column_items - - @staticmethod - def _parse_element(column_xml, ns): - id = column_xml.get("id", None) - name = column_xml.get("name", None) - description = column_xml.get("description", None) - remote_type = column_xml.get("remoteType", None) - - return id, name, description, remote_type +from defusedxml.ElementTree import fromstring + +from .property_decorators import property_not_empty + + +class ColumnItem: + def __init__(self, name, description=None): + self._id = None + self.description = description + self.name = name + + def __repr__(self): + return f"<{self.__class__.__name__} {self._id} {self.name} {self.description}>" + + @property + def id(self): + return self._id + + @property + def name(self): + return self._name + + @name.setter + @property_not_empty + def name(self, value): + self._name = value + + @property + def description(self): + return self._description + + @description.setter + def description(self, value): + self._description = value + + @property + def remote_type(self): + return self._remote_type + + def _set_values(self, id, name, description, remote_type): + if id is not None: + self._id = id + if name: + self._name = name + if description: + self.description = description + if remote_type: + self._remote_type = remote_type + + @classmethod + def from_response(cls, resp, ns): + all_column_items = list() + parsed_response = fromstring(resp) + all_column_xml = parsed_response.findall(".//t:column", namespaces=ns) + + for column_xml in all_column_xml: + id, name, description, remote_type = cls._parse_element(column_xml, ns) + column_item = cls(name) + column_item._set_values(id, name, description, remote_type) + all_column_items.append(column_item) + + return all_column_items + + @staticmethod + def _parse_element(column_xml, ns): + id = column_xml.get("id", None) + name = column_xml.get("name", None) + description = column_xml.get("description", None) + remote_type = column_xml.get("remoteType", None) + + return id, name, description, remote_type diff --git a/tableauserverclient/models/data_alert_item.py b/tableauserverclient/models/data_alert_item.py index ff62c3ae8..02ebb6409 100644 --- a/tableauserverclient/models/data_alert_item.py +++ b/tableauserverclient/models/data_alert_item.py @@ -37,9 +37,7 @@ def __init__(self): def __repr__(self) -> str: return "".format( - **self.__dict__ - ) + public={_public}>".format(**self.__dict__) @property def id(self) -> str | None: diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index 61c75e2d6..f53a1e0cf 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,144 +1,140 @@ -from typing import TYPE_CHECKING - -from defusedxml.ElementTree import fromstring - -from .property_decorators import property_is_boolean -from .target import Target -from tableauserverclient.models import ScheduleItem - -if TYPE_CHECKING: - from .target import Target - - -class SubscriptionItem: - def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: - self._id = None - self.attach_image = True - self.attach_pdf = False - self.message = None - self.page_orientation = None - self.page_size_option = None - self.schedule_id = schedule_id - self.send_if_view_empty = True - self.subject = subject - self.suspended = False - self.target = target - self.user_id = user_id - self.schedule = None - - def __repr__(self) -> str: - if self.id is not None: - return " bool: - return self._attach_image - - @attach_image.setter - @property_is_boolean - def attach_image(self, value: bool): - self._attach_image = value - - @property - def attach_pdf(self) -> bool: - return self._attach_pdf - - @attach_pdf.setter - @property_is_boolean - def attach_pdf(self, value: bool) -> None: - self._attach_pdf = value - - @property - def send_if_view_empty(self) -> bool: - return self._send_if_view_empty - - @send_if_view_empty.setter - @property_is_boolean - def send_if_view_empty(self, value: bool) -> None: - self._send_if_view_empty = value - - @property - def suspended(self) -> bool: - return self._suspended - - @suspended.setter - @property_is_boolean - def suspended(self, value: bool) -> None: - self._suspended = value - - @classmethod - def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]: - parsed_response = fromstring(xml) - all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) - - all_subscriptions = [SubscriptionItem._parse_element(x, ns) for x in all_subscriptions_xml] - return all_subscriptions - - @classmethod - def _parse_element(cls, element, ns): - schedule_element = element.find(".//t:schedule", namespaces=ns) - content_element = element.find(".//t:content", namespaces=ns) - user_element = element.find(".//t:user", namespaces=ns) - - # Schedule element - schedule_id = None - schedule = None - if schedule_element is not None: - schedule_id = schedule_element.get("id", None) - - # If schedule id is not provided, then TOL with full schedule provided - if schedule_id is None: - schedule = ScheduleItem.from_element(element, ns) - - # Content element - target = None - send_if_view_empty = None - if content_element is not None: - target = Target(content_element.get("id", None), content_element.get("type")) - send_if_view_empty = string_to_bool(content_element.get("sendIfViewEmpty", "")) - - # User element - user_id = None - if user_element is not None: - user_id = user_element.get("id", None) - - # Main attributes - id_ = element.get("id", None) - subject = element.get("subject", None) - attach_image = string_to_bool(element.get("attachImage", "")) - attach_pdf = string_to_bool(element.get("attachPdf", "")) - message = element.get("message", None) - page_orientation = element.get("pageOrientation", None) - page_size_option = element.get("pageSizeOption", None) - suspended = string_to_bool(element.get("suspended", "")) - - # Create SubscriptionItem and set fields - sub = cls(subject, schedule_id, user_id, target) - sub._id = id_ - sub.attach_image = attach_image - sub.attach_pdf = attach_pdf - sub.message = message - sub.page_orientation = page_orientation - sub.page_size_option = page_size_option - sub.send_if_view_empty = send_if_view_empty - sub.suspended = suspended - sub.schedule = schedule - - return sub - - -# Used to convert string represented boolean to a boolean type -def string_to_bool(s: str) -> bool: - return s.lower() == "true" +from typing import TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + +from .property_decorators import property_is_boolean +from .target import Target +from tableauserverclient.models import ScheduleItem + +if TYPE_CHECKING: + from .target import Target + + +class SubscriptionItem: + def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: + self._id = None + self.attach_image = True + self.attach_pdf = False + self.message = None + self.page_orientation = None + self.page_size_option = None + self.schedule_id = schedule_id + self.send_if_view_empty = True + self.subject = subject + self.suspended = False + self.target = target + self.user_id = user_id + self.schedule = None + + def __repr__(self) -> str: + if self.id is not None: + return " bool: + return self._attach_image + + @attach_image.setter + @property_is_boolean + def attach_image(self, value: bool): + self._attach_image = value + + @property + def attach_pdf(self) -> bool: + return self._attach_pdf + + @attach_pdf.setter + @property_is_boolean + def attach_pdf(self, value: bool) -> None: + self._attach_pdf = value + + @property + def send_if_view_empty(self) -> bool: + return self._send_if_view_empty + + @send_if_view_empty.setter + @property_is_boolean + def send_if_view_empty(self, value: bool) -> None: + self._send_if_view_empty = value + + @property + def suspended(self) -> bool: + return self._suspended + + @suspended.setter + @property_is_boolean + def suspended(self, value: bool) -> None: + self._suspended = value + + @classmethod + def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]: + parsed_response = fromstring(xml) + all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) + + all_subscriptions = [SubscriptionItem._parse_element(x, ns) for x in all_subscriptions_xml] + return all_subscriptions + + @classmethod + def _parse_element(cls, element, ns): + schedule_element = element.find(".//t:schedule", namespaces=ns) + content_element = element.find(".//t:content", namespaces=ns) + user_element = element.find(".//t:user", namespaces=ns) + + # Schedule element + schedule_id = None + schedule = None + if schedule_element is not None: + schedule_id = schedule_element.get("id", None) + + # If schedule id is not provided, then TOL with full schedule provided + if schedule_id is None: + schedule = ScheduleItem.from_element(element, ns) + + # Content element + target = None + send_if_view_empty = None + if content_element is not None: + target = Target(content_element.get("id", None), content_element.get("type")) + send_if_view_empty = string_to_bool(content_element.get("sendIfViewEmpty", "")) + + # User element + user_id = None + if user_element is not None: + user_id = user_element.get("id", None) + + # Main attributes + id_ = element.get("id", None) + subject = element.get("subject", None) + attach_image = string_to_bool(element.get("attachImage", "")) + attach_pdf = string_to_bool(element.get("attachPdf", "")) + message = element.get("message", None) + page_orientation = element.get("pageOrientation", None) + page_size_option = element.get("pageSizeOption", None) + suspended = string_to_bool(element.get("suspended", "")) + + # Create SubscriptionItem and set fields + sub = cls(subject, schedule_id, user_id, target) + sub._id = id_ + sub.attach_image = attach_image + sub.attach_pdf = attach_pdf + sub.message = message + sub.page_orientation = page_orientation + sub.page_size_option = page_size_option + sub.send_if_view_empty = send_if_view_empty + sub.suspended = suspended + sub.schedule = schedule + + return sub + + +# Used to convert string represented boolean to a boolean type +def string_to_bool(s: str) -> bool: + return s.lower() == "true" diff --git a/tableauserverclient/server/endpoint/data_alert_endpoint.py b/tableauserverclient/server/endpoint/data_alert_endpoint.py index ec7d76c9c..f58d06e41 100644 --- a/tableauserverclient/server/endpoint/data_alert_endpoint.py +++ b/tableauserverclient/server/endpoint/data_alert_endpoint.py @@ -9,7 +9,6 @@ from typing import TYPE_CHECKING - if TYPE_CHECKING: from ..server import Server from ..request_options import RequestOptions diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 941b6eee6..321d0120b 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -226,7 +226,7 @@ def add_to_schedule( task_type = TaskItem.Type.RunFlow items.append( (schedule_id, flow, "flow", RequestFactory.Schedule.add_flow_req, task_type) - ) # type:ignore[arg-type] + ) # type: ignore[arg-type] results = (self._add_to(*x) for x in items) return [x for x in results if not x.result] diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index ec1425d6f..be3802ddf 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -6,7 +6,6 @@ from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions - T = TypeVar("T") diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index f2e1db382..e22e43e2f 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -611,7 +611,7 @@ def update_req(self, schedule_item): intervals_element = ET.SubElement(frequency_element, "intervals") if hasattr(interval_item, "interval"): for interval in interval_item._interval_type_pairs(): - (expression, value) = interval + expression, value = interval single_interval_element = ET.SubElement(intervals_element, "interval") single_interval_element.attrib[expression] = value return ET.tostring(xml_request) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index b497e9086..856387cea 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -48,7 +48,6 @@ from tableauserverclient.server.endpoint.exceptions import NotSignedInError from tableauserverclient.namespace import Namespace - _PRODUCT_TO_REST_VERSION = { "10.0": "2.3", "9.3": "2.2", diff --git a/test/test_extensions.py b/test/test_extensions.py index 9dc001876..7f6f84b93 100644 --- a/test/test_extensions.py +++ b/test/test_extensions.py @@ -6,7 +6,6 @@ import tableauserverclient as TSC - TEST_ASSET_DIR = Path(__file__).parent / "assets" GET_SERVER_EXT_SETTINGS = TEST_ASSET_DIR / "extensions_server_settings_true.xml" diff --git a/test/test_job.py b/test/test_job.py index fa17b9953..19f324d1e 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -9,7 +9,6 @@ from tableauserverclient.server.endpoint.exceptions import JobFailedException 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" From 815b194861f9bdb203eadfd744e373db43c21380 Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jun 2026 14:54:29 -0700 Subject: [PATCH 166/219] chore: run tests in parallel with pytest-xdist (#1792) Adds -n auto to pytest addopts so the test suite uses all available CPU cores. Reduces local and CI run time from ~6 min to ~2 min. Co-authored-by: Claude Sonnet 4.6 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ebb4fe8be..c0961c889 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ exclude = ['/bin/'] [tool.pytest.ini_options] testpaths = ["test"] -addopts = "--junitxml=./test.junit.xml" +addopts = "--junitxml=./test.junit.xml -n auto" [tool.versioneer] VCS = "git" From 00982712deeed61fa22de94c31780c3654bcc2db Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jun 2026 15:40:18 -0700 Subject: [PATCH 167/219] fix: quote tag labels containing spaces or commas (#1782) * fix: quote tag labels containing spaces or commas in XML request The server's TagUtil.parseTags splits unquoted labels on spaces and commas, causing tags like "Yearly Sales" to be stored as two separate tags. Wrapping labels in double quotes prevents the split; the server strips the quotes before storing. Fixes #1738 Co-Authored-By: Claude Sonnet 4.6 * fix: quote tag labels containing spaces or commas; add e2e test suite The Tableau server splits unquoted tag labels on spaces and commas (via TagUtil.parseTags), so "Yearly Sales" becomes two tags "Yearly" and "Sales". Wrapping labels in double quotes prevents the split; the server strips the quotes before storing. Also adds the first e2e test infrastructure to the repo: - test_e2e/ directory with a session-scoped server fixture reading credentials from env vars (TABLEAU_SERVER, TABLEAU_SITE, TABLEAU_TOKEN, TABLEAU_TOKEN_NAME, TABLEAU_PROJECT) - test_e2e/test_tagging.py validates the tag quoting fix against a real Tableau server - contributing.md documents how to run unit and e2e tests Fixes #1738 Co-Authored-By: Claude Sonnet 4.6 * fix: encode special characters in tag delete URL path Fixes #675 #994 Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- contributing.md | 80 ++++++++++++------ pyproject.toml | 1 + .../server/endpoint/resource_tagger.py | 4 +- tableauserverclient/server/request_factory.py | 12 ++- test/test_tagging.py | 40 ++++++++- test_e2e/__init__.py | 0 test_e2e/assets/WorkbookWithoutExtract.twbx | Bin 0 -> 20782 bytes test_e2e/conftest.py | 32 +++++++ test_e2e/test_tagging.py | 78 +++++++++++++++++ 9 files changed, 217 insertions(+), 30 deletions(-) create mode 100644 test_e2e/__init__.py create mode 100644 test_e2e/assets/WorkbookWithoutExtract.twbx create mode 100644 test_e2e/conftest.py create mode 100644 test_e2e/test_tagging.py diff --git a/contributing.md b/contributing.md index a0132919f..bba25e1d6 100644 --- a/contributing.md +++ b/contributing.md @@ -1,25 +1,55 @@ -# Contributing - -We welcome contributions to this project! - -Contribution can include, but are not limited to, any of the following: - -* File an Issue -* Request a Feature -* Implement a Requested Feature -* Fix an Issue/Bug -* Add/Fix documentation - -## Issues and Feature Requests - -To submit an issue/bug report, or to request a feature, please submit a [GitHub issue](https://github.com/tableau/server-client-python/issues) to the repo. - -If you are submitting a bug report, please provide as much information as you can, including clear and concise repro steps, attaching any necessary -files to assist in the repro. **Be sure to scrub the files of any potentially sensitive information. Issues are public.** - -For a feature request, please try to describe the scenario you are trying to accomplish that requires the feature. This will help us understand -the limitations that you are running into, and provide us with a use case to know if we've satisfied your request. - -### Making Contributions - -Refer to the [Developer Guide](https://tableau.github.io/server-client-python/docs/dev-guide) which explains how to make contributions to the TSC project. +# Contributing + +We welcome contributions to this project! + +Contribution can include, but are not limited to, any of the following: + +* File an Issue +* Request a Feature +* Implement a Requested Feature +* Fix an Issue/Bug +* Add/Fix documentation + +## Issues and Feature Requests + +To submit an issue/bug report, or to request a feature, please submit a [GitHub issue](https://github.com/tableau/server-client-python/issues) to the repo. + +If you are submitting a bug report, please provide as much information as you can, including clear and concise repro steps, attaching any necessary +files to assist in the repro. **Be sure to scrub the files of any potentially sensitive information. Issues are public.** + +For a feature request, please try to describe the scenario you are trying to accomplish that requires the feature. This will help us understand +the limitations that you are running into, and provide us with a use case to know if we've satisfied your request. + +### Making Contributions + +Refer to the [Developer Guide](https://tableau.github.io/server-client-python/docs/dev-guide) which explains how to make contributions to the TSC project. + +## Running Tests + +### Unit tests + +```bash +pip install -e ".[test]" +pytest +``` + +### End-to-end tests + +E2e tests run against a real Tableau server and are kept in `test_e2e/`. They are excluded from the default `pytest` run. + +**Required environment variables:** + +| Variable | Description | +|---|---| +| `TABLEAU_SERVER` | Server URL, e.g. `https://10ax.online.tableau.com/` | +| `TABLEAU_SITE` | Site content URL | +| `TABLEAU_TOKEN` | Personal access token value | +| `TABLEAU_TOKEN_NAME` | Personal access token name | +| `TABLEAU_PROJECT` | Project to publish test content into (defaults to `Default`) | + +**Run:** + +```bash +TABLEAU_SERVER=https://... TABLEAU_SITE=mysite TABLEAU_TOKEN=... TABLEAU_TOKEN_NAME=mytoken TABLEAU_PROJECT="My Project" \ +pytest test_e2e/ +``` diff --git a/pyproject.toml b/pyproject.toml index c0961c889..72f0625e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ exclude = ['/bin/'] [tool.pytest.ini_options] testpaths = ["test"] addopts = "--junitxml=./test.junit.xml -n auto" +markers = ["e2e: mark test as end-to-end (requires a real Tableau server)"] [tool.versioneer] VCS = "git" diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 705d33441..72c7274c8 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -39,7 +39,7 @@ def _add_tags(self, baseurl, resource_id, tag_set): # Delete a resource's tag by name def _delete_tag(self, baseurl, resource_id, tag_name): - encoded_tag_name = urllib.parse.quote(tag_name) + encoded_tag_name = urllib.parse.quote(tag_name, safe="") url = f"{baseurl}/{resource_id}/tags/{encoded_tag_name}" try: @@ -124,7 +124,7 @@ def delete_tags(self, item: T | str, tags: Iterable[str] | str) -> None: tag_set = set(tags) for tag in tag_set: - encoded_tag_name = urllib.parse.quote(tag) + encoded_tag_name = urllib.parse.quote(tag, safe="") url = f"{self.baseurl}/{item_id}/tags/{encoded_tag_name}" self.delete_request(url) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index e22e43e2f..2cabd70aa 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -921,13 +921,21 @@ def update_req(self, table_item): content_types = Iterable["ColumnItem | DatabaseItem | DatasourceItem | FlowItem | TableItem | WorkbookItem"] +def _encode_tag_label(tag: str) -> str: + # The server splits unquoted labels on spaces or commas. Wrap in double + # quotes so labels containing spaces are stored as a single tag. + if " " in tag or "," in tag: + return f'"{tag}"' + return tag + + class TagRequest: def add_req(self, tag_set): xml_request = ET.Element("tsRequest") tags_element = ET.SubElement(xml_request, "tags") for tag in tag_set: tag_element = ET.SubElement(tags_element, "tag") - tag_element.attrib["label"] = tag + tag_element.attrib["label"] = _encode_tag_label(tag) return ET.tostring(xml_request) @_tsrequest_wrapped @@ -936,7 +944,7 @@ def batch_create(self, element: ET.Element, tags: set[str], content: content_typ tags_element = ET.SubElement(tag_batch, "tags") for tag in tags: tag_element = ET.SubElement(tags_element, "tag") - tag_element.attrib["label"] = tag + tag_element.attrib["label"] = _encode_tag_label(tag) contents_element = ET.SubElement(tag_batch, "contents") for item in content: content_element = ET.SubElement(contents_element, "content") diff --git a/test/test_tagging.py b/test/test_tagging.py index 842e27c12..87f444fce 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -21,7 +21,9 @@ def get_server() -> TSC.Server: return server -def add_tag_xml_response_factory(tags: Iterable[str]) -> str: +def add_tag_xml_response_factory(tags: Iterable[str] | str) -> str: + if isinstance(tags, str): + tags = [tags] root = ET.Element("tsResponse") tags_element = ET.SubElement(root, "tags") for tag in tags: @@ -242,3 +244,39 @@ def test_tags_batch_delete(get_server) -> None: tag_result = server.tags.batch_delete(tags, content) assert set(tag_result) == set(tags) + + +def test_tag_with_spaces_is_quoted_in_request() -> None: + """Tags containing spaces must be quoted in the XML request to prevent server-side splitting.""" + from tableauserverclient.server.request_factory import RequestFactory + + tag_set = {"Yearly Sales", "simple"} + xml_bytes = RequestFactory.Tag.add_req(tag_set) + root = ET.fromstring(xml_bytes) + labels = {tag.get("label") for tag in root.findall(".//tag")} + assert '"Yearly Sales"' in labels + assert "simple" in labels + + +@pytest.mark.parametrize( + "tag, expected_encoded", + [ + ("tag#name", "tag%23name"), # issue #675: hash must be percent-encoded + ("tag.name", "tag.name"), # issue #994: dot is safe, no encoding needed + ("tag+name", "tag%2Bname"), # plus must be percent-encoded + ("tag/name", "tag%2Fname"), # slash must be percent-encoded (safe='' fix) + ("tag name", "tag%20name"), # space must be percent-encoded + ], +) +def test_delete_tags_special_characters_encoded(get_server, tag, expected_encoded) -> None: + """Verify delete_tags percent-encodes special characters in the tag path segment.""" + server = get_server + workbook = make_workbook() + + with requests_mock.mock() as m: + m.delete(requests_mock.ANY, status_code=200) + server.workbooks.delete_tags(workbook, tag) + history = m.request_history + + assert len(history) == 1 + assert history[0].url.endswith(f"/tags/{expected_encoded}") diff --git a/test_e2e/__init__.py b/test_e2e/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test_e2e/assets/WorkbookWithoutExtract.twbx b/test_e2e/assets/WorkbookWithoutExtract.twbx new file mode 100644 index 0000000000000000000000000000000000000000..49fc684af5208df28a07b79ec2ce41d9b2279985 GIT binary patch literal 20782 zcmV(_K-9lbO9KQH00syM04!!^SfUv;uwzF60H~Y*02%-Q09S8vYhrJ2YgcJ>Xm53N zMR;^_VPkYIba!Ixy?KwLTDC9v|C8Q#$S1Wr-xG>tG(&ZsdSW!#fB}Q2tE&=bwlQF4 zyVc*lmm|WC$nl&!x9Sev6;hpwfX!kLYp>z=Tbum+zsFPlp^xe&D~sPg;-9dOAEF{C z!>mYt``ESd6Z`SQ|NiTL|8M{OzkU86Pfs8AU6zL*PJUK=BB)OoiBX?$mLxy?&d<{m zi1<8|^>JU8#}7@})j{<1EbOl|9Sh3`~-jD7|wpeIEwpUAMcH;+;8Ku zKK=G_$cm5ey3BsUKYoBw{AKSq(QhBqwym1Kpy=6uDk8uB)Hd0t+=nNiNzweJYohw4 z5wsbG;Zy7H^T_X-GHwUIjy?tD35BNUt>H)L^@k6i!!qbjQPDo#-tV_hLFyMt^mOuz zERLG?xe+w`+ow3FKJi5cZD2MRg>aFg*zfZ8Q(Je@r%&I#_W}L#dBd-d4;l9dzqt7I z6N~*J3R#pjLu1{vwrQ8ysmi0b_RVX<&*&dn zx6gG{l}*-`_2nrqgXfj|kcGc}T)!&oI&0IiYvr+pezYGyRDPTO_7QE-gCC%Gjcz`E zsG~l6*+j%A60|mJK^dXV$9s3GI*OwjHdoZ#SN!eLhr&NW`!4%*-+iB&G|J;oX_O@? zyteoA4jK^DT^=ah_a6WLQ}q3*&*e*vL%;Q3Y|UfJ-!6T~iaaY|PH&s@yID8{mYvrm>cwUZW{EZAAfs4VYhyKxx3?W6ib8Gmr1psd5@ zUq61hsUQdffA^+j_qW{C?VX^nUj#QFtNuj~KcinS^Sj&M-h40j)pNYFmEYR&*w34r z3#p@*34j6KmG8TH@~jN6N`0Tv56`mMrBpU0RLZIndyz4zZ6x1S_6L?pMp``2Z6`i#E6@Rel0 zs_JqCgV`1BUvX&q{_n5a!Zv6j8f~76F3NQ^|jMFCM1h-L0?0zgNpYp{?)V^XS}96&3X42Z|c|r!2qx^*OA>|Buh;>)&5> z_?3V+r8pQq9;>AUC@;FLEk?86Nvxur9|eSFQ=?^E#|AOi60@`ePSBERWsK!jc%Ja;|- zh#)5G;o6NN`3pdYC8X*vXj{>p0Ec-Gth@*9?xFuP$mDlF!r^X%xQTnD#NRH7`!z4| zI>ZNZ`bEAD*yUsBuK?GJ4*bjC{`T6Jzul&!2EgNQFN^xOEd1Nw03Z884j_krU{>yY z4yaP@Uw}IJ@Pat}fcN-xNA|+(^aMa_03eT_z|>=(CLjOcqpz#<)#E@F++@POk8V2_ z0N=by{e1)o@W&7LN4|fzNz360HhLGdu=;n1{N2bJV0dm&^3yY9|E7R2=r3=5I7Syh z9G}fVeSsM@VfVpAzC!)~=eaA}|H~_YEeSs?L9wFd<72hg6_Vo_xlk%F1`WrU#P*yXMX6*OYbB;8xa7oU@cp4FE^mJjYc3t-fxQ+><~bh ze{VGRxpzPU-MjZi$H7TFLtgJL-Y>mP(4PRm-cLHva_Eh^(l+ zzxD0PtMzt)cm42;=zMt3$oz=RyET8oe||*Zp+DmL-gv$5kb2Lv`D{YoPgxJt=exf_ zpWcRiAJK~fd~xe%6x7?+ds)CA34Hgar!cCb0%+7*kn?N-Unc##bivoU`K`~mfnpDb z@jes$Rjc?i@^4@85?8#v;x&T0+gvbZQE?+!@4l!gPd9}6y`lI*^b?@Mtq*kT8y9qs z%D^y!N$j7|=-)n?&lK3JN4W7}e|c^9m*NP6tFpd?-3xZ?-}2^n2mr`@|9U_WAA@eX z(_h}t?O)E{@4=UT_{Yleg${hFlJ7vwyD`35$}gw$Gy0V-zp%j%D*jm+E5CS9+OI2c zyY!&}8u*zV1#Twy$LHC8D(}?h_ilz+(NBpjV3M-Ne0Ic)y81*HDV^*MIpA9}CXzYi)x3 z;nw;saky=RA3ps(ZFmlj-XwfRzqCG|U;B}TAG+>V#_)f0m`Ia6FFxTEAwN;L$Ubo- zB|eE0g=I-aU@=nqf8HkT^K(tZv+;fH=Fe;|@wQSwETnkYUgDp&_fOj1f5&+Jb!)w2 z!0(&nHB$aj)c*S5*RlIu-q$I}7w+Ue+w=EwC*OI&3)X*kC_hbYzBH*|2woG6w?yI# z`R{G^KV$&!VX=S90KVfCzhSz6$3K13{?9n6$BcfbLtkmb&wStAc?4XfM4D!wC_xmSD2imB1cs!aCKE-JWEvMmnfuc@ z5BX-Ye?RAeO7QO&jQ7x52?uU}qp4{&}7<$p0<{~t~ezMA}>#h~w- z{+aOa_tS#=68(NJ9uDh!5Z=!Kw4Zh2y<7gBJn4PYzxQ0ci?aVvFaGpg{K0tqpWgM) zcvn!gXQ1?k)4@rEe*<3nVSMt(42qzisqLS{{_pJEvrc{yl0kp|(*UjS=LUeoe~-EO zX2brcotz(`(EsyySN^3i{I>KTkPkm;{m#JL8vm~!1aQCq!iVMgBkz0b9lY=tV?IE# zDM}`>rwJuP%qQX86VLMelfY5zgd}8z6NsP0eD84Y7j*qI95cP0GQ6KZeL0Bt+eZ?+ z$A7Oa?kL3VEZ{S4erI!jz)=Q1971}hTA!mZxu3ktqqx0`e~l|{v{(hl39mrIpl$#TSAC0JzBPGC#D1sI4W#&{$?aer^yGcV(Ob{oUwAi?wyb_K5CW$d ziX~}^`LfZ?^}coXB_{j>J%PvGPtzZheq&<3o~+*;B&g*V%lB+Bs}cbE_w$g#na%um zZu8y6+tA7!4%q?0_%!&JFK)q2IO>|iL6bj(THO-rTkCs9^r|X974a<<^{tHOP50d{ zen!7Hrq4-K!qGmz4$`OF>DPUhXYJ+A#(i6#$6R`;>?QDFC`IEw6`nbM$p!q+f7D>|13s+x|$Ev{_Z>B+Kf2n zvb}PsVnGJ+%5f(nrm}L_D+v~5T7B3Byim^)Z#rd{;*<#1;^Je5EKfUC{pBD zCco)i@-7bv%&RFQ5yFkv{=irPX`&oEp}B6@@ke3P3>e2xC0~QliG!_eZ@(LFI{`zVuPldE4~zV3P;+;v8BSecnyuYgV0$rgV~3Sab)|zJ|eW z7nO)jaoKpV7VgHe?(z~cLmTC=s(1Kd;&jneKpT6;`%%cGM`Cj(5Ba+9I&zOLnAqb> zdq`(tp|9fU$tbVae!ZaZl^a~{@)~rLG40^cM>`}iv*?!zUgGYvd@5$r{_NmVxlFI< z9N*6Dt7mj8)M(Bb8d*zyJ`w3k+2ApDmseW;lFJ7+(=iMPc6M(VK;{QfGq_uh*R}O|eBxbq8ra zjSj`0IoKX3TB;?r-ZAx-)j3~7x>c4tCsIepnl;T?3d7s%?(!@G4L3?3X8koAs!J(M z=c) z+~L=N8*DzAu!p&_-Rz{pil&f}aR+>=ilj2xN7s^AEb-6_i6tE~(saAn;s^1R^pm?h z?SiVSMM&;t207GBH5hrQZCiU!pgj*CH8J;9ygAFK1+q~sMCmM=^?f;8qD!ZukX3zH zGi((L(OsU(9aEiQd?!JVorYGWx8hu?ZAj&$pKZjajH(sWB9|5}YkRtf23BGA=4HO# zFUsv~Pzkp?IRbu{hZ3ov9l811%BQmplN@HEVv=3D5z4p*)3rA-mqSyTtZ36~bpy}Z zGlOsAX(vz4NqpK)CYyYjJnVtd=yQD1jMAm(Oi^9S$3;9r8h(G?E%v#IoGy)^0-90kGEORbI%YC^?b|N5_Z|Hm+MJm8rxkG`h9)II9zen338ZPD;MeZ z!2pj4< zpF-D@wQ(i!aT(87Ta)8fQJ9Zg*k(i{I~NL+fa;xK9k3K03Xl1#H|?=oo|fa;8+R8s zOXz-v)vkA;EPY*tdMBrsps|*|%Uq{x-8VTIbE`2~?V6)`>FZJ4WG&e}<}Z@2lm2*~ zO&X%`#_6hA=KJVG>mw^2;5rb z2H|*FZjtHm07Mhm3x&cZ!e<GoPRnhh6f{o3Fi(*Ii6EzDfwkd>an@@a&u4`{b=)aFSg4q0L|K ze!4oW_!`&CMKft*0rTwxQQzhDX<@2O(z#+{4g{s)K1%PZgg*z99#rl)G>ElxWR9d) zJ#sP_oix-%J#nc<+>GaWoLA&YIG-N=R}JVM&%|1r?b=f`j5=1N@Lv~E(lDaNWbwMjQIxa-kYLeZRyZ3Kl?)=E}%xrT$Urvp^Mb^F3u4rlJ zhkACM7-z(9dtX~yOC=&vH`ixh@;?1mUUwv4~3gJeB7M{0kZSnI_iWzEBU8uJ>s)b(;=l+hAXmd3BdUh&tG7Z%nV{6J?qZn%r{a`tHvZB0ER8$9i$cDCeLAIXH z(ZzB&I>&9`-}`r6F?qeetcT51gm5UH^)reXN~|ytB1IFGWrnrcIa4QdR^AYT7;K43uU$k4Hc1WAriJY(lTA6S}}KKP1zrLCBiaa^9_-?tnOWxskTDNjdA?@kFwy z=8VC0Z6c**hYMFd^H*v@3ml)G?)}qDlJ8xSANSeDun6&VN?!NLg%WVBRMavO4}#KY z$EZ^H@+4nQr5D1R7yQf~AMx>B-bwr69>a+A*Y()a6hZyj8t{- z>g9Mv9U5`bqCg}pRI&DOm$!~fj>&}OW>Yu5thPPVIj49rr>&%HTkj~*C@)FQeBjvL zSN7czJ1CcBkF3c3SX@ZjQg`tMUAcF8;xbq2xMU~C%)0c{vE0n8iQMFiP$6*VIN5F4 z6EbXNf4*FI;CWmnm?nPKF~=)bS&K?7(J+=*5Bq$<4SV3!gXV>MS-4GqiJ4&2-mRSD=HKL!`PRXs#h}Y`ake4* z;?h42(SbSbI2EZ}@yZE|KJFDi)n1&Z6r4QX=AA-mx{v zplGVynTpkTx!Zc}Hn}dZj!7t9X6mx6X^U99q-(j~H2Y+e9(exPY`@N{?(+Ureq{~u zT-wg(qE2+nTiVJ$@~x$}S~!qKY&Gq-mv=xt%st8eNsbLU^tU-ST>JGLTY_ZoImZtB zzJ)aM59QcX-+ScP;nib<(7CE?SQIIx=FN(a)|Ydx@3+J>H4nJfkCaIqW`w~;J>MHH zFdyf|!^VafG9BfrIhg9SQsnssbr_t_Yku1c-$Fbcbcvu`J@p)xmPTqx=aH7W%YpfE( zG+S?$AZmbJk;E0xt83M+?`!S{80H4XQ1pzi8{v377U%&*jp|yZ#%yGenq%YMpUWGg z-_(>x&A|y+lyKUv#jTT`RzyqMw)4oDR~o;t8KL2acGbD^jLJg zyCd7nepgF-gr~Y3;s`Pij)mKbS3Lk-2<#bqpFdhTw;Xu)Rm$yD2E);yFcAz{FzP@D z(LC|!zF)__y1z0cy9)K$g^KqEx|Yz%!9Pt>n!z_${6yU4vFIjD5rj3Zy`huw+>SXo zJqC(`hGa`DCdKsR=O<5zXo&DNu=ywwb%)uqlcuDlT(z)^mrdZucX>%n`L5^&$2}7q z_C-$nYKI|Ln<}`3GSq`da9}$~3@@v6lqS>`0YYuGP+~%lGQuu(qtDhdyS>X3n#E*y zb-FTD{O2WotlzJ!wJks7X*p^uU7gY1VTZssD$97Z zHu2?LPDDP{CKZ$PU6|boksFbT1+D; z2a*Vpk)k6TQhj4Y}O+hx149UG6Q4!w<(?k-Q?DJTMV zd$)D++x|C`vD_9{IJkip{ zlEp=>*KtVhnv^`o=`p+vy#@$n&B~`xCzLWxLz`R{`*v*;7l1!aLxvy&ENL=_#C))6q%lBOI1cB>Ql>>!+M2QHZI; z!6T;|AX9nmAHpMKZ@Cl&nR89@i&P8(k!bN<7sppFQ{tf&W$Mc6(=kC7fRea-XPjco z1-$ElQCzq91@Y629gmvp9*Ec%g067Nk+4{uH_e5$caf<>aQ(5~W&7la#igrE_&&xY zd{1VFeAQA{U%Ss=F}31sPotsAUE6E7@jHXaX5*otRm){Ji^wy7Da+dmy>jP{f*Td6|Jf9(iHoAu&b=Oc51sfb1}rj*IJToXrk|KoK50 z9uy)unTJ9%cZazgrc;tP)AV5TK(KUJ?d8)mx5OVf8`{D9!aT|St6y{wmyE%(+Mm&g zgJ_Q=Yu)oS%@brtnK_EQ9(Vgj(=vT7C0kzJO^;I^?#*V|W4r?IEQ1OLhY;<4BbWxT z4gUE&ug7T5)scmp*k&7>yV5du9TvDtdl^XQn?8YQ(^ol}uu^M0a9%1_D6E|TI-8Gc zr*uw6GIh{p;wP_^xLdR)e=eZ}?%8`v}!`L4N1?5QA zqUcR<4!nhKZ*uFNLI>P zt-HoMUCx{Z1wV|Zq+3y0t*97a5Up2)27>`7NBM?qvSxmlH|4j+K#8Yy+>7N}-j>8{ z!W&kd;l}7iB2JO1WFo$SLOEd^&}8Ze=2Sdf=55H;n4R&Vf$47ei22f!oG4|Rchs2D zAxCfqJKG8C-L&g?k&*VwY;1aM9E%>^9$f&=d4eQuANBB&v^;#rn4ZBYVDsq;hn{b;_-7g)f+)4QkD2Te>?LEiUIl{xZxf%yn4;0Bb zTP~6ldRi4ypv_eO{II8#gqj7ljAI%-&k-b|B`)6?tC7aJzTVCb)_#ei9fjGvicAtD z*4EQ)U_w%_M;L!(+cSEd2VVcc@%M8{*a}i{tw;%<=dQOB>|{nqYU{4>NS#gzPTu59 zR6=ZLwo)*(IbUBXAVY0;+$XHU?x?h79x=;gdPz=!wX6j@J1Ny&VxF{VE2<*p58dq0`cf9TSIN=XaIs$bW z%+`cJ@)4sArznoMegps&n2?>gT5XP#$V*{Q3!y@6nF5?VeIqK7zU|XB@ID%Mn;@`e$K4-*=jvqbe>>{JA;UA z++51Z5LjgmP}d56LIGE8F}aVI2EVp)VCf})FD>cKd|a73+{=zz@qEsOl^J&VK})OC z*~}svVoj>+=qqmwq<)x{%VA>>bbT})F*v`CVO!(NwDIQK39;no+4vmh*$6FK!ku7@7k(US0t9!nf zGvqG8d`AP=L4>%rS?ls4Cq~2Z>}tnMvU#SZ%jBzlaF_SBUj9fQe{Zkc<^7rGLD!9p zvTb0#uQQg8EW1_i^1MhOIz|DHd@j#b-63VB&1GUKCPIjF>Xy*5q97?hI|JP3xZAxn zQkpowG?^@ELSmHlnVvS}qb}qcX2jlUS#Xhol!WVANd#vlU%aK^$o@t+@@f)Fz0?(0iY4XScg;J&benw*0L^xf zpT}K-uj|!$T2w;>|2hykfL)6tUe3XWjAjy*?H8uE{Gw?~vj0JPi2ps1D$J zEr=-Y^XE{gXv#zbnDF8@e@%K^)C*9R={tflsC-DKeYg z;W|r39$`0989aCz9_m)8UZz&cS|N9gXDs|?DcYBLwN;N$G;tftaOE45pkY4j=)i`{ zmK5q!vQ35AxY#i}ed=r<@ykKoEZt!aNUgu87uO~=@RLp8t?up`LXvC)OlGs`Nv2e< zsAZk5&;A1=_U%Pu``M*~`U`uzc+|yR22*szrC!Zg885sr25dMcV_=WsaNk5z$!Vr& z#5m5#neKK4xthIR?pTrTVENY^!j3J~L;ccy{x%}TvQ2X6^@W-l2l1HDJh+z)#a$-B zkjVTYM>l16b6ac9+~_^@RZGuiS7d6HrSd=?FeV7ge5^i;hR}iQY-(UVTY^?(UelFq*eY@S#wm>IY;PodX2b`7nwB=i z$chgz(E#(U44ndYspls=Ft@1f=FUGPO*%W z1S&8H4@}4rw&X+|}lQ%3#f+7zibMWS$;2V_W%g$$6d5ZxiE?b+;ly1+ilDO`1*r()&Qj{ z3CybLsJ|w{73{~vEueNpTnrcH(c5Nh)4Ly z|0Qiqi{;F^N)|du9r&Ol+@Dfrl7X!vp%eu!g`it%3i|?xVc|b zRfMAADr=ks_UxdwTxY@_Bv|Fh!?^TY-^P-CVAzKV3}X?!sGT$`fvrn~6`Pf2;D^WLZx!+R>Xm2Lp>kaap-tn%UPLTykiqiRGU&ER>{Q3EhgAoe3b;u!mXBGJ{K=dssWN6 zr-6_nJ&7iqzpD;q1q?RkfW*Ge{+ibSbWy!D}@miP`(zZIEW?|^e zsS#f$Gah18Dwd#f+nniZJsq^bH>NI-$@X+!!n7qjf1Q`=W-$(noBdl!%ZI;Q#h6Yk z&g2Lmi3MbBT>-wM4#IkR4WL%EN1$5RCs%&CSbD;CR-QJ+Ar+Qm&Ebl z@9SGmA?tvB)hkrbmnuIjTB&q|K!;5A)X- z#a~t9;x4bER~^&xm;^A|Vn#0Zv*4mD+bunD0vBF94_5k*DZEN*xKr;&?a_=thkG}tF*(#hQM$7r) zz_XAR>|p;pC95W=NVyX5<|yxDq^k<+^rnY5EHzdTS)T2Co8gT6c)7sCT(9d!OoMpH zcNlj*9bLy<()3}K9_k#5Kdv(36Uz5<0ou`p-=|bHJ4Zk`kXU1BR68N0yc6;$ByJ^**GG92Z99;^#^rZ6i z#u2@p^T5~giH5nMk))dBLWoVw12aqa%7XSb3D(Y*@E+mzzv~QRlna6&;%r^#!z^VN z&gJB5nV2YET|eTh1r#9_nu6*$%}%vuqhri#ZdH#9`{z3RUWe}M@hf}a{qtWx_mKCK zJ@j~fy0XSe=U3&4B#Tmq$hAGsvvQc>B$?!j2lIWGr+^!aJ-k}jddVzq>K>2!zkp!_S6|ZQFlMV2brmd76U$=pNxIQ>bWMP&*(M!!~qi=O< z++_yDI~O;H%s0_5tffE-mNQqBbfC1x_T5$NPJOeQ=FTbw|3WN*Y8K{?d|{ktljWs# zWAAvB4}yu2dfhndO$*p3I7c4pAe#KDHMhOuL;SbK8%P>V!)Hw>@LeMDWxGui3v`X`MO4OE$1}9mxZ0X8;231|5V3_&E zDl??m>E)*Mspi2)K}}u&Ufy3W=$*Lk;~q;e`>1KXHJQmIGhy;PkeWCR&nXp6CErE!2zu^DEcI2_syGb`2`u{|S{w7VhrGls>JD+-?1 zkH==`cx!hgba8^Gs1=501c@e^ZSLb$*(-8MSj+R7EvHc7=73+s7{!#UF`n6rb)9q~ zVr&)|)I$y0zTc?rR4Czik1*3|(T^6;H}`@(;wzxCJAE5v^I1ac38GjTRF&%N0kNj! z{3=Xp5#8^c8CQ7+aZnBQ+LP?O;WxDNw?30AWEzNxFk9mPFP7dOv8hzsg zP%GU|fiJ8NMzrA1tZVxCMrd#o4~M^^_A6&Ap_ z4aCaxCQR2MOu!1ByA0ZVW$6qi0BnvHMtLc$Y>Ie_zc|mR3XLr|Fw(I5E5~y?yJGaa zyhFX#XUGDEO_?20OYK5qi|Ns#_6Qd{XG^b-P<C=b7bkDAmIu5=kcMHwjq!A| z&(2zjw-Z1$)_j>Aw18_K`DH-OakGO8d~D(@4>+fNF zcX_{$i#tmt5rJHyCYz#f%@A;X;J}h_QMkwgay>>$?YBB@ul3|xj^oLtc*8}@xqlLh zI0)=GiWY{+S$ksJB1pW-kQgea_8hwaTxNNXLoH|3Q-%GApVB za3R-Sztd0VRx%d6tj!LEy{$xBO6cR>X3*d;EeR+J?ipZ3Vyx9J>=Y@m$?1HTx2vrM zowle}%{I$KOH_Vx!0Ac22BbmQ42X)3h;=P#2>U~Fiw_I_(&N{@gd>EW*r;hRUjkn> zT<-E*2jZ`0*17lz{3H-p{sXs$xJ*d8jS83*PIM63l&2+=Pi4n0#0I+1T=^WH_X{N^3#OAo@vmtG^mS&-5lV583pXVZ1SZs zwQn4o0C~L;Z}--&QG4>@^(M!|B$e!VYjR zpctXT1R(LrA|e#P?wsh3Y2D@J8NLq|0BUZoH0=j`ve2__MNPGAe(9buEXQ0yBvYO< z*x=yN0AB#SJWUs(vW1vI9NUY@O&+^VOc+h1y z1L3SK!2PLqS$P0c(8t*XAc*;e1(equ4D1SLQ62UX@521gj&i{3RiHy!rqGM}JoErt zAHCB-T-E+sQ~v8;IUE+h%R3Zbh+BuOnY87Cnp@6Q0FM=N=#OQpa$xAUP_w zINe5)U3vx;B2-Wv=`aJHs3S;w?h9L8+d^!gNC6Tr#y!+Wl-)E<_Ke(Wa`pSyY(w!X%KjpE#*+>ODU^EZBo&sE-c!9i|pCg=)_d|q| z5;vUAa5ys+gh#yq;F`NgCtcbZR#S|HL#Vc$cc|_!QmMzTc(p+eJADPjr*5i?9$PL; z{)WdfHQ;D~_w?W^qJ{t9yLa&I6?&x`C)ZG8SjHRX?nt)pEE6wj&|*4Cr{H{Ud_~}P zu?7dGE}9LRwcS7%?~-(9A2(t6-~(Rf1gf}1O?buq>AJDfKslWC!^FfcP=}~w#jq3h zH`8z^AqO28x_}y}lT_jKQBtF#dicxRUSeZaRS~Pi`~}6pPcuC@T7T_A29?hFy1wD$ zSgx^9gvG^PVG7_9M=b|P+n`j)3(rFG$j_5G(1+(S1_r_Qhf)`>aGt9ly!==J-g(3( z3eG1U&qI+0YN7g-#g%wNAvfLg@Nfl{lRNunJMQb7pPi29JWRSx-ZH7@?BeFzwV|1b z*97>4sWUFuqyiil2Q~K1Hh`ugccJCvMbAi zs+=_20v3Kg0x6R>^a6N}y=7V8i7(XVxdsU=zq!^|XT_`L%XSumiFnwT%2Sv8ULcs& zIe^b9EN*kPoNSvUM0*E=`sICHYnORu$_2gbn5f^hBhot+u!b0Q%g-B?Vk6^W4>%$r zWJl#v+7@jy#K);N>I#q>JPqI?t!hY6EMQ-kXJm~7+X8qy5FpxR4}_8e@#`}IGUXrf ziw`!t0WguX~}(wquk5dhB9clSrIGnA##=JXn86W9UKf}TuB z38R(iqdp8-%ysxGxS45(r4PIc4PdxE&8s=Ur)lNt&R^j+c}2FBp4Saj2}T%3pA9C&MUvC z6bU$$z%wI9*?NxMtZV0C52!^1JAz_r8wRyfPAuTB@9Pe~sl9twllcDLU0fUgI8uM_tHO3J&cNa6&OztQIr8O>4zi^D5g}R}S53K-PJCh?S1h z1MrDwI(p=ny)x++6?`N^g}Tptwc75ZV&^2+U2QkgMYzVUV#?GmT~C=nt7ItNrz}O@ z%)%_6fnPH1B%z96@`fkAVn6wmp4>3eGD}K5qv71gVcV(lVR>xz{%UKTJ0#%yFCO?8 ztzDSuah}3QCgfG)9TM~6-q+*2XE5;nkfU5F6pBCE$!flWVtaI{MZCXMLH>NtV(E9W zn0GkMUEVi%&AT?ga_=thr&!Q0$iB<_0!#XZ=O537g$rM*INjy>YaMFM@Nz%B0$g{z zHtKZ}Kn#2==@ZzT5_n%-_Jqx941K5(7$-9mjy8h5J{qigjp8no$)+9_+*Igg_XX>6(2!SCr1iEr#r|0Bk!HYT9boML`74<09-JsM4Tz%&Qp$0+_ozOJLM%h1~!Hjzc6qu_6D6 zN?5)|f5B$lBeMgXVy&x(6!`OX4relDfvNl6s;^M{0ocIVeAEy1YBIt%IQBV$Ed{nX z%Yg(yQa=t*WK0;&){-nK-KlUI+4=>Qq4d zdUS8=%ELdNxu#-Brq7r)M9XC!bsIyjYvv*zN@~Fyq9TsL5XTipst^jS;fywae4O!P z7c|2VdP~&ntw96}t7o8kdP#8P2IH(-z&aQ-2Q}!Frz<4rxhi|g7#_%dg+~?&g{mg8 zwws5%Tduw@T?rs#`jD9_6lzInLP8}MajTWu=^X-7R{5CO@3RX~wt^1DVW>KpaOj~| z4{(+!?20%3ErR6sTH^RBStOtsDtuH#nTzGwK0^7(6>5Xry=1^V42dgKqzsETUPa!Q z!1q>!>35rLKffHyM?J%ejt4zER!Pw3K)2PHWVaX2%OR3SRmh6=vH-;w;A00gf@syF zjO9$`vPnRMK5^X|Jg3071M&yH7C8wMp2kJp#L5_~wtX>7ZZ-Nm4Ihl+ZgIDg@14VR zFqUTL*}%m^j?C(9c(N`KQ)+;g0Ut;_;&Ebq9S{_oECPnd1Jr>ev|!`w_4=IOm@$%H zb0UY2b=Xp{KLpFncCu!7$ctnkT*rWL_sW;Y^&>t2U}Fv+b0ET#&s0;zQKlPP+q)YY z^BS#OXEdB!+t!JmXc29MBa9YZbRp5AMen`$-i;6uqDFMaAnGWI8fCPI8cZBPFh(17 zqK(nZC*OI``<}P`_}2RNUh97Dz1O~;y`JaCb=^PqzFvz$_4t1tJ(r8umo)X6B9{!Z zP)d953msw}BzfX3v!Q?Wu@jp+AA_8E+*z+V;v_I1P_#-jhNp0uF`Ihs==VEAhKaar zx^j9B(K?s!cI%X?g<{Ay!I+3c%6=%^x@=3?@hHUmIeR$|cOY3C1b=s-Ow-IFZPY(T zn+Xmxt6~!;I4R4ig`;h!P!r^G^%KS^Ctl+Ku043UValMG6gv^h9}%Eeq1k6z{PTFh z^-j+;Ezo)r(A4_nK)9UrJ6w{SpwYcMb`xb|mozw6i-T6K&ySg*z`m0({S^wP8_1E;BN zmNh2)7^g`T>e@QS1hc-y-I&7>^7*bnMWgS^%mEpkia2uaF?%L=mDdRoZ7O1q0w7Lh z*0)^ZiYT32y5>hen@Tr`ei*LEcUQUlw-^%cl8V7(MSW&!Wyad8a1U@cC@4^k{%#wz zy{a=5N^4A6@Gg1Dj=);z;8B!Hc#U^2dlIs0$o5El1JhWgi7oJjlUz;JjZVcFdVKGR ztV_=3#@G={0`n>v*2$z2gq->$bk_@SeL9P)-f4!DhUoF&8%6^=nB9{kx=vBy_=_x; z`kCsc<@C6?2!s-%SwvU!L3UDKlC@r9BTQ4dOtC+6s#bjBt*F;#KI1N9@NPmywjOVd%Te$?nWHi8><8km42QM(muH&A6p9Za1 zqF{x6*O9P1VOz=>b_+)~Y?s*sLvT!w4u>ZK)QHJetm)1(mf~o)O#P$=S0qjoXf33#eh)`V3Z_Edrka>QH)a$iEcXY1B$bpyGUIsDU%@LW)70nX+u z7w;`{Prfna8}uFgkm$VMW-T+Zd>?^>21+pi}PM zaEN2*p?%;{xaA;kP{Ip3S-&_CIv_M8!cD^UmJ%V74Ji!F@K#lr)T5+-?aae-chx%} z2CxL{DLoHgI~yeANhwuc_oB?4Qd%WAsRb?3WSdRuQH|&3EJrqbD2ImHDp2MWoroi z_fA?oq|sZwY?@EL^N9|VeNGjB?HL)>y%bR>;V=EpP5X*@++tvl2I5bRjObk=-zZ?2)K~777m>FY%eMQ499u{UdDw zO!b@80)rFOO16$ASMgXZAzzBHNZ*mq8^7Oq!^W<5yh3`tpM=+jGT}npzx;61(NU^` zx0LY6`UCNumK>|uxJRGD1ax8p_&YKjGzH@@cB0QL$9w@vUAyf9aHv7Dv-O9bh2T#@ zLNw<2&+H?*gDZkWIIyv$>dVd_ylr?q4wq}$k2@(A+d_PBDf>Tc)xqY^Vrg(TXNGMy zxC^fu>d6}#bZz(mki&}1;^MRf*t;17D>i;B-@1w^%2N*nxU=+7-nRwYb((n9H`vT* zN1&?k8Ucg(+-+;v=^BpvAd!EvtRPR_?d&8Cso|%UM-IoIrEJriMNXsVG_|%(fuI z=;$q}5a8PxT~np&kU$?p4^w zO0qZ$?ZAzA_BK6_(YR+3McD-u^+*%=WqV_+`dG6ZFQ;o0@N~jTiu&-BIpNV)awGhY zyU6vluuhKMLEye8NJB3z1-Bz?6%ljmqBJGBqe^L1;E~~wdCz^xs}RPHhf5#DZJZ3U z+9M8Ng+05XE+R*YdTYyHoZpTZ8u=;|(iqTM!ggnlbg*A!Dt zLnSSmXSW)OVf>1=n>}35?*}+}<_6pxG!Htp56(0^67Oyw5uQr+jv4rJ8QqgOWWl~K z&Lv6ctB&7vv7P0w5_>ssM=M#oM-yjQ=Nj6G5;-#lkQUgu1tIT8X3guHKjhm99Dc4P zTi9M0nncsvbzRFA{dAQ(k7YS9^ z#y=vSGq}dG4bd+faS0TIAMY3z@4siAhpQwABcG9}tw?jVY9G+bF@;{x*VtGvx6T^mFm>c6YF{ zznKL5ZPMMr#!B%d^`;!LHof?$*k zP?R7CV7$kedau_j(9!GIyrmMcY7a%~jWtNOOM~P@8age;EqA1JGZewxN+VqYKh>7M z`!=^$rBEZMZX1}zVYm{5+AqNkVS*RuRzo)XL@%kMa0mc6xFiZVcz>piPZr0%l!L;i zN$alD&j~^+eF_D(`r6H}yMlKlK3#cfxZS7u>-ztNb|2t@n9t^r+H@KjKU@KBVlB%^ zj?X#jttYLIzeztb?P$_@7s(^3OA+#W(L`c?2Jz(6Jc)M&@Em{F`u^?m1Qg_Xjd~M5 z`PPA`*KvV~2~?`oIgVJHj<^SR7Dh7dV>EYBjYFb0q$TqIP_V2q-)%x0l$&BOE^~Y zIi08BnAY&(0*_^c&i65%@wUi%)2frs^|7aF$;oII#CgtmG8*yuoUnda6ID^0svg;2 zsVUhs`%xW|DV>ok)rt_!IyR$i zD>!yoe+k0p1FEb9-#$R77<4ZenG9eky@6H!0pofINHFI;gbyB#uzyGNgUe0tGStjI zY2njVx)hnUOry|6b`uMS%(KDo_9hwMLIjiix@g`x=0loZbtqN#>ez^Sa#PVd(#F@^dGc2l%&lI{TWqA)$!^9C{d2eHvO4B^(s&oOB1)V7)nP&#^Hk;PW-By6aXOfT%S4P9HzX;}SHHH%mmF9DUn^%#31@{i9u@Ty9}(CT!a!=tS${*k^0~yI{UN zwdQhDR%j4vaJZqH zQF==&NCi8*h7S6!Q#d{-q7l)bjse3g-_G})fytkW`K zyc6#}F9LE7keODCIgL+oM@5KJ@2VBVvEAH$LIebX#U~`z=cDA!+Dk1>H&)$lXMxTQ zqxD-UWiiEtrXIiBhF=+sy2)cNzKA=#asL;F{0mwBdpeaJmCICSiJ{@Th^K6^nu$~t zgmo3NAU|kzb;)8aza3L0?!M^+J^V}qK7xfGIit+ZL5F?IRy&v5g`j|uiPM;ai6M^7 z!|8JjnQLuz?j+l2gywj{01eT%bn4(5fNk=d;=b^3q}ZXNQaH4q~`8 zg8xR#zsUUs76<{mPS%0^H$K?xXse7{`{{=xXVI_zLHU1v#Rp2lw@|aWcXHsu-@b;{ z4@gzBCXoM=IQ&{^T%>>z{6ZM9*wJcDzXnZbdJu4t0e0GAL!J(**LzN5h_|5qSca7C z30N(@Yxpc*HWM!6JoQ5W3Bda&!QtuC2)d3)m`iWJRzuXjGl2Tf-EU;5n)pR+Me5M{ z+-}Ze2~RPylkmA$j*~{0z>+nGOM!wKxZ)kJsoUn7FV-x63st8gDUMb{&Y!q6k9_|c znA7MlHxx|k$YxV3u|68wK5SR7^oO!d8!Hr^rem(a|r#G2QIJO=Batr7i1a- z2l}qH?N-DKq8@H89yh&E_ygjm3ON1wa-C{Qrvbw4aYt3oOJe;r`<%b`9e|PD12_@K z$#gmNXdg5;+3uskD^nXJ$H{{C{pH`q)a@Cd^LO6mu5pF{+?8Eu9-G+q_@+*{TAWcR ztKx3Pg-p;@In@I<Rp{Eh#g)YuE}npj5z7Y_&LzZ(z!QRn=MzuJGdA^fNJ k{D&&(5B#r~cVlb#f2f&sGzf`)!SHT+(akeR=hx_e04*--WB>pF literal 0 HcmV?d00001 diff --git a/test_e2e/conftest.py b/test_e2e/conftest.py new file mode 100644 index 000000000..376d41a09 --- /dev/null +++ b/test_e2e/conftest.py @@ -0,0 +1,32 @@ +import os +import pytest +import tableauserverclient as TSC + + +def pytest_configure(config): + config.addinivalue_line("markers", "e2e: mark test as end-to-end (requires a real Tableau server)") + + +@pytest.fixture(scope="session") +def server(): + """ + Authenticated TSC server session for e2e tests. + + Required environment variables: + TABLEAU_SERVER — server URL, e.g. https://10ax.online.tableau.com + TABLEAU_SITE — site content URL + TABLEAU_TOKEN — personal access token value + TABLEAU_TOKEN_NAME — personal access token name + """ + url = os.environ.get("TABLEAU_SERVER") + site = os.environ.get("TABLEAU_SITE", "") + token = os.environ.get("TABLEAU_TOKEN") + token_name = os.environ.get("TABLEAU_TOKEN_NAME") + + if not all([url, token, token_name]): + pytest.skip("E2E tests require TABLEAU_SERVER, TABLEAU_TOKEN, and TABLEAU_TOKEN_NAME env vars") + + server = TSC.Server(url, use_server_version=True) + auth = TSC.PersonalAccessTokenAuth(token_name, token, site) + with server.auth.sign_in(auth): + yield server diff --git a/test_e2e/test_tagging.py b/test_e2e/test_tagging.py new file mode 100644 index 000000000..4af741255 --- /dev/null +++ b/test_e2e/test_tagging.py @@ -0,0 +1,78 @@ +""" +E2E tests for tag operations against a real Tableau server. + +Run with: + TABLEAU_SERVER=https://... TABLEAU_SITE=mysite TABLEAU_TOKEN=... TABLEAU_TOKEN_NAME=... \ + pytest test_e2e/test_tagging.py -v +""" +import os +from pathlib import Path + +import pytest +import tableauserverclient as TSC + +ASSETS_DIR = Path(__file__).parent / "assets" +SAMPLE_WORKBOOK = ASSETS_DIR / "WorkbookWithoutExtract.twbx" + +pytestmark = pytest.mark.e2e + + +@pytest.fixture(scope="module") +def workbook(server): + """Publish a workbook for tagging tests, clean up after. + + Uses TABLEAU_PROJECT env var if set, otherwise falls back to the first + project named 'Default' or 'Personal Work', then the first available project. + """ + project_name = os.environ.get("TABLEAU_PROJECT", "Default") + opts = TSC.RequestOptions() + opts.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, project_name)) + projects, _ = server.projects.get(opts) + if not projects: + pytest.skip(f"Project {project_name!r} not found — set TABLEAU_PROJECT env var") + project = projects[0] + + wb = TSC.WorkbookItem(name="tsc-e2e-tagging-test", project_id=project.id) + wb = server.workbooks.publish(wb, SAMPLE_WORKBOOK, TSC.Server.PublishMode.Overwrite) + yield wb + server.workbooks.delete(wb.id) + + +def test_tag_with_spaces_stored_as_single_tag(server, workbook): + """A tag containing a space must be stored as one tag, not split on the space.""" + spaced_tag = "Yearly Sales" + server.workbooks.add_tags(workbook, spaced_tag) + updated = server.workbooks.get_by_id(workbook.id) + try: + assert spaced_tag in updated.tags, ( + f"Tag '{spaced_tag}' not found in {updated.tags!r} — was it split on the space?" + ) + assert "Yearly" not in updated.tags, "Tag was incorrectly split — 'Yearly' should not be a separate tag" + assert "Sales" not in updated.tags, "Tag was incorrectly split — 'Sales' should not be a separate tag" + finally: + server.workbooks.delete_tags(workbook, spaced_tag) + + +def test_tag_with_comma_stored_as_single_tag(server, workbook): + """A tag containing a comma must be stored as one tag, not split on the comma.""" + comma_tag = "Sales,Marketing" + server.workbooks.add_tags(workbook, comma_tag) + updated = server.workbooks.get_by_id(workbook.id) + try: + assert comma_tag in updated.tags, ( + f"Tag '{comma_tag}' not found in {updated.tags!r} — was it split on the comma?" + ) + finally: + server.workbooks.delete_tags(workbook, comma_tag) + + +def test_multiple_tags_including_spaced(server, workbook): + """Adding multiple tags where one has a space should all round-trip correctly.""" + tags = ["simple", "Yearly Sales", "another tag"] + server.workbooks.add_tags(workbook, tags) + updated = server.workbooks.get_by_id(workbook.id) + try: + for tag in tags: + assert tag in updated.tags, f"Tag '{tag}' not found in {updated.tags!r}" + finally: + server.workbooks.delete_tags(workbook, tags) From fa46397db3fad9e40a142daac2e99a08e8967db4 Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jun 2026 15:41:35 -0700 Subject: [PATCH 168/219] fix: don't default authSetting to ServerDefault in bulk user add (#1779) * fix: don't default authSetting to ServerDefault in bulk user add ServerDefault is wrong for Tableau Cloud. Match the single-user add/update behaviour and only send authSetting when explicitly set. Fixes #1777 Co-Authored-By: Claude Sonnet 4.6 * fix: update test to match corrected bulk add authSetting behaviour Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- tableauserverclient/server/request_factory.py | 4 ++-- test/test_user.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 2cabd70aa..8a45c91a0 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1005,8 +1005,8 @@ def import_from_csv_req(self, csv_content: bytes, users: Iterable[UserItem]): 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" + elif user.auth_setting is not None: + user_element.attrib["authSetting"] = user.auth_setting parts = { "tableau_user_import": ("tsc_users_file.csv", csv_content, "file"), diff --git a/test/test_user.py b/test/test_user.py index 8f489187f..6e481fce2 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -467,7 +467,7 @@ def test_bulk_add(server: TSC.Server) -> None: 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") + assert xml_user.get("authSetting") == user.auth_setting else: assert xml_user.get("idpConfigurationId") == user.idp_configuration_id assert xml_user.get("authSetting") is None From 953f1952439ba98a3b88384067b098c47dc94e89 Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jun 2026 15:41:38 -0700 Subject: [PATCH 169/219] fix: improve timeout error messages and document TSC_CHUNK_SIZE_MB (#1781) * fix: improve timeout error messages and document TSC_CHUNK_SIZE_MB When a 504 occurs during an async chunked upload, the error message now hints at reducing TSC_CHUNK_SIZE_MB rather than suggesting async mode (which the user is already using). Also document the env var in the publish docstrings for workbooks and datasources. Co-Authored-By: Claude Sonnet 4.6 * style: apply black formatting Co-Authored-By: Claude Sonnet 4.6 * test: add tests for async publish 504 timeout error message Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../server/endpoint/datasources_endpoint.py | 22 ++++++++++++++----- .../server/endpoint/workbooks_endpoint.py | 16 +++++++++++--- test/test_datasource.py | 16 ++++++++++++++ test/test_workbook.py | 11 ++++++++++ 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 0fb5c7dc9..9e9fdc721 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -577,10 +577,12 @@ def publish( The connections to use when publishing the datasource. Mutually exclusive with the connection_credentials parameter. - as_job : bool, default False - If True, the publish operation is asynchronous and returns a job - item. If False, the publish operation is synchronous and returns a - datasource item. + as_job : bool, default False + If True, the publish operation is asynchronous and returns a job + item. If False, the publish operation is synchronous and returns a + datasource item. For large files on slow connections, if uploads + time out you can tune the chunk size with the TSC_CHUNK_SIZE_MB + environment variable (default: 50). Returns ------- @@ -670,8 +672,16 @@ def publish( try: server_response = self.post_request(url, xml_request, content_type) except InternalServerError as err: - if err.code == 504 and not as_job: - err.content = "Timeout error while publishing. Please use asynchronous publishing to avoid timeouts." + if err.code == 504: + if as_job: + err.content = ( + "Timeout error during chunked file upload. Try reducing the chunk size by setting the " + "TSC_CHUNK_SIZE_MB environment variable to a lower value (current default: 50)." + ) + else: + err.content = ( + "Timeout error while publishing. Please use asynchronous publishing to avoid timeouts." + ) raise err if as_job: diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index ce605806f..f9c47c1ca 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -852,7 +852,9 @@ def publish( as_job : bool, default False Set to True to run the upload as a job (asynchronous upload). If set to True a job will start to perform the publishing process and a Job - object is returned. Defaults to False. + object is returned. Defaults to False. For large files on slow + connections, if uploads time out you can tune the chunk size with + the TSC_CHUNK_SIZE_MB environment variable (default: 50). skip_connection_check : bool, default False Set to True to skip connection check at time of upload. Publishing @@ -974,8 +976,16 @@ def publish( try: server_response = self.post_request(url, xml_request, content_type, parameters) except InternalServerError as err: - if err.code == 504 and not as_job: - err.content = "Timeout error while publishing. Please use asynchronous publishing to avoid timeouts." + if err.code == 504: + if as_job: + err.content = ( + "Timeout error during chunked file upload. Try reducing the chunk size by setting the " + "TSC_CHUNK_SIZE_MB environment variable to a lower value (current default: 50)." + ) + else: + err.content = ( + "Timeout error while publishing. Please use asynchronous publishing to avoid timeouts." + ) raise err if as_job: diff --git a/test/test_datasource.py b/test/test_datasource.py index cb1157bc5..274f60150 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -762,6 +762,22 @@ def test_synchronous_publish_timeout_error(server) -> None: ) +def test_async_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 + + with pytest.raises(InternalServerError, match="TSC_CHUNK_SIZE_MB"): + server.datasources.publish( + new_datasource, + TEST_ASSET_DIR / "SampleDS.tds", + publish_mode, + as_job=True, + ) + + def test_delete_extracts(server) -> None: server.version = "3.10" with requests_mock.mock() as m: diff --git a/test/test_workbook.py b/test/test_workbook.py index c5c4f6662..1232650d9 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -879,6 +879,17 @@ def test_synchronous_publish_timeout_error(server: TSC.Server) -> None: server.workbooks.publish(new_workbook, TEST_ASSET_DIR / "SampleWB.twbx", publish_mode) +def test_async_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="TSC_CHUNK_SIZE_MB"): + server.workbooks.publish(new_workbook, TEST_ASSET_DIR / "SampleWB.twbx", publish_mode, as_job=True) + + def test_delete_extracts_all(server: TSC.Server) -> None: server.version = "3.10" server.workbooks.baseurl From 368cee4de871ee3159286402aed5e6e5ca4a5bc3 Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jun 2026 15:41:42 -0700 Subject: [PATCH 170/219] fix: treat duplicate extract job (409093) as warning in datasources and flows (#1785) Fixes #1090 Co-authored-by: Claude Sonnet 4.6 --- .../server/endpoint/datasources_endpoint.py | 17 ++++++++++++++--- .../server/endpoint/exceptions.py | 4 ++++ .../server/endpoint/flows_endpoint.py | 17 ++++++++++++++--- .../server/endpoint/workbooks_endpoint.py | 3 ++- test/assets/datasource_refresh_duplicate.xml | 3 +++ test/assets/flow_refresh_duplicate.xml | 3 +++ test/test_datasource.py | 14 ++++++++++++++ test/test_flow.py | 13 +++++++++++++ 8 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 test/assets/datasource_refresh_duplicate.xml create mode 100644 test/assets/flow_refresh_duplicate.xml diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 9e9fdc721..34b32eab9 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -19,7 +19,12 @@ from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in -from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.exceptions import ( + DUPLICATE_EXTRACT_JOB_CODE, + InternalServerError, + MissingRequiredFieldError, + ServerResponseError, +) from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin @@ -433,7 +438,7 @@ def update_connections( return connection_items @api(version="2.8") - def refresh(self, datasource_item: DatasourceItem | str, incremental: bool = False) -> JobItem: + def refresh(self, datasource_item: DatasourceItem | str, incremental: bool = False) -> JobItem | None: """ Refreshes the extract of an existing workbook. @@ -454,7 +459,13 @@ def refresh(self, datasource_item: DatasourceItem | str, incremental: bool = Fal id_ = getattr(datasource_item, "id", datasource_item) url = f"{self.baseurl}/{id_}/refresh" refresh_req = RequestFactory.Task.refresh_req(incremental, self.parent_srv) - server_response = self.post_request(url, refresh_req) + try: + server_response = self.post_request(url, refresh_req) + except ServerResponseError as e: + if e.code == DUPLICATE_EXTRACT_JOB_CODE: + logger.warning(f"{e.summary} {e.detail}") + return None + raise new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 9d30ce331..154aa4549 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -1,6 +1,10 @@ from defusedxml.ElementTree import fromstring from typing import TypeVar +# Server error code for "extract refresh already queued" — treated as a +# non-fatal warning rather than an exception in refresh() methods. +DUPLICATE_EXTRACT_JOB_CODE = "409093" + def split_pascal_case(s: str) -> str: return "".join([f" {c}" if c.isupper() else c for c in s]).strip() diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 2d480e883..ea7b59526 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -10,7 +10,12 @@ from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api -from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.exceptions import ( + DUPLICATE_EXTRACT_JOB_CODE, + InternalServerError, + MissingRequiredFieldError, + ServerResponseError, +) from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger, TaggingMixin from tableauserverclient.models import FlowItem, PaginationItem, ConnectionItem, JobItem @@ -305,7 +310,7 @@ def update_connection(self, flow_item: FlowItem, connection_item: ConnectionItem return connection @api(version="3.3") - def refresh(self, flow_item: FlowItem | str) -> JobItem: + def refresh(self, flow_item: FlowItem | str) -> JobItem | None: """ Runs the flow to refresh the data. @@ -324,7 +329,13 @@ def refresh(self, flow_item: FlowItem | str) -> JobItem: 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) + try: + server_response = self.post_request(url, empty_req) + except ServerResponseError as e: + if e.code == DUPLICATE_EXTRACT_JOB_CODE: + logger.warning(f"{e.summary} {e.detail}") + return None + raise new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index f9c47c1ca..2aea9c3ed 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -11,6 +11,7 @@ from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in from tableauserverclient.server.endpoint.exceptions import ( + DUPLICATE_EXTRACT_JOB_CODE, InternalServerError, MissingRequiredFieldError, ServerResponseError, @@ -147,7 +148,7 @@ def refresh(self, workbook_item: WorkbookItem | str, incremental: bool = False) try: server_response = self.post_request(url, refresh_req) except ServerResponseError as e: - if e.code.startswith("409") and "already" in e.detail: + if e.code == DUPLICATE_EXTRACT_JOB_CODE: logger.warning(f"{e.summary} {e.detail}") return None raise diff --git a/test/assets/datasource_refresh_duplicate.xml b/test/assets/datasource_refresh_duplicate.xml new file mode 100644 index 000000000..38a258933 --- /dev/null +++ b/test/assets/datasource_refresh_duplicate.xml @@ -0,0 +1,3 @@ + + +Resource ConflictJob for \'extract\' is already queued. Not queuing a duplicate. diff --git a/test/assets/flow_refresh_duplicate.xml b/test/assets/flow_refresh_duplicate.xml new file mode 100644 index 000000000..38a258933 --- /dev/null +++ b/test/assets/flow_refresh_duplicate.xml @@ -0,0 +1,3 @@ + + +Resource ConflictJob for \'extract\' is already queued. Not queuing a duplicate. diff --git a/test/test_datasource.py b/test/test_datasource.py index 274f60150..8b6f254ac 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -35,6 +35,7 @@ UPDATE_CONNECTION_XML = TEST_ASSET_DIR / "datasource_connection_update.xml" UPDATE_CONNECTIONS_XML = TEST_ASSET_DIR / "datasource_connections_update.xml" UPDATE_CONNECTIONS_NO_AUTH_XML = TEST_ASSET_DIR / "datasource_connections_update_no_auth.xml" +REFRESH_DUPLICATE_XML = TEST_ASSET_DIR / "datasource_refresh_duplicate.xml" @pytest.fixture(scope="function") @@ -473,6 +474,19 @@ def test_refresh_object(server) -> None: assert "7c3d599e-949f-44c3-94a1-f30ba85757e4" == new_job.id +def test_refresh_already_running(server) -> None: + server.version = "2.8" + response_xml = REFRESH_DUPLICATE_XML.read_text() + with requests_mock.mock() as m: + m.post( + server.datasources.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/refresh", + status_code=409, + text=response_xml, + ) + refresh_job = server.datasources.refresh("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") + assert refresh_job is None + + def test_datasource_refresh_request_empty(server) -> None: server.version = "2.8" item = TSC.DatasourceItem("") diff --git a/test/test_flow.py b/test/test_flow.py index 9ebbbe5d6..008c39a96 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -17,6 +17,7 @@ PUBLISH_XML = TEST_ASSET_DIR / "flow_publish.xml" UPDATE_XML = TEST_ASSET_DIR / "flow_update.xml" REFRESH_XML = TEST_ASSET_DIR / "flow_refresh.xml" +REFRESH_DUPLICATE_XML = TEST_ASSET_DIR / "flow_refresh_duplicate.xml" @pytest.fixture(scope="function") @@ -232,6 +233,18 @@ def test_refresh_id_str(server: TSC.Server) -> None: assert format_datetime(refresh_job.flow_run.started_at) == "2018-05-22T13:00:29Z" +def test_refresh_already_running(server: TSC.Server) -> None: + response_xml = REFRESH_DUPLICATE_XML.read_text() + with requests_mock.mock() as m: + m.post( + server.flows.baseurl + "/92967d2d-c7e2-46d0-8847-4802df58f484/run", + status_code=409, + text=response_xml, + ) + refresh_job = server.flows.refresh("92967d2d-c7e2-46d0-8847-4802df58f484") + assert refresh_job is None + + def test_bad_download_response(server: TSC.Server) -> None: with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td: m.get( From b4a94461f967687227ccb57978dca11acc1fec3c Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jun 2026 15:41:45 -0700 Subject: [PATCH 171/219] feat: add database_name to ConnectionItem (#1787) Fixes #1571 Co-authored-by: Claude Sonnet 4.6 --- tableauserverclient/models/connection_item.py | 21 +++++ tableauserverclient/server/request_factory.py | 2 + .../datasource_populate_connections.xml | 2 +- test/test_connection_.py | 82 +++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 9e2f008a0..fa4b8cf9f 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -47,6 +47,9 @@ class ConnectionItem: The Connection Credentials object containing authentication details for the connection. Replaces username/password/embed_password when publishing a flow, document or workbook file in the request body. + + database_name: str + The name of the database for the connection. """ def __init__(self): @@ -62,6 +65,7 @@ def __init__(self): self.connection_credentials: ConnectionCredentials | None = None self._query_tagging: bool | None = None self._auth_type: str | None = None + self._database_name: str | None = None @property def datasource_id(self) -> str | None: @@ -102,6 +106,14 @@ def auth_type(self) -> str | None: def auth_type(self, value: str | None): self._auth_type = value + @property + def database_name(self) -> str | None: + return self._database_name + + @database_name.setter + def database_name(self, value: str | None): + self._database_name = value + def __repr__(self): return "".format( **self.__dict__ @@ -124,6 +136,11 @@ def from_response(cls, resp, ns) -> list["ConnectionItem"]: string_to_bool(s) if (s := connection_xml.get("queryTagging", None)) else None ) connection_item._auth_type = connection_xml.get("authenticationType", None) + # The REST API GET /connections response uses "dbName" for the database + # name attribute. This is different from the publish request body, which + # uses "databaseName" (see _add_connections_element in request_factory.py). + # Both names map to the same database_name property on ConnectionItem. + connection_item._database_name = connection_xml.get("dbName", 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) @@ -152,6 +169,10 @@ 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) + # Publish/update request bodies use "databaseName" (matching the + # publish-request schema), while GET responses use "dbName". See + # from_response() above and _add_connections_element() in request_factory.py. + connection_item._database_name = connection_xml.get("databaseName", None) connection_credentials = connection_xml.find(".//t:connectionCredentials", namespaces=ns) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 8a45c91a0..fc4694c01 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -47,6 +47,8 @@ def _add_connections_element(connections_element, connection): connection_element.attrib["serverAddress"] = connection.server_address if connection.server_port: connection_element.attrib["serverPort"] = connection.server_port + if connection.database_name: + connection_element.attrib["databaseName"] = connection.database_name if connection.connection_credentials: connection_credentials = connection.connection_credentials elif connection.username is not None and connection.password is not None and connection.embed_password is not None: diff --git a/test/assets/datasource_populate_connections.xml b/test/assets/datasource_populate_connections.xml index eaaa24934..f6fbd13f6 100644 --- a/test/assets/datasource_populate_connections.xml +++ b/test/assets/datasource_populate_connections.xml @@ -1,7 +1,7 @@ - + \ No newline at end of file diff --git a/test/test_connection_.py b/test/test_connection_.py index 8bfed79c7..d15cd969b 100644 --- a/test/test_connection_.py +++ b/test/test_connection_.py @@ -1,7 +1,13 @@ +import xml.etree.ElementTree as ET +from pathlib import Path + import tableauserverclient as TSC import pytest +ASSETS_DIR = Path(__file__).parent / "assets" +NS = {"t": "http://tableau.com/api"} + def test_require_boolean_query_tag_fails() -> None: conn = TSC.ConnectionItem() @@ -23,3 +29,79 @@ def test_ignore_query_tag(conn_type: str) -> None: conn._connection_type = conn_type conn.query_tagging = True assert conn.query_tagging is None + + +def test_database_name_default_none() -> None: + conn = TSC.ConnectionItem() + assert conn.database_name is None + + +def test_database_name_getter_setter() -> None: + conn = TSC.ConnectionItem() + conn.database_name = "my_database" + assert conn.database_name == "my_database" + + +def test_database_name_from_response_parses_db_name() -> None: + xml = """ + + + + +""" + connections = TSC.ConnectionItem.from_response(xml, NS) + assert len(connections) == 1 + assert connections[0].database_name == "SalesDB" + + +def test_database_name_from_response_none_when_absent() -> None: + xml = """ + + + + +""" + connections = TSC.ConnectionItem.from_response(xml, NS) + assert len(connections) == 1 + assert connections[0].database_name is None + + +def test_database_name_parsed_from_xml_asset() -> None: + response_xml = (ASSETS_DIR / "datasource_populate_connections.xml").read_text() + connections = TSC.ConnectionItem.from_response(response_xml, NS) + assert len(connections) == 2 + conn_with_db = next(c for c in connections if c.id == "be786ae0-d2bf-4a4b-9b34-e2de8d2d4488") + conn_without_db = next(c for c in connections if c.id == "970e24bc-e200-4841-a3e9-66e7d122d77e") + assert conn_with_db.database_name == "SalesDB" + assert conn_without_db.database_name is None + + +def test_database_name_round_trip() -> None: + """database_name parsed from GET response (dbName) round-trips through + _add_connections_element which emits databaseName in the publish request.""" + import xml.etree.ElementTree as ET + from tableauserverclient.server.request_factory import _add_connections_element + + # Parse from a GET-style response (attribute name: dbName) + xml = """ + + + + +""" + connections = TSC.ConnectionItem.from_response(xml, NS) + assert len(connections) == 1 + conn = connections[0] + assert conn.database_name == "Northwind" + + # Now emit as a publish request element and confirm databaseName is used + conn.server_address = "db.example.com" # already set, but make it explicit + parent_elem = ET.Element("connections") + _add_connections_element(parent_elem, conn) + connection_elem = parent_elem.find("connection") + assert connection_elem is not None + assert connection_elem.attrib.get("databaseName") == "Northwind" + assert "dbName" not in connection_elem.attrib From e9f58812acfb8a7b3c884e1b947d8980ce6f90fa Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jun 2026 15:41:49 -0700 Subject: [PATCH 172/219] fix: use return instead of raise StopIteration in QuerySet.__iter__ (#1788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: use return instead of raise StopIteration in QuerySet.__iter__ Per PEP 479, StopIteration raised inside a generator is converted to RuntimeError. QuerySet.__iter__ is a generator and was raising StopIteration to handle 400006 (invalid page number) errors, causing RuntimeError for callers iterating endpoints that don't support pagination past the last page. Fixes #1786 Co-Authored-By: Claude Sonnet 4.6 * test: remove bare baseurl statements outside requests_mock context server.workbooks.baseurl accessed outside a requests_mock context causes DNS resolution of http://test on Linux, failing with a ConnectionError. These statements were no-ops — baseurl is pure string formatting. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- tableauserverclient/server/query.py | 5 +++-- test/test_pager.py | 33 +++++++++++++++++++++++++++++ test/test_workbook.py | 14 ------------ 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 6027a914e..eb84248b5 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -84,8 +84,9 @@ def __iter__(self: Self) -> Iterator[T]: if e.code == "400006": # If the endpoint does not support pagination, it will end # up overrunning the total number of pages. Catch the - # error and break out of the loop. - raise StopIteration + # error and return to cleanly end the generator. + # (raise StopIteration would cause RuntimeError per PEP 479) + return if len(self._result_cache) == 0: return yield from self._result_cache diff --git a/test/test_pager.py b/test/test_pager.py index 0a7ccf00a..82b79016c 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -136,3 +136,36 @@ def test_queryset_no_matches(server: TSC.Server) -> None: all_groups = server.groups.all() groups = list(all_groups) assert len(groups) == 0 + + +def test_queryset_400006_returns_cleanly() -> None: + """Regression test for PEP 479: 400006 must not raise RuntimeError. + + Before the fix, QuerySet.__iter__ raised StopIteration which PEP 479 + converts to RuntimeError inside a generator. This test directly exercises + the __iter__ method with a mock that raises 400006 on the second call. + """ + from tableauserverclient.server.endpoint.exceptions import ServerResponseError + from tableauserverclient.server.query import QuerySet + + calls = [0] + + class MockEndpoint: + def get(self, req_options=None): + calls[0] += 1 + assert calls[0] <= 10, f"get() called {calls[0]} times — infinite loop detected" + if calls[0] >= 2: + raise ServerResponseError("400006", "Bad Request", "Invalid page number", "http://test") + item = TSC.ProjectItem(name="Test") + item._id = "abc" + pagination = TSC.PaginationItem() + pagination._total_available = None # unknown size — triggers loop + return [item], pagination + + qs: QuerySet[TSC.ProjectItem] = QuerySet(MockEndpoint()) # type: ignore[arg-type] + + # Use a generator expression to bypass list()'s __len__ optimization, + # which would consume the first page before __iter__ starts. + # Before the fix this raised: RuntimeError: generator raised StopIteration + results = [x for x in qs] + assert len(results) == 1 diff --git a/test/test_workbook.py b/test/test_workbook.py index 1232650d9..d740eaa7f 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -169,7 +169,6 @@ def test_get_by_id_missing_id(server: TSC.Server) -> None: 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( @@ -182,7 +181,6 @@ def test_refresh_id(server: TSC.Server) -> None: def test_refresh_already_running(server: TSC.Server) -> None: server.version = "2.8" - server.workbooks.baseurl response_xml = WORKBOOK_REFRESH_DUPLICATE_XML.read_text() with requests_mock.mock() as m: m.post( @@ -196,7 +194,6 @@ def test_refresh_already_running(server: TSC.Server) -> None: 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() @@ -326,7 +323,6 @@ def test_download_sanitizes_name(server: TSC.Server) -> None: 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( @@ -467,7 +463,6 @@ def test_populate_connections_missing_id(server: TSC.Server) -> None: 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( @@ -487,7 +482,6 @@ def test_populate_pdf(server: TSC.Server) -> None: 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", @@ -507,7 +501,6 @@ def test_populate_pdf_unsupported(server: TSC.Server) -> None: 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( @@ -531,7 +524,6 @@ def test_populate_pdf_vf_dims(server: TSC.Server) -> 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) @@ -892,7 +884,6 @@ def test_async_publish_timeout_error(server: TSC.Server) -> None: 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: @@ -906,7 +897,6 @@ def test_delete_extracts_all(server: TSC.Server) -> None: 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: @@ -920,7 +910,6 @@ def test_create_extracts_all(server: TSC.Server) -> None: 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" @@ -936,7 +925,6 @@ def test_create_extracts_one(server: TSC.Server) -> None: def test_revisions(server: TSC.Server) -> None: - server.workbooks.baseurl workbook = TSC.WorkbookItem("project", "test") workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" @@ -967,7 +955,6 @@ def test_revisions(server: TSC.Server) -> None: def test_delete_revision(server: TSC.Server) -> None: - server.workbooks.baseurl workbook = TSC.WorkbookItem("project", "test") workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" @@ -997,7 +984,6 @@ def test_bad_download_response(server: TSC.Server) -> None: 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() From 5c71930b6869909200f176a090ff4c4401b4b75b Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jun 2026 15:41:52 -0700 Subject: [PATCH 173/219] feat: add email as a filter field for users endpoint (#1789) feat: document email filter for users endpoint Fixes #1562 Co-authored-by: Claude Sonnet 4.6 --- .../server/endpoint/users_endpoint.py | 2 ++ tableauserverclient/server/request_options.py | 1 + test/test_user.py | 27 +++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 966ebd0a8..48c77da66 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -717,6 +717,8 @@ def filter(self, *invalid, page_size: int | None = None, **kwargs) -> QuerySet[U domain_name=... domain_name__in=... + email=... + email__in=... friendly_name=... friendly_name__in=... is_local=... diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 15cf2e0f4..5a8600908 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -137,6 +137,7 @@ class Field: DisplayTabs = "displayTabs" DomainName = "domainName" DomainNickname = "domainNickname" + Email = "email" FavoritesTotal = "favoritesTotal" Fields = "fields" FlowId = "flowId" diff --git a/test/test_user.py b/test/test_user.py index 6e481fce2..fec21d25f 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -541,6 +541,33 @@ def test_add_idp_and_auth_error(server: TSC.Server) -> None: server.users.bulk_add(users) +def test_filter_by_email(server: TSC.Server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(requests_mock.ANY, text=response_xml) + qs = server.users.filter(email="test@example.com") + + # Access total_available to trigger the HTTP request and inspect the query string + assert qs.total_available == 2 + assert len(m.request_history) >= 1 + first_request_qs = m.request_history[0].qs + assert "filter" in first_request_qs + assert "email:eq:test@example.com" in first_request_qs["filter"] + + +def test_filter_by_email_in(server: TSC.Server) -> None: + response_xml = GET_XML.read_text() + with requests_mock.mock() as m: + m.get(requests_mock.ANY, text=response_xml) + qs = server.users.filter(email__in=["alice@example.com", "bob@example.com"]) + + assert qs.total_available == 2 + assert len(m.request_history) >= 1 + first_request_qs = m.request_history[0].qs + assert "filter" in first_request_qs + assert "email:in:[alice@example.com,bob@example.com]" in first_request_qs["filter"] + + def test_remove_users_csv(server: TSC.Server) -> None: server.version = "3.15" users = [ From be0ab6458700aa1bb9090d5a77a284b6696b27df Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jun 2026 15:41:56 -0700 Subject: [PATCH 174/219] fix: accept both event names and source names for webhooks (#1790) Fixes #1597 Co-authored-by: Claude Sonnet 4.6 --- tableauserverclient/models/webhook_item.py | 11 ++- test/assets/webhook_get_new_event.xml | 14 ++++ test/test_webhook.py | 80 ++++++++++++++++++++++ 3 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 test/assets/webhook_get_new_event.xml diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index c8857bb75..a8dfe3ef0 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -72,12 +72,17 @@ def id(self) -> str | None: @property def event(self) -> str | None: if self._event: - return self._event.replace("webhook-source-event-", "") + return self._event.removeprefix("webhook-source-event-") return None @event.setter - def event(self, value: str) -> None: - self._event = f"webhook-source-event-{value}" + def event(self, value: str | None) -> None: + if value is None: + self._event = None + elif value.startswith("webhook-source-event-") or value.startswith("webhook-event-"): + self._event = value + else: + self._event = f"webhook-source-event-{value}" @classmethod def from_response(cls: type["WebhookItem"], resp: bytes, ns) -> list["WebhookItem"]: diff --git a/test/assets/webhook_get_new_event.xml b/test/assets/webhook_get_new_event.xml new file mode 100644 index 000000000..776a7ac99 --- /dev/null +++ b/test/assets/webhook_get_new_event.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/test/test_webhook.py b/test/test_webhook.py index e0217e93f..4fa011da0 100644 --- a/test/test_webhook.py +++ b/test/test_webhook.py @@ -10,6 +10,7 @@ TEST_ASSET_DIR = Path(__file__).parent / "assets" GET_XML = TEST_ASSET_DIR / "webhook_get.xml" +GET_NEW_EVENT_XML = TEST_ASSET_DIR / "webhook_get_new_event.xml" CREATE_XML = TEST_ASSET_DIR / "webhook_create.xml" CREATE_REQUEST_XML = TEST_ASSET_DIR / "webhook_create_request.xml" @@ -87,3 +88,82 @@ def test_request_factory(): 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 + + +def test_event_setter_none(): + """Setting event to None should store None without crashing.""" + item = WebhookItem() + item.event = "datasource-updated" + assert item.event == "datasource-updated" + item.event = None + assert item._event is None + assert item.event is None + + +def test_event_setter_short_name(): + """Short event names should be stored with the webhook-source-event- prefix.""" + item = WebhookItem() + item.event = "datasource-updated" + assert item._event == "webhook-source-event-datasource-updated" + assert item.event == "datasource-updated" + + +def test_event_setter_full_source_name(): + """Full webhook-source-event- names should be accepted and stored as-is.""" + item = WebhookItem() + item.event = "webhook-source-event-datasource-updated" + assert item._event == "webhook-source-event-datasource-updated" + assert item.event == "datasource-updated" + + +def test_event_setter_new_style_event_name(): + """New-style event names (webhook-event-*) should be stored as-is and not mangled.""" + item = WebhookItem() + item.event = "webhook-event-user-promoted-admin" + assert item._event == "webhook-event-user-promoted-admin" + assert item.event == "webhook-event-user-promoted-admin" + + +def test_get_new_style_event(server: TSC.Server) -> None: + """Webhooks with new-style event names (webhook-event-*) should parse correctly.""" + response_xml = GET_NEW_EVENT_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.id == "webhook-id-2" + assert webhook.name == "webhook-name-2" + assert webhook.url == "https://example.com/hook" + # New-style event name should not have the webhook-source-event- prefix stripped + assert webhook.event == "webhook-event-user-promoted-admin" + assert webhook.owner_id == "webhook_owner_luid" + + +def test_create_with_short_event_name(server: TSC.Server) -> None: + """Creating a webhook with a short event name (e.g. datasource-created) should work.""" + 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_create_with_source_event_name(server: TSC.Server) -> None: + """Creating a webhook with a full webhook-source-event-* name should work.""" + 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 = "webhook-source-event-datasource-created" + + new_webhook = server.webhooks.create(webhook_model) + assert new_webhook.id is not None From a4882166e0a00b8f51886048dae85fd97a3126c4 Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jun 2026 15:41:59 -0700 Subject: [PATCH 175/219] feat: include description in workbook update requests (#1791) * feat: include description in workbook publish and update requests The REST API has supported workbook description since v3.20 (2023.2). Fixes #899 Co-Authored-By: Claude Sonnet 4.6 * fix: correct version gate and test style for workbook description update - Restore version threshold to 3.21 (matching Tableau REST API docs) - Restore parent_srv is None guard to exclude description (original behavior) - Update tests to reflect 3.21 boundary - Use re.search on multipart body for publish test, matching existing pattern - Fix stale sample comment Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- samples/explore_workbook.py | 2 +- test/test_workbook.py | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index f51639ab3..d537f21d6 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -72,7 +72,7 @@ def main(): # Pick one workbook from the list sample_workbook = all_workbooks[0] sample_workbook.name = "Name me something cooler" - sample_workbook.description = "That doesn't work" + sample_workbook.description = "Updated description" updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) print(updated.name, updated.description) diff --git a/test/test_workbook.py b/test/test_workbook.py index d740eaa7f..5cab2dd35 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -242,6 +242,40 @@ def test_update(server: TSC.Server) -> None: assert not single_workbook.data_acceleration_config["accelerate_now"] +def test_update_description_in_request_xml(server: TSC.Server) -> None: + """description should be included in the update request XML when server >= 3.21.""" + server.version = "3.21" + 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.description = "A great workbook" + server.workbooks.update(single_workbook) + request_body = m.request_history[0].body + xml_root = fromstring(request_body) + workbook_el = xml_root.find(".//workbook") + assert workbook_el is not None + assert workbook_el.get("description") == "A great workbook" + + +def test_update_description_excluded_below_v3_21(server: TSC.Server) -> None: + """description should be excluded from the update request XML when server < 3.21.""" + server.version = "3.20" + 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.description = "A great workbook" + server.workbooks.update(single_workbook) + request_body = m.request_history[0].body + xml_root = fromstring(request_body) + workbook_el = xml_root.find(".//workbook") + assert workbook_el is not None + assert workbook_el.get("description") is None + + def test_update_missing_id(server: TSC.Server) -> None: single_workbook = TSC.WorkbookItem("test") with pytest.raises(TSC.MissingRequiredFieldError): @@ -584,6 +618,21 @@ def test_publish(server: TSC.Server) -> None: assert "REST API Testing" == new_workbook.description +def test_publish_description_in_request_xml(server: TSC.Server) -> None: + """description should be included in the publish request XML.""" + 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.description = "A great workbook" + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") + server.workbooks.publish(new_workbook, sample_workbook, server.PublishMode.CreateNew) + request_body = m._adapter.request_history[0]._request.body + assert re.search(b'description=\\"A great workbook\\"', request_body) + + def test_publish_a_packaged_file_object(server: TSC.Server) -> None: response_xml = PUBLISH_XML.read_text() with requests_mock.mock() as m: From 718b9301d0048f7889822a667f0bada2a7fc9bc7 Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jun 2026 15:42:03 -0700 Subject: [PATCH 176/219] fix: deprecate no_extract parameter with DeprecationWarning (#1793) fix: replace no_extract with DeprecationWarning, document include_extract no_extract was silently accepted but never documented as removed. Now raises DeprecationWarning and maps to include_extract=False so existing callers get a clear migration path rather than a silent no-op. Fixes #1541 Co-authored-by: Claude Sonnet 4.6 --- .../server/endpoint/datasources_endpoint.py | 23 +++++++++++++++---- .../server/endpoint/workbooks_endpoint.py | 20 +++++++++++++++- test/test_datasource.py | 16 +++++++++++++ test/test_workbook.py | 16 +++++++++++++ 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 34b32eab9..30c66d7ad 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -98,6 +98,9 @@ HyperAction = HyperActionTable | HyperActionRow +_UNSET = object() + + class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]): def __init__(self, parent_srv: "Server") -> None: super().__init__(parent_srv) @@ -240,6 +243,7 @@ def download( datasource_id: str, filepath: T, include_extract: bool = True, + no_extract: object = ..., ) -> T: ... @overload @@ -248,17 +252,18 @@ def download( datasource_id: str, filepath: FilePath | None = None, include_extract: bool = True, + no_extract: object = ..., ) -> 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, filepath=None, include_extract=True, + no_extract=_UNSET, ): """ Downloads the specified data source from a site. The data source is @@ -279,10 +284,20 @@ def download( If True, the extract is included in the download. If False, the extract is not included. - Returns - ------- - filepath : PathOrFileW + Notes + ----- + The ``no_extract`` parameter is deprecated. Use ``include_extract=False`` + instead. """ + if no_extract is not _UNSET: + import warnings + + warnings.warn( + "no_extract is deprecated and will be removed. Use include_extract=False instead.", + DeprecationWarning, + stacklevel=2, + ) + include_extract = not no_extract return self.download_revision( datasource_id, None, diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 2aea9c3ed..62c6203f9 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -64,6 +64,9 @@ PathOrFileW = FilePath | FileObjectW +_UNSET = object() + + class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): def __init__(self, parent_srv: "Server") -> None: super().__init__(parent_srv) @@ -405,6 +408,7 @@ def download( workbook_id: str, filepath: T, include_extract: bool = True, + no_extract: object = ..., ) -> T: ... @overload @@ -413,17 +417,18 @@ def download( workbook_id: str, filepath: FilePath | None = None, include_extract: bool = True, + no_extract: object = ..., ) -> 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, filepath=None, include_extract=True, + no_extract=_UNSET, ): """ Downloads a workbook to the specified directory (optional). @@ -451,8 +456,21 @@ def download( ------ ValueError If the workbook ID is not defined. + + Notes + ----- + The ``no_extract`` parameter is deprecated. Use ``include_extract=False`` + instead. """ + if no_extract is not _UNSET: + import warnings + warnings.warn( + "no_extract is deprecated and will be removed. Use include_extract=False instead.", + DeprecationWarning, + stacklevel=2, + ) + include_extract = not no_extract return self.download_revision( workbook_id, None, diff --git a/test/test_datasource.py b/test/test_datasource.py index 8b6f254ac..87189fb98 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -659,6 +659,22 @@ def test_download_extract_only(server) -> None: os.remove(file_path) +def test_download_no_extract_emits_deprecation_warning(server) -> None: + """no_extract=True should emit a DeprecationWarning and map to includeExtract=False.""" + 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, + ) + with pytest.warns(DeprecationWarning, match="deprecated and will be removed"): + file_path = server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", no_extract=True) + assert os.path.exists(file_path) + os.remove(file_path) + + def test_update_missing_id(server) -> None: single_datasource = TSC.DatasourceItem("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", "test") with pytest.raises(TSC.MissingRequiredFieldError): diff --git a/test/test_workbook.py b/test/test_workbook.py index 5cab2dd35..7aebcdead 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -370,6 +370,22 @@ def test_download_extract_only(server: TSC.Server) -> None: os.remove(file_path) +def test_download_no_extract_emits_deprecation_warning(server: TSC.Server) -> None: + """no_extract=True should emit a DeprecationWarning and map to includeExtract=False.""" + server.version = "2.5" + + 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, + ) + with pytest.warns(DeprecationWarning, match="deprecated and will be removed"): + file_path = server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2", no_extract=True) + 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("") From 4849074c1a3e5e2d8cef4551a19b8437e33f2e71 Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jun 2026 15:42:06 -0700 Subject: [PATCH 177/219] fix: handle missing error element in server error responses (#1794) Fixes #1083 Co-authored-by: Claude Sonnet 4.6 --- .../server/endpoint/exceptions.py | 23 +++++-- test/test_endpoint.py | 68 +++++++++++++++++++ 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 154aa4549..49e065ed3 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -33,13 +33,22 @@ def from_response(cls, resp, ns, url): # Check elements exist before .text parsed_response = fromstring(resp) try: - error_response = cls( - parsed_response.find("t:error", namespaces=ns).get("code", ""), - parsed_response.find(".//t:summary", namespaces=ns).text, - parsed_response.find(".//t:detail", namespaces=ns).text, - url, - ) - except Exception as e: + error_element = parsed_response.find("t:error", namespaces=ns) + summary_element = parsed_response.find(".//t:summary", namespaces=ns) + detail_element = parsed_response.find(".//t:detail", namespaces=ns) + + # Guard against responses that don't contain a t:error element + if error_element is None: + raw = resp.decode("utf-8", errors="replace") if isinstance(resp, bytes) else str(resp) + error_response = cls("", raw, raw, url) + else: + error_response = cls( + error_element.get("code", ""), + summary_element.text if summary_element is not None else "", + detail_element.text if detail_element is not None else "", + url, + ) + except Exception: raise NonXMLResponseError(resp) return error_response diff --git a/test/test_endpoint.py b/test/test_endpoint.py index 0b852ab0e..e4670d1fc 100644 --- a/test/test_endpoint.py +++ b/test/test_endpoint.py @@ -4,6 +4,11 @@ import tableauserverclient as TSC from tableauserverclient.server.endpoint import Endpoint +from tableauserverclient.server.endpoint.exceptions import ( + FailedSignInError, + NonXMLResponseError, + ServerResponseError, +) import requests_mock @@ -91,3 +96,66 @@ 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") + + +# --- ServerResponseError / FailedSignInError exception parsing (issue #1083) --- + +NS = {"t": "http://tableau.com/api"} + +STANDARD_ERROR_XML = b""" + + + Unauthorized Access + Invalid credentials were provided. + +""" + +NO_ERROR_ELEMENT_XML = b""" + + Something went wrong but with no error element +""" + +NOT_XML_CONTENT = b"Internal Server Error (not XML at all)" + + +def test_server_response_error_standard_xml(): + """Standard XML with a t:error element parses code/summary/detail correctly.""" + err = ServerResponseError.from_response(STANDARD_ERROR_XML, NS, "http://test/") + assert err.code == "401002" + assert "Unauthorized" in err.summary + assert "Invalid credentials" in err.detail + + +def test_server_response_error_no_error_element_does_not_raise(): + """XML without a t:error element must not raise AttributeError (issue #1083).""" + err = ServerResponseError.from_response(NO_ERROR_ELEMENT_XML, NS, "http://test/") + assert err.code == "" + # The raw XML content should appear in summary/detail as the fallback + assert "Something went wrong" in err.summary or len(err.summary) > 0 + + +def test_server_response_error_not_xml_raises_parse_error(): + """Non-XML content causes fromstring to raise a ParseError (not AttributeError).""" + import xml.etree.ElementTree as ET + + with pytest.raises(ET.ParseError): + ServerResponseError.from_response(NOT_XML_CONTENT, NS, "http://test/") + + +def test_failed_sign_in_error_no_error_element_does_not_raise(): + """FailedSignInError shares from_response — same None guard must apply.""" + err = FailedSignInError.from_response(NO_ERROR_ELEMENT_XML, NS, "http://test/") + assert err.code == "" + assert isinstance(err, FailedSignInError) + + +def test_server_response_error_missing_summary_and_detail(): + """XML with t:error but missing summary/detail children falls back gracefully.""" + xml = b""" + + +""" + err = ServerResponseError.from_response(xml, NS, "http://test/") + assert err.code == "500001" + assert err.summary == "" + assert err.detail == "" From e9d4860bb868c4de2774fca309df461aaf9e700b Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 19 Jun 2026 15:42:10 -0700 Subject: [PATCH 178/219] feat: add vizWidth/vizHeight to PDF export for views and workbooks (#1795) * feat: add vizWidth/vizHeight to PDFRequestOptions Fixes #1102 - PDFRequestOptions already inherited viz_width/viz_height from _ImagePDFCommonExportOptions (shared with ImageRequestOptions), which serialises them as vizWidth/vizHeight query params - Added version guard in views.populate_pdf: raises UnsupportedAttributeError when viz_height or viz_width are used below API 3.26 - Added tests for the new version guard, the happy path (API 3.26+), unit- level query-param serialisation, and the existing XOR validation - Removed stray bare literal '44' from test_request_option.py Co-Authored-By: Claude Sonnet 4.6 * fix: remove incorrect version gate on vizWidth/vizHeight for PDF export The REST API docs claimed vizHeight/vizWidth required API 3.26 and were Cloud-only, but server-side code shows these params have been accepted unconditionally since at least 2021.4 with no platform distinction. Remove the 3.26 guard; the endpoint's own minimum version is sufficient. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../server/endpoint/views_endpoint.py | 3 +- .../server/endpoint/workbooks_endpoint.py | 10 ++--- test/test_request_option.py | 41 +++++++++++++++++-- test/test_view.py | 3 +- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 19a30849a..c2779b17d 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -204,7 +204,8 @@ def populate_pdf(self, view_item: ViewItem, req_options: "PDFRequestOptions | No req_options: PDFRequestOptions | None, default None Optional request options for the request. These options can include - parameters such as orientation and paper size. + parameters such as orientation, paper size, and viz dimensions + (viz_width and viz_height). Returns ------- diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 62c6203f9..dd9722cbe 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -623,12 +623,10 @@ def populate_pdf(self, workbook_item: WorkbookItem, req_options: "PDFRequestOpti def pdf_fetcher() -> bytes: return self._get_wb_pdf(workbook_item, req_options) - if not self.parent_srv.check_at_least_version("3.23") and req_options is not None: - if req_options.view_filters or req_options.view_parameters: - raise UnsupportedAttributeError("view_filters and view_parameters are only supported in 3.23+") - - if req_options.viz_height or req_options.viz_width: - raise UnsupportedAttributeError("viz_height and viz_width are only supported in 3.23+") + if req_options is not None: + if not self.parent_srv.check_at_least_version("3.23"): + if req_options.view_filters or req_options.view_parameters: + raise UnsupportedAttributeError("view_filters and view_parameters are only supported in 3.23+") workbook_item._set_pdf(pdf_fetcher) logger.info(f"Populated pdf for workbook (ID: {workbook_item.id})") diff --git a/test/test_request_option.py b/test/test_request_option.py index 2c5354b2a..3709e18d3 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -380,9 +380,6 @@ 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: @@ -441,3 +438,41 @@ def test_queryset_field_all(server: TSC.Server) -> None: fields = history.qs.get("fields", [""])[0] assert fields == "_all_" + + +def test_pdf_viz_dimensions_query_params() -> None: + opts = TSC.PDFRequestOptions(viz_width=1920, viz_height=1080) + params = opts.get_query_params() + assert params["vizWidth"] == 1920 + assert params["vizHeight"] == 1080 + + +def test_pdf_viz_dimensions_only_one_raises() -> None: + opts = TSC.PDFRequestOptions(viz_width=1920) + with pytest.raises(ValueError): + opts.get_query_params() + + opts2 = TSC.PDFRequestOptions(viz_height=1080) + with pytest.raises(ValueError): + opts2.get_query_params() + + +def test_pdf_viz_dimensions_none_by_default() -> None: + opts = TSC.PDFRequestOptions() + params = opts.get_query_params() + assert "vizWidth" not in params + assert "vizHeight" not in params + + +def test_pdf_viz_dimensions_via_request(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = server.views.baseurl + "/abc/pdf" + opts = TSC.PDFRequestOptions(viz_width=800, viz_height=600) + + resp = server.views.get_request(url, request_object=opts) + query_string = parse_qs(resp.request.query) + assert "vizwidth" in query_string + assert ["800"] == query_string["vizwidth"] + assert "vizheight" in query_string + assert ["600"] == query_string["vizheight"] diff --git a/test/test_view.py b/test/test_view.py index a940e1d18..f4d5b7e1f 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -427,8 +427,7 @@ def test_filter_excel(server: TSC.Server) -> None: assert response == excel_file -def test_pdf_height(server: TSC.Server) -> None: - server.version = "3.8" +def test_pdf_viz_dimensions(server: TSC.Server) -> None: response = POPULATE_PDF.read_bytes() with requests_mock.mock() as m: m.get( From 897d0884cb72a8c8548d0568c2b55d620aed04ea Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 24 Jun 2026 11:17:54 -0700 Subject: [PATCH 179/219] always use the server version by default (#1796) Fixes #876 and #959 by auto-detecting and use the server's current REST API version *after* signin. This gives cleaner error scenarios when something goes wrong connecting/signing in. --- tableauserverclient/server/endpoint/auth_endpoint.py | 2 ++ tableauserverclient/server/server.py | 3 +-- test/http/test_http_requests.py | 6 ++++-- test/test_server_info.py | 10 +++++----- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 35dfa5d78..fe0c9b3da 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -89,6 +89,8 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None) self.parent_srv._set_auth(site_id, user_id, auth_token, site_url) logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}") + if self.parent_srv._use_server_version: + self.parent_srv.use_server_version() return Auth.contextmgr(self.sign_out) # We use the same request that username/password login uses for all auth types. diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 856387cea..6b0338970 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -195,8 +195,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.validate_connection_settings() # does not make an actual outgoing request self.version = default_server_version - if use_server_version: - self.use_server_version() # this makes a server call + self._use_server_version = use_server_version def validate_connection_settings(self): try: diff --git a/test/http/test_http_requests.py b/test/http/test_http_requests.py index a595e7d36..494d458ea 100644 --- a/test/http/test_http_requests.py +++ b/test/http/test_http_requests.py @@ -49,9 +49,11 @@ 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): +def test_init_server_model_bad_server_name_do_version_check(): + # use_server_version=True no longer makes a network call at construction; + # version detection is deferred until sign_in server = TSC.Server("fake-url", use_server_version=True) + assert server._use_server_version is True def test_init_server_model_bad_server_name_not_version_check_random_options(): diff --git a/test/test_server_info.py b/test/test_server_info.py index bc1a1bcb3..6eefab8a8 100644 --- a/test/test_server_info.py +++ b/test/test_server_info.py @@ -66,11 +66,11 @@ def test_server_info_use_highest_version_upgrades(server: TSC.Server) -> None: 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" + # use_server_version=True defers version detection to sign_in, so the + # version is not updated at construction time + server = TSC.Server("http://test", use_server_version=True) + assert server._use_server_version is True + assert server.version == "2.4" # still at default until sign_in def test_server_wrong_site(server: TSC.Server) -> None: From 02cbd4449b9ec909df7fbfa7d1c6d3b215ce32eb Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 25 Jun 2026 12:17:24 -0700 Subject: [PATCH 180/219] fix: correct wheel packaging and add release safety checks (#1798) fix: use exclude instead of include in packages.find to fix broken wheel The include=["tableauserverclient*"] glob caused setuptools to strip the tableauserverclient/ prefix from all subpackages, producing a wheel where top_level.txt listed bin/helpers/models/server instead of tableauserverclient. Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/publish-pypi.yml | 67 +++++++++++++++++++++----- .github/workflows/pypi-smoke-tests.yml | 2 + pyproject.toml | 7 ++- test/test_packaging.py | 19 ++++++++ 4 files changed, 81 insertions(+), 14 deletions(-) create mode 100644 test/test_packaging.py diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index d6d36f7ba..fe3728b9d 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,9 +1,7 @@ name: Publish to PyPi -# 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 +# Builds the package, checks wheel structure, publishes to TestPyPI, runs smoke +# tests against TestPyPI, then publishes to real PyPI only if all prior steps pass. on: release: types: [published] @@ -13,8 +11,8 @@ on: - 'v*.*.*' jobs: - build-n-publish: - name: Build dist files for PyPi + build: + name: Build and check wheel runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -22,22 +20,67 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v5 with: - python-version: 3.13 + python-version: '3.13' - name: Build dist files run: | python -m pip install --upgrade pip python -m pip install -e .[test] build python -m build git describe --tag --dirty --always - - - name: Publish distribution 📦 to Test PyPI # always run + - name: Check wheel structure + run: python -m pytest test/test_packaging.py -m packaging -v + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish-test-pypi: + name: Publish to Test PyPI + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Publish to Test PyPI uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 with: password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository_url: https://test.pypi.org/legacy/ - - - name: Publish distribution 📦 to PyPI - if: ${{ github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') }} + skip_existing: true + + smoke-test-pypi: + name: Smoke test from Test PyPI + needs: publish-test-pypi + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.x'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install from Test PyPI + run: | + pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ tableauserverclient + - name: Smoke test + run: | + python -c "import tableauserverclient as TSC; server = TSC.Server('http://example.com', use_server_version=False)" + + publish-pypi: + name: Publish to PyPI + needs: smoke-test-pypi + if: ${{ github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') }} + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 # license BSD-2 with: password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/pypi-smoke-tests.yml b/.github/workflows/pypi-smoke-tests.yml index 45ea94400..a125b4bc5 100644 --- a/.github/workflows/pypi-smoke-tests.yml +++ b/.github/workflows/pypi-smoke-tests.yml @@ -5,6 +5,8 @@ name: Pypi smoke tests on: workflow_dispatch: + release: + types: [published] schedule: - cron: 0 11 * * * # Every day at 11AM UTC (7AM EST) diff --git a/pyproject.toml b/pyproject.toml index 72f0625e4..8134f73f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ tableauserverclient = ["*"] [tool.setuptools.packages.find] where = ["."] -include = ["tableauserverclient*"] +exclude = ["test*", "samples*", "test_e2e*", "docs*", "dist*", "build*"] [tool.setuptools.dynamic] version = {attr = "versioneer.get_version"} @@ -68,7 +68,10 @@ exclude = ['/bin/'] [tool.pytest.ini_options] testpaths = ["test"] addopts = "--junitxml=./test.junit.xml -n auto" -markers = ["e2e: mark test as end-to-end (requires a real Tableau server)"] +markers = [ + "e2e: mark test as end-to-end (requires a real Tableau server)", + "packaging: mark test as requiring a built wheel in dist/", +] [tool.versioneer] VCS = "git" diff --git a/test/test_packaging.py b/test/test_packaging.py new file mode 100644 index 000000000..3674a938b --- /dev/null +++ b/test/test_packaging.py @@ -0,0 +1,19 @@ +import zipfile +from pathlib import Path +import pytest + +pytestmark = pytest.mark.packaging + + +def _find_wheel(): + wheels = list(Path("dist").glob("tableauserverclient-*.whl")) + if not wheels: + pytest.skip("No wheel in dist/ -- run 'python -m build --wheel' first") + return max(wheels, key=lambda p: p.stat().st_mtime) + + +def test_wheel_only_tableauserverclient_at_root(): + with zipfile.ZipFile(_find_wheel()) as whl: + top_dirs = {n.split("/")[0] for n in whl.namelist() if "/" in n} + non_dist_info = {d for d in top_dirs if not d.endswith(".dist-info") and not d.endswith(".data")} + assert non_dist_info == {"tableauserverclient"}, f"Unexpected top-level entries: {non_dist_info}" From ee174e19a228ace0438db45c7a1d17cf758826c9 Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 25 Jun 2026 16:00:42 -0700 Subject: [PATCH 181/219] fix: use tmp_path fixture in download tests to avoid cwd pollution (#1800) * fix: use tmp_path fixture in test_download to avoid cwd pollution * fix: use tmp_path fixture in all download tests to avoid cwd pollution --- test/test_datasource.py | 24 ++++++++++++------------ test/test_flow.py | 5 ++--- test/test_workbook.py | 24 ++++++++++++------------ 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/test/test_datasource.py b/test/test_datasource.py index 87189fb98..c4bca0a7b 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -608,15 +608,14 @@ def test_delete(server) -> None: server.datasources.delete("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb") -def test_download(server) -> None: +def test_download(server, tmp_path) -> 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") + file_path = server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", filepath=tmp_path) assert os.path.exists(file_path) - os.remove(file_path) def test_download_object(server) -> None: @@ -630,7 +629,7 @@ def test_download_object(server) -> None: assert isinstance(file_path, BytesIO) -def test_download_sanitizes_name(server) -> None: +def test_download_sanitizes_name(server, tmp_path) -> None: filename = "Name,With,Commas.tds" disposition = f'name="tableau_workbook"; filename="{filename}"' with requests_mock.mock() as m: @@ -638,13 +637,12 @@ def test_download_sanitizes_name(server) -> None: server.datasources.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", headers={"Content-Disposition": disposition}, ) - file_path = server.datasources.download("1f951daf-4061-451a-9df1-69a8062664f2") + file_path = server.datasources.download("1f951daf-4061-451a-9df1-69a8062664f2", filepath=tmp_path) 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: +def test_download_extract_only(server, tmp_path) -> None: # Pretend we're 2.5 for 'extract_only' server.version = "2.5" @@ -654,12 +652,13 @@ def test_download_extract_only(server) -> None: 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) + file_path = server.datasources.download( + "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", include_extract=False, filepath=tmp_path + ) assert os.path.exists(file_path) - os.remove(file_path) -def test_download_no_extract_emits_deprecation_warning(server) -> None: +def test_download_no_extract_emits_deprecation_warning(server, tmp_path) -> None: """no_extract=True should emit a DeprecationWarning and map to includeExtract=False.""" server.version = "2.5" @@ -670,9 +669,10 @@ def test_download_no_extract_emits_deprecation_warning(server) -> None: complete_qs=True, ) with pytest.warns(DeprecationWarning, match="deprecated and will be removed"): - file_path = server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", no_extract=True) + file_path = server.datasources.download( + "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", no_extract=True, filepath=tmp_path + ) assert os.path.exists(file_path) - os.remove(file_path) def test_update_missing_id(server) -> None: diff --git a/test/test_flow.py b/test/test_flow.py index 008c39a96..e0275dcc9 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -33,15 +33,14 @@ def server(): return server -def test_download(server: TSC.Server) -> None: +def test_download(server: TSC.Server, tmp_path: Path) -> 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") + file_path = server.flows.download("587daa37-b84d-4400-a9a2-aa90e0be7837", filepath=tmp_path) assert os.path.exists(file_path) is True - os.remove(file_path) def test_download_object(server: TSC.Server) -> None: diff --git a/test/test_workbook.py b/test/test_workbook.py index 7aebcdead..7f4d80041 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -318,15 +318,14 @@ def test_update_tags(server: TSC.Server) -> None: assert single_workbook._initial_tags == updated_workbook._initial_tags -def test_download(server: TSC.Server) -> None: +def test_download(server: TSC.Server, tmp_path: Path) -> 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") + file_path = server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2", filepath=tmp_path) assert os.path.exists(file_path) - os.remove(file_path) def test_download_object(server: TSC.Server) -> None: @@ -340,7 +339,7 @@ def test_download_object(server: TSC.Server) -> None: assert isinstance(file_path, BytesIO) -def test_download_sanitizes_name(server: TSC.Server) -> None: +def test_download_sanitizes_name(server: TSC.Server, tmp_path: Path) -> None: filename = "Name,With,Commas.twbx" disposition = f'name="tableau_workbook"; filename="{filename}"' with requests_mock.mock() as m: @@ -348,13 +347,12 @@ def test_download_sanitizes_name(server: TSC.Server) -> None: server.workbooks.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/content", headers={"Content-Disposition": disposition}, ) - file_path = server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2") + file_path = server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2", filepath=tmp_path) 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: +def test_download_extract_only(server: TSC.Server, tmp_path: Path) -> None: # Pretend we're 2.5 for 'extract_only' server.version = "2.5" @@ -365,12 +363,13 @@ def test_download_extract_only(server: TSC.Server) -> None: 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) + file_path = server.workbooks.download( + "1f951daf-4061-451a-9df1-69a8062664f2", include_extract=False, filepath=tmp_path + ) assert os.path.exists(file_path) - os.remove(file_path) -def test_download_no_extract_emits_deprecation_warning(server: TSC.Server) -> None: +def test_download_no_extract_emits_deprecation_warning(server: TSC.Server, tmp_path: Path) -> None: """no_extract=True should emit a DeprecationWarning and map to includeExtract=False.""" server.version = "2.5" @@ -381,9 +380,10 @@ def test_download_no_extract_emits_deprecation_warning(server: TSC.Server) -> No complete_qs=True, ) with pytest.warns(DeprecationWarning, match="deprecated and will be removed"): - file_path = server.workbooks.download("1f951daf-4061-451a-9df1-69a8062664f2", no_extract=True) + file_path = server.workbooks.download( + "1f951daf-4061-451a-9df1-69a8062664f2", no_extract=True, filepath=tmp_path + ) assert os.path.exists(file_path) - os.remove(file_path) def test_download_missing_id(server: TSC.Server) -> None: From 7f3a26f148cf353b04a6595af1bbecf9f9b7f93f Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Mon, 20 Jul 2026 17:12:27 -0700 Subject: [PATCH 182/219] Update several actions to the latest stable versions (#1814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update several actions to the latest stable versions Note: third-party action versions not updated in this PR The simonw/actions-latest versions list only tracks actions/* org actions, so these three third-party actions were left untouched but should be revisited separately: - pypa/gh-action-pypi-publish@release/v1 — pinned to the maintainer's documented floating branch ref (their README explicitly shows this usage). Latest concrete tag is v1.14.0. Per pypa's own "pro tip," pinning to an exact tag/SHA instead of the branch is more reproducible — worth considering. - MishaKav/pytest-coverage-comment@main — currently tracks the main branch directly (not a version tag). Latest release is v1.10.0. Should pin to a tagged version instead of main to avoid unreviewed changes landing silently. - archive/github-actions-slack@v2.8.0 — real version bump available to v3.1.0 (major version jump). Needs a changelog/breaking-change check before upgrading. * Remove draft package test action I accidentally included here --- .github/workflows/code-coverage.yml | 4 ++-- .github/workflows/meta-checks.yml | 6 +++--- .github/workflows/publish-pypi.yml | 12 ++++++------ .github/workflows/pypi-smoke-tests.yml | 2 +- .github/workflows/run-tests.yml | 6 +++--- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 70bc845e9..7afaf71b4 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -16,10 +16,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 0e2b425ee..b174b191f 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -20,17 +20,17 @@ jobs: echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: cache - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.pip-cache.outputs.dir }} key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} restore-keys: | ${{ runner.os }}-${{ matrix.python-version }}-pip- - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index fe3728b9d..f28993f97 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -15,10 +15,10 @@ jobs: name: Build and check wheel runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: '3.13' - name: Build dist files @@ -29,7 +29,7 @@ jobs: git describe --tag --dirty --always - name: Check wheel structure run: python -m pytest test/test_packaging.py -m packaging -v - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: dist path: dist/ @@ -39,7 +39,7 @@ jobs: needs: build runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: dist path: dist/ @@ -60,7 +60,7 @@ jobs: python-version: ['3.x'] runs-on: ${{ matrix.os }} steps: - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install from Test PyPI @@ -76,7 +76,7 @@ jobs: if: ${{ github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') }} runs-on: ubuntu-latest steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: name: dist path: dist/ diff --git a/.github/workflows/pypi-smoke-tests.yml b/.github/workflows/pypi-smoke-tests.yml index a125b4bc5..ce12bccbb 100644 --- a/.github/workflows/pypi-smoke-tests.yml +++ b/.github/workflows/pypi-smoke-tests.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: pip install diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 9ac7ebb6a..ba3ba8671 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -25,17 +25,17 @@ jobs: echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: cache - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.pip-cache.outputs.dir }} key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }} restore-keys: | ${{ runner.os }}-${{ matrix.python-version }}-pip- - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} allow-prereleases: ${{ matrix.allow-prereleases || false }} From 5379cc8bac33580fce531a6579ddf3420ca2fc64 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Mon, 20 Jul 2026 17:12:48 -0700 Subject: [PATCH 183/219] Add dependabot to watch for Python and Actions updates, create PRs into development branch (#1815) Add dependabot to watch for Python and Actions updates, create PRs for development --- .github/dependabot.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..38411afcc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + target-branch: "development" + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + target-branch: "development" + schedule: + interval: "weekly" From d12d70267995554576289b3b4de3559f156e1c5e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:16:40 -0700 Subject: [PATCH 184/219] chore(deps): bump archive/github-actions-slack from 2.8.0 to 3.1.0 (#1819) Bumps [archive/github-actions-slack](https://github.com/archive/github-actions-slack) from 2.8.0 to 3.1.0. - [Release notes](https://github.com/archive/github-actions-slack/releases) - [Commits](https://github.com/archive/github-actions-slack/compare/v2.8.0...v3.1.0) --- updated-dependencies: - dependency-name: archive/github-actions-slack dependency-version: 3.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/slack.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml index 9afebf25b..65cfc3cde 100644 --- a/.github/workflows/slack.yml +++ b/.github/workflows/slack.yml @@ -10,7 +10,7 @@ jobs: steps: - name: Send message to Slack API continue-on-error: true - uses: archive/github-actions-slack@v2.8.0 + uses: archive/github-actions-slack@v3.1.0 id: notify with: slack-bot-user-oauth-access-token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }} From f4387a2c9cf2d0829f2087ec2597f68f6eb0ca99 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:45:57 -0700 Subject: [PATCH 185/219] chore(deps-dev): bump black from 26.3.1 to 26.5.1 (#1822) Bumps [black](https://github.com/psf/black) from 26.3.1 to 26.5.1. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/26.3.1...26.5.1) --- updated-dependencies: - dependency-name: black dependency-version: 26.5.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8134f73f1..c063a9b0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["black==26.3.1", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", +test = ["black==26.5.1", "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.package-data] From 5c59faa6a6c12b7dc94466f7c264898d0ab307eb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:47:26 -0700 Subject: [PATCH 186/219] chore(deps): bump actions/setup-python from 6 to 7 (#1820) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/code-coverage.yml | 2 +- .github/workflows/meta-checks.yml | 2 +- .github/workflows/publish-pypi.yml | 4 ++-- .github/workflows/pypi-smoke-tests.yml | 2 +- .github/workflows/run-tests.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 7afaf71b4..9f5e53acb 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index b174b191f..554febf64 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index f28993f97..8c0289ba2 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v7 with: python-version: '3.13' - name: Build dist files @@ -60,7 +60,7 @@ jobs: python-version: ['3.x'] runs-on: ${{ matrix.os }} steps: - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install from Test PyPI diff --git a/.github/workflows/pypi-smoke-tests.yml b/.github/workflows/pypi-smoke-tests.yml index ce12bccbb..930d1bd5b 100644 --- a/.github/workflows/pypi-smoke-tests.yml +++ b/.github/workflows/pypi-smoke-tests.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: pip install diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index ba3ba8671..aab5d1cb3 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} allow-prereleases: ${{ matrix.allow-prereleases || false }} From 64ffa092c771735dc719b9decb0bf38e12fb4e8f Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 23 Jul 2026 01:00:41 -0700 Subject: [PATCH 187/219] Extract download streaming logic into DownloadableMixin (#1803) * feat: strengthen QuerysetEndpoint base class hierarchy * Remove unused io_types_w after DownloadableMixin refactor After the download logic was extracted into DownloadableMixin._download_content, the module-level io_types_w tuples in datasources, flows, and workbooks endpoints became dead code. Also removes the duplicate io_types_r / io_types_w definitions in flows_endpoint and the unreferenced io_types alias in datasources_endpoint. All 843 tests pass. Co-Authored-By: Claude Sonnet 4.6 * Add DownloadableMixin to endpoint package __all__ The mixin was imported in __init__.py but omitted from __all__, making the export implicit. Add it explicitly so the public surface matches the import statement. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../server/endpoint/__init__.py | 3 +- .../server/endpoint/datasources_endpoint.py | 27 +-------- .../server/endpoint/endpoint.py | 57 +++++++++++++++++++ .../server/endpoint/flows_endpoint.py | 29 +--------- .../server/endpoint/workbooks_endpoint.py | 26 +-------- 5 files changed, 68 insertions(+), 74 deletions(-) diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index d944bc429..0a6eb8640 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -4,7 +4,7 @@ from tableauserverclient.server.endpoint.data_alert_endpoint import DataAlerts from tableauserverclient.server.endpoint.databases_endpoint import Databases from tableauserverclient.server.endpoint.datasources_endpoint import Datasources -from tableauserverclient.server.endpoint.endpoint import Endpoint, QuerysetEndpoint +from tableauserverclient.server.endpoint.endpoint import Endpoint, QuerysetEndpoint, DownloadableMixin from tableauserverclient.server.endpoint.exceptions import ServerResponseError, MissingRequiredFieldError from tableauserverclient.server.endpoint.extensions_endpoint import Extensions from tableauserverclient.server.endpoint.favorites_endpoint import Favorites @@ -40,6 +40,7 @@ "DataAlerts", "Databases", "Datasources", + "DownloadableMixin", "QuerysetEndpoint", "MissingRequiredFieldError", "Endpoint", diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 30c66d7ad..f5212485a 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,10 +1,8 @@ -from email.message import Message import copy import json import io import os -from contextlib import closing from pathlib import Path from typing import Literal, TYPE_CHECKING, TypedDict, TypeVar, overload from collections.abc import Iterable, Sequence @@ -18,7 +16,7 @@ from .schedules_endpoint import AddResponse from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint -from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in +from tableauserverclient.server.endpoint.endpoint import DownloadableMixin, QuerysetEndpoint, api, parameter_added_in from tableauserverclient.server.endpoint.exceptions import ( DUPLICATE_EXTRACT_JOB_CODE, InternalServerError, @@ -30,10 +28,8 @@ from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, BYTES_PER_MB, config from tableauserverclient.filesys_helpers import ( - make_download_path, get_file_type, get_file_object_size, - to_filename, ) from tableauserverclient.helpers.logging import logger from tableauserverclient.models import ( @@ -46,9 +42,7 @@ ) from tableauserverclient.server import RequestFactory, RequestOptions -io_types = (io.BytesIO, io.BufferedReader) io_types_r = (io.BytesIO, io.BufferedReader) -io_types_w = (io.BytesIO, io.BufferedWriter) FilePath = str | os.PathLike FileObject = io.BufferedReader | io.BytesIO @@ -101,7 +95,7 @@ _UNSET = object() -class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]): +class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem], DownloadableMixin): def __init__(self, parent_srv: "Server") -> None: super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) @@ -1061,22 +1055,7 @@ def download_revision( if not include_extract: url += "?includeExtract=False" - with closing(self.get_request(url, parameters={"stream": True})) as server_response: - m = Message() - m["Content-Disposition"] = server_response.headers["Content-Disposition"] - filename = m.get_filename(failobj="") - if isinstance(filepath, io_types_w): - for chunk in server_response.iter_content(1024): # 1KB - filepath.write(chunk) - return_path = filepath - else: - filename = to_filename(os.path.basename(filename)) - download_path = make_download_path(filepath, filename) - with open(download_path, "wb") as f: - for chunk in server_response.iter_content(1024): # 1KB - f.write(chunk) - return_path = os.path.abspath(download_path) - + return_path = self._download_content(url, filepath) logger.info(f"Downloaded datasource revision {revision_number} to {return_path} (ID: {datasource_id})") return return_path diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 149aab5ec..31a0806dc 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,3 +1,7 @@ +from email.message import Message +import io +import os +from contextlib import closing from typing_extensions import Concatenate, ParamSpec from tableauserverclient import datetime_helpers as datetime @@ -18,6 +22,7 @@ from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions +from tableauserverclient.filesys_helpers import to_filename, make_download_path from tableauserverclient.server.endpoint.exceptions import ( FailedSignInError, @@ -323,6 +328,58 @@ def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R: T = TypeVar("T") +_io_types_w = (io.BytesIO, io.BufferedWriter) + +FilePath = str | os.PathLike +FileObjectW = io.BufferedWriter | io.BytesIO +PathOrFileW = FilePath | FileObjectW + + +class DownloadableMixin: + """Mixin for endpoints whose resources can be downloaded as binary files. + + Provides a single private helper that streams a server response to a file + path or writable file object, avoiding copy-paste of the identical streaming + loop in Workbooks, Datasources, and Flows. + """ + + def _download_content( + self, + url: str, + filepath: PathOrFileW | None, + ) -> PathOrFileW: + """Stream content at url to filepath and return the resolved path. + + Parameters + ---------- + url : str + Fully-qualified URL whose response body should be saved. + filepath : PathOrFileW | None + Destination file path or writable file object. When None the file + is saved to the current working directory using the server-supplied + filename from the Content-Disposition header. + + Returns + ------- + PathOrFileW + The absolute file path written, or the caller-supplied file object. + """ + with closing(self.get_request(url, parameters={"stream": True})) as server_response: # type: ignore[attr-defined] + m = Message() + m["Content-Disposition"] = server_response.headers["Content-Disposition"] + filename = m.get_filename(failobj="") + if isinstance(filepath, _io_types_w): + for chunk in server_response.iter_content(1024): # 1KB + filepath.write(chunk) + return filepath + else: + filename = to_filename(os.path.basename(filename)) + download_path = make_download_path(filepath, filename) + with open(download_path, "wb") as f: + for chunk in server_response.iter_content(1024): # 1KB + f.write(chunk) + return os.path.abspath(download_path) + class QuerysetEndpoint(Endpoint, Generic[T]): @api(version="2.0") diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index ea7b59526..8595006b8 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -1,15 +1,13 @@ -from email.message import Message import copy import io import logging import os -from contextlib import closing from pathlib import Path from typing import TYPE_CHECKING from collections.abc import Iterable from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint -from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.endpoint import DownloadableMixin, QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import ( DUPLICATE_EXTRACT_JOB_CODE, InternalServerError, @@ -21,18 +19,12 @@ from tableauserverclient.models import FlowItem, PaginationItem, ConnectionItem, JobItem from tableauserverclient.server import RequestFactory from tableauserverclient.filesys_helpers import ( - to_filename, - make_download_path, get_file_type, get_file_object_size, ) from tableauserverclient.server.query import QuerySet io_types_r = (io.BytesIO, io.BufferedReader) -io_types_w = (io.BytesIO, io.BufferedWriter) - -io_types_r = (io.BytesIO, io.BufferedReader) -io_types_w = (io.BytesIO, io.BufferedWriter) # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -55,7 +47,7 @@ PathOrFileW = FilePath | FileObjectW -class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem]): +class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem], DownloadableMixin): def __init__(self, parent_srv): super().__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) @@ -227,22 +219,7 @@ def download(self, flow_id: str, filepath: PathOrFileW | None = None) -> PathOrF raise ValueError(error) url = f"{self.baseurl}/{flow_id}/content" - with closing(self.get_request(url, parameters={"stream": True})) as server_response: - m = Message() - m["Content-Disposition"] = server_response.headers["Content-Disposition"] - filename = m.get_filename(failobj="") - if isinstance(filepath, io_types_w): - for chunk in server_response.iter_content(1024): # 1KB - filepath.write(chunk) - return_path = filepath - else: - filename = to_filename(os.path.basename(filename)) - download_path = make_download_path(filepath, filename) - with open(download_path, "wb") as f: - for chunk in server_response.iter_content(1024): # 1KB - f.write(chunk) - return_path = os.path.abspath(download_path) - + return_path = self._download_content(url, filepath) logger.info(f"Downloaded flow to {return_path} (ID: {flow_id})") return return_path diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index dd9722cbe..8310d58b3 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -1,15 +1,13 @@ -from email.message import Message import copy import io import logging import os -from contextlib import closing from pathlib import Path from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.server.query import QuerySet -from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in +from tableauserverclient.server.endpoint.endpoint import DownloadableMixin, QuerysetEndpoint, api, parameter_added_in from tableauserverclient.server.endpoint.exceptions import ( DUPLICATE_EXTRACT_JOB_CODE, InternalServerError, @@ -21,8 +19,6 @@ from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin from tableauserverclient.filesys_helpers import ( - to_filename, - make_download_path, get_file_type, get_file_object_size, ) @@ -47,7 +43,6 @@ from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse io_types_r = (io.BytesIO, io.BufferedReader) -io_types_w = (io.BytesIO, io.BufferedWriter) # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT = 1024 * 1024 * 64 # 64MB @@ -67,7 +62,7 @@ _UNSET = object() -class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): +class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem], DownloadableMixin): def __init__(self, parent_srv: "Server") -> None: super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) @@ -1123,22 +1118,7 @@ def download_revision( if not include_extract: url += "?includeExtract=False" - with closing(self.get_request(url, parameters={"stream": True})) as server_response: - m = Message() - m["Content-Disposition"] = server_response.headers["Content-Disposition"] - filename = m.get_filename(failobj="") - if isinstance(filepath, io_types_w): - for chunk in server_response.iter_content(1024): # 1KB - filepath.write(chunk) - return_path = filepath - else: - filename = to_filename(os.path.basename(filename)) - download_path = make_download_path(filepath, filename) - with open(download_path, "wb") as f: - for chunk in server_response.iter_content(1024): # 1KB - f.write(chunk) - return_path = os.path.abspath(download_path) - + return_path = self._download_content(url, filepath) logger.info(f"Downloaded workbook revision {revision_number} to {return_path} (ID: {workbook_id})") return return_path From 2df882cc2956064f95b50e4b5daa9e7e63fa9d71 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Thu, 23 Jul 2026 13:18:09 -0700 Subject: [PATCH 188/219] Add "stale" action to help manage issues and pull requests (#1827) Closes #1680 --- .github/workflows/stale.yml | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/stale.yml diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..74694bf4c --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,41 @@ +name: Mark stale issues and pull requests + +on: + schedule: + - cron: '30 1 * * *' + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + stale-issue-label: 'stale' + stale-pr-label: 'stale' + exempt-issue-labels: 'good first issue,Design Proposal,in-progress' + exempt-pr-labels: 'in-progress' + + days-before-issue-stale: 60 + days-before-issue-close: 14 + days-before-pr-stale: 90 + days-before-pr-close: 14 + + close-issue-reason: 'not_planned' + + stale-issue-message: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed in 14 days if no further activity occurs. + If this is still relevant, please comment to keep it open. + stale-pr-message: > + This pull request has been automatically marked as stale because it has not had + recent activity. It will be closed in 14 days if no further activity occurs. + If this is still active work, please comment or push a new commit to keep it open. + close-issue-message: > + Closing due to inactivity. If this is still relevant, please reopen or file a new issue. + close-pr-message: > + Closing due to inactivity. Feel free to reopen if you'd like to continue this work. From 09ffa11a2e7a87e05c25d58a39bd4744a06066ca Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 23 Jul 2026 13:18:44 -0700 Subject: [PATCH 189/219] fix: resolve mypy 2.3 type errors (unblocks dependabot #1821) (#1826) * chore(deps-dev): bump mypy from 1.4 to 2.3.0 Bumps [mypy](https://github.com/python/mypy) from 1.4 to 2.3.0. - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v1.4.0...v2.3.0) --- updated-dependencies: - dependency-name: mypy dependency-version: 2.3.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * fix: resolve mypy 2.3 errors in strings.py and test_datasource.py - strings.py: cast tostring() return value to T since mypy 2.3 no longer accepts bytes as compatible with TypeVar T constrained to str|bytes - test_datasource.py: add explicit `import unittest.mock` since mypy 2.3 no longer resolves submodule attributes through the parent import Co-Authored-By: Claude Sonnet 4.6 * fix: correctly resolve mypy 2.3 TypeVar error in strings.py The bytes overload was passing bytearray to _redact_any_type whose TypeVar T is constrained to str|bytes. mypy 2.3 correctly rejects bytearray as Sequence[int]. Fix: pass bytes directly and cast the return value for both call sites. Co-Authored-By: Claude Sonnet 4.6 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- pyproject.toml | 2 +- tableauserverclient/helpers/strings.py | 9 +++++---- test/test_datasource.py | 1 + 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c063a9b0e..e5a330c39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["black==26.5.1", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", +test = ["black==26.5.1", "build", "mypy==2.3.0", "pytest>=7.0", "pytest-cov", "pytest-subtests", "pytest-xdist", "requests-mock>=1.0,<2.0", "types-requests>=2.32.4.20250913"] [tool.setuptools.package-data] diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py index 7a0eaddc7..a095d606e 100644 --- a/tableauserverclient/helpers/strings.py +++ b/tableauserverclient/helpers/strings.py @@ -1,6 +1,6 @@ from defusedxml.ElementTree import fromstring, tostring from functools import singledispatch -from typing import TypeVar, overload +from typing import TypeVar, cast, overload # the redact method can handle either strings or bytes, but it can't mix them. # Generic type so we can write the actual logic once, then use singledispatch to @@ -17,8 +17,9 @@ def _redact_any_type(xml: T, sensitive_word: T, replacement: T, encoding=None) - matches = root.findall(".//password") for item in matches: item.text = "********" - # tostring returns bytes unless an encoding value is passed - return tostring(root, encoding=encoding) + # tostring returns bytes unless an encoding value is passed; cast since + # the callers pass encoding="unicode" for str and None for bytes + return cast(T, tostring(root, encoding=encoding)) except Exception: # something about the xml handling failed. Just cut off the text at the first occurrence of "password" location = xml.find(sensitive_word) @@ -39,7 +40,7 @@ def _(xml: str) -> str: @redact_xml.register # type: ignore[no-redef] def _(xml: bytes) -> bytes: - return _redact_any_type(bytearray(xml), b"password", b"..[redacted]") + return cast(bytes, _redact_any_type(xml, b"password", b"..[redacted]")) @overload diff --git a/test/test_datasource.py b/test/test_datasource.py index c4bca0a7b..95332c6db 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -3,6 +3,7 @@ from pathlib import Path import tempfile import unittest +import unittest.mock from zipfile import ZipFile from defusedxml.ElementTree import fromstring From 94ab7619e4b23073bcf7c2a0a393e7b867e8266d Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 23 Jul 2026 13:25:59 -0700 Subject: [PATCH 190/219] fix: correct Filter serialization for datetimes and booleans (#1805) * fix: prevent double-encoding of colons in date filter values Fixes #1025 Co-Authored-By: Claude Sonnet 4.6 * fix: serialize boolean filter values as lowercase true/false Tableau REST API requires boolean filter values to be lowercase ('true'/'false'), but Python's str(bool) produces 'True'/'False'. Add explicit bool handling in Filter.__str__ alongside the existing datetime fix, and expand test coverage to include bool, int, datetime (format, timezone conversion, no-encoding), list edge cases, and the invalid-operator-with-list guard. Co-Authored-By: Claude Sonnet 4.6 * fix: reject naive datetimes and document Filter.__str__ format (#1025) Naive datetime objects passed to Filter now raise ValueError with a clear message instead of silently converting using the local system timezone, which would produce wrong UTC values on any non-UTC machine. Also adds a docstring to Filter.__str__ documenting the serialization rules for all supported value types. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- tableauserverclient/server/filter.py | 27 +++++++ test/test_filter.py | 109 +++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index fd90e281f..2e64361ba 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -1,3 +1,6 @@ +import datetime + +from tableauserverclient.datetime_helpers import format_datetime from .request_options import RequestOptions @@ -9,6 +12,30 @@ def __init__(self, field, operator, value): self.value = value def __str__(self): + """Return the filter as a Tableau REST API filter string. + + Format: ``::`` + + Value serialization rules: + - datetime: ISO-8601 UTC, e.g. ``2023-01-01T00:00:00Z``. + Naive datetimes (no tzinfo) are rejected with ValueError; always + pass timezone-aware datetime objects. + - bool: lowercase ``true`` or ``false`` as required by the REST API. + - list: bracket-enclosed comma-separated values, e.g. ``[a,b,c]``. + Only valid with the ``in`` operator. + - All other types: ``str()`` of the value. + """ + if isinstance(self._value, datetime.datetime): + if self._value.tzinfo is None: + raise ValueError( + "Naive datetime passed to Filter; Tableau Server requires UTC. " + "Use a timezone-aware datetime, e.g. " + "datetime.datetime(..., tzinfo=datetime.timezone.utc)." + ) + return f"{self.field}:{self.operator}:{format_datetime(self._value)}" + if isinstance(self._value, bool): + # Tableau REST API requires lowercase 'true'/'false', not Python's 'True'/'False' + return f"{self.field}:{self.operator}:{str(self._value).lower()}" value_string = str(self._value) if isinstance(self._value, list): # this should turn the string representation of the list diff --git a/test/test_filter.py b/test/test_filter.py index 460813dd5..8d3ec541e 100644 --- a/test/test_filter.py +++ b/test/test_filter.py @@ -1,3 +1,5 @@ +import datetime + import tableauserverclient as TSC @@ -14,3 +16,110 @@ def test_filter_in(): filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, projects_to_find) assert str(filter) == "name:in:[default,Salesforce Sales Projeśt]" + + +def test_filter_in_single_value(): + """A single-element list produces valid bracket syntax.""" + filter = TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["sample"]) + + assert str(filter) == "tags:in:[sample]" + + +def test_filter_in_multiple_values(): + """Multi-element list produces comma-separated values inside brackets.""" + filter = TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.In, ["a", "b", "c"]) + + assert str(filter) == "tags:in:[a,b,c]" + + +def test_filter_integer_value(): + """Integer filter values are serialized as plain decimal strings.""" + filter = TSC.Filter(TSC.RequestOptions.Field.Size, TSC.RequestOptions.Operator.GreaterThan, 0) + + assert str(filter) == "size:gt:0" + + +def test_filter_integer_nonzero(): + filter = TSC.Filter(TSC.RequestOptions.Field.SheetCount, TSC.RequestOptions.Operator.GreaterThanOrEqual, 5) + + assert str(filter) == "sheetCount:gte:5" + + +def test_filter_date_no_encoding(): + """Date filter values should not have colons pre-encoded (fixes #1025). + + The requests library handles URL encoding of the whole filter parameter, + so pre-encoding colons in datetime values causes double-encoding on the wire. + """ + utc = datetime.timezone.utc + dt = datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=utc) + filter = TSC.Filter(TSC.RequestOptions.Field.CreatedAt, TSC.RequestOptions.Operator.LessThan, dt) + + result = str(filter) + assert result == "createdAt:lt:2023-01-01T00:00:00Z" + assert "%3A" not in result, "Colons in datetime values must not be percent-encoded" + + +def test_filter_date_uses_tableau_format(): + """Datetime values are serialized in Tableau ISO-8601 format, not Python default.""" + utc = datetime.timezone.utc + dt = datetime.datetime(2024, 6, 15, 12, 30, 45, tzinfo=utc) + filter = TSC.Filter(TSC.RequestOptions.Field.UpdatedAt, TSC.RequestOptions.Operator.GreaterThan, dt) + + result = str(filter) + # Must use 'T' separator and 'Z' suffix, not Python's space-separated format + assert result == "updatedAt:gt:2024-06-15T12:30:45Z" + assert " " not in result.split(":", 2)[2], "Datetime value must not contain a space (Python default format)" + + +def test_filter_date_non_utc_converted(): + """Non-UTC datetime values are converted to UTC before serialization.""" + eastern = datetime.timezone(datetime.timedelta(hours=-5)) + dt = datetime.datetime(2023, 3, 10, 12, 0, 0, tzinfo=eastern) + filter = TSC.Filter(TSC.RequestOptions.Field.CreatedAt, TSC.RequestOptions.Operator.Equals, dt) + + result = str(filter) + assert result == "createdAt:eq:2023-03-10T17:00:00Z" + + +def test_filter_bool_true(): + """Boolean True is serialized as lowercase 'true' for Tableau REST API.""" + filter = TSC.Filter(TSC.RequestOptions.Field.IsCertified, TSC.RequestOptions.Operator.Equals, True) + + result = str(filter) + assert result == "isCertified:eq:true" + assert "True" not in result, "Boolean True must be lowercase 'true'" + + +def test_filter_bool_false(): + """Boolean False is serialized as lowercase 'false' for Tableau REST API.""" + filter = TSC.Filter(TSC.RequestOptions.Field.IsCertified, TSC.RequestOptions.Operator.Equals, False) + + result = str(filter) + assert result == "isCertified:eq:false" + assert "False" not in result, "Boolean False must be lowercase 'false'" + + +def test_filter_bool_has_extracts(): + """Boolean filter works for hasExtracts field.""" + filter = TSC.Filter(TSC.RequestOptions.Field.HasExtracts, TSC.RequestOptions.Operator.Equals, True) + + assert str(filter) == "hasExtracts:eq:true" + + +def test_filter_date_naive_raises(): + """A naive datetime (no tzinfo) raises ValueError with a helpful message.""" + import pytest + + naive_dt = datetime.datetime(2023, 1, 1, 12, 0, 0) # no tzinfo + f = TSC.Filter(TSC.RequestOptions.Field.CreatedAt, TSC.RequestOptions.Operator.Equals, naive_dt) + with pytest.raises(ValueError, match="Naive datetime"): + str(f) + + +def test_filter_list_rejects_non_in_operator(): + """A list value with a non-In operator raises ValueError.""" + import pytest + + with pytest.raises(ValueError): + TSC.Filter(TSC.RequestOptions.Field.Tags, TSC.RequestOptions.Operator.Equals, ["a", "b"]) From af1d77dfe65cc7f2fe8c74326e8c8611b1b91997 Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 23 Jul 2026 13:26:24 -0700 Subject: [PATCH 191/219] Add Projects.get_by_path() for hierarchy-aware project lookup (#1804) * feat: add get_by_path to projects endpoint Fixes #1317 Co-Authored-By: Claude Sonnet 4.6 * test: expand get_by_path coverage and sharpen docstring Add test cases for trailing slash, root-level disambiguation (API returns same-named projects at different hierarchy levels), duplicate siblings (first result is used), deep three-level paths, and names containing spaces. Add three new XML fixture files to support these scenarios. Improve the get_by_path docstring to document the client-side root-level parentProjectId filter, the duplicate-name tie-break, and the empty-component-stripping behavior. Co-Authored-By: Claude Sonnet 4.6 * docs: add CHANGELOG entry for Projects.get_by_path Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- CHANGELOG.md | 7 + .../server/endpoint/projects_endpoint.py | 71 ++++++ .../project_get_by_name_ambiguous_root.xml | 8 + test/assets/project_get_by_name_child.xml | 7 + ...project_get_by_name_duplicate_siblings.xml | 8 + .../assets/project_get_by_name_grandchild.xml | 7 + test/assets/project_get_by_name_top_level.xml | 7 + test/assets/project_get_empty.xml | 6 + test/test_project.py | 233 ++++++++++++++++++ 9 files changed, 354 insertions(+) create mode 100644 test/assets/project_get_by_name_ambiguous_root.xml create mode 100644 test/assets/project_get_by_name_child.xml create mode 100644 test/assets/project_get_by_name_duplicate_siblings.xml create mode 100644 test/assets/project_get_by_name_grandchild.xml create mode 100644 test/assets/project_get_by_name_top_level.xml create mode 100644 test/assets/project_get_empty.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index c018294d3..943436b27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ +## Unreleased + +* Added `Projects.get_by_path(path)` to look up a project by its slash-separated + hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by + level using the REST API name filter, so a path with *n* components issues *n* + requests. Returns the matching `ProjectItem` or `None` if no project is found. + ## 0.18.0 (6 April 2022) * Switched to using defused_xml for xml attack protection * added linting and type hints diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index f63110592..edc6ef670 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -5,6 +5,7 @@ from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server import RequestFactory, RequestOptions +from tableauserverclient.server.filter import Filter from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.models import ProjectItem, PaginationItem, Resource @@ -89,6 +90,76 @@ 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_path(self, path: str) -> "ProjectItem | None": + """ + Retrieves a project by its path. The path is a slash-separated string + of project names from the root to the target project, for example + ``"Marketing/Q1 Reports"`` or ``"/Marketing/Q1 Reports"``. + + There is no native path filter in the Tableau REST API, so this method + walks the project hierarchy level by level using ``filter(name=...)``. + Each level makes one API request, so a path with *n* components issues + *n* requests. + + At the root level, the API ``filter(name=...)`` may return projects from + different levels of the hierarchy that share the same name. Only + projects with no parent (``parentProjectId`` absent) are considered at + this step. For subsequent levels, the ``parentProjectId`` filter is sent + to the server so only direct children of the current project are + returned. + + If multiple sibling projects share the same name at any level, the + first project returned by the API is used and the rest are ignored. + + Parameters + ---------- + path : str + The slash-separated path of the project. Leading and trailing + slashes are ignored. Empty components (e.g. from consecutive + slashes) are discarded. + + Returns + ------- + ProjectItem | None + The matching project, or ``None`` if no project exists at the + given path. + + Raises + ------ + ValueError + If ``path`` is empty or contains only slashes. + """ + components = [c for c in path.split("/") if c] + if not components: + raise ValueError("Project path must not be empty.") + + # Walk the hierarchy one level at a time. + parent_id: "str | None" = None + current: "ProjectItem | None" = None + + for name in components: + opts = RequestOptions() + opts.filter.add(Filter(RequestOptions.Field.Name, RequestOptions.Operator.Equals, name)) + if parent_id is not None: + opts.filter.add(Filter(RequestOptions.Field.ParentProjectId, RequestOptions.Operator.Equals, parent_id)) + + projects, _ = self.get(opts) + + if parent_id is None: + # At the root level the API may return projects with the same + # name that belong to different parents; keep only top-level ones. + projects = [p for p in projects if p.parent_id is None] + + if not projects: + return None + + # If multiple sibling projects share the same name, take the first. + current = projects[0] + parent_id = current.id + + return current + @api(version="2.0") def get_by_id(self, project_id: str) -> ProjectItem: """ diff --git a/test/assets/project_get_by_name_ambiguous_root.xml b/test/assets/project_get_by_name_ambiguous_root.xml new file mode 100644 index 000000000..b2b65ed64 --- /dev/null +++ b/test/assets/project_get_by_name_ambiguous_root.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/assets/project_get_by_name_child.xml b/test/assets/project_get_by_name_child.xml new file mode 100644 index 000000000..2ce185c1d --- /dev/null +++ b/test/assets/project_get_by_name_child.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/assets/project_get_by_name_duplicate_siblings.xml b/test/assets/project_get_by_name_duplicate_siblings.xml new file mode 100644 index 000000000..68f73b3bd --- /dev/null +++ b/test/assets/project_get_by_name_duplicate_siblings.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/assets/project_get_by_name_grandchild.xml b/test/assets/project_get_by_name_grandchild.xml new file mode 100644 index 000000000..70ef855c0 --- /dev/null +++ b/test/assets/project_get_by_name_grandchild.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/assets/project_get_by_name_top_level.xml b/test/assets/project_get_by_name_top_level.xml new file mode 100644 index 000000000..df7cdb38e --- /dev/null +++ b/test/assets/project_get_by_name_top_level.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/assets/project_get_empty.xml b/test/assets/project_get_empty.xml new file mode 100644 index 000000000..d8c206124 --- /dev/null +++ b/test/assets/project_get_empty.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/test_project.py b/test/test_project.py index eb33f6732..839838101 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -1,4 +1,5 @@ from pathlib import Path +from urllib.parse import parse_qs, urlparse import pytest import requests_mock @@ -22,6 +23,12 @@ UPDATE_VIRTUALCONNECTION_DEFAULT_PERMISSIONS_XML = ( TEST_ASSET_DIR / "project_update_virtualconnection_default_permissions.xml" ) +GET_BY_NAME_TOP_LEVEL_XML = TEST_ASSET_DIR / "project_get_by_name_top_level.xml" +GET_BY_NAME_CHILD_XML = TEST_ASSET_DIR / "project_get_by_name_child.xml" +GET_EMPTY_XML = TEST_ASSET_DIR / "project_get_empty.xml" +GET_BY_NAME_AMBIGUOUS_ROOT_XML = TEST_ASSET_DIR / "project_get_by_name_ambiguous_root.xml" +GET_BY_NAME_DUPLICATE_SIBLINGS_XML = TEST_ASSET_DIR / "project_get_by_name_duplicate_siblings.xml" +GET_BY_NAME_GRANDCHILD_XML = TEST_ASSET_DIR / "project_get_by_name_grandchild.xml" @pytest.fixture(scope="function") @@ -464,3 +471,229 @@ def test_get_all_fields(server: TSC.Server) -> None: assert project.content_permissions == "ManagedByOwner" assert project.parent_id is None assert project.writeable is True + + +# --- get_by_path tests --- + + +def test_get_by_path_top_level(server: TSC.Server) -> None: + """A single-component path resolves to a top-level project.""" + response_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text() + with requests_mock.mock() as m: + m.get(server.projects.baseurl + "?filter=name:eq:Tableau", text=response_xml) + project = server.projects.get_by_path("Tableau") + + assert project is not None + assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74" + assert project.name == "Tableau" + assert project.parent_id is None + + +def test_get_by_path_top_level_with_leading_slash(server: TSC.Server) -> None: + """Leading slash is stripped; result is the same as without it.""" + response_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text() + with requests_mock.mock() as m: + m.get(server.projects.baseurl + "?filter=name:eq:Tableau", text=response_xml) + project = server.projects.get_by_path("/Tableau") + + assert project is not None + assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74" + + +def _filter_params(request) -> dict: + """Parse the comma-separated 'filter' query param into a dict of field:value pairs.""" + qs = parse_qs(urlparse(request.url).query) + filters = {} + for token in qs.get("filter", [""])[0].split(","): + if ":" in token: + parts = token.split(":", 2) # field, operator, value + if len(parts) == 3: + filters[parts[0]] = parts[2] + return filters + + +def test_get_by_path_nested(server: TSC.Server) -> None: + """A two-component path walks the hierarchy and returns the child project.""" + top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text() + child_xml = GET_BY_NAME_CHILD_XML.read_text() + baseurl = server.projects.baseurl + + def respond(request, context): + params = _filter_params(request) + if params.get("name") == "Tableau" and "parentProjectId" not in params: + return top_level_xml + if params.get("name") == "Child 1" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74": + return child_xml + context.status_code = 404 + return "" + + with requests_mock.mock() as m: + m.get(baseurl, text=respond) + project = server.projects.get_by_path("Tableau/Child 1") + + assert project is not None + assert project.id == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf" + assert project.name == "Child 1" + assert project.parent_id == "1d0304cd-3796-429f-b815-7258370b9b74" + + +def test_get_by_path_not_found_root(server: TSC.Server) -> None: + """Returns None when the root-level component does not exist.""" + empty_xml = GET_EMPTY_XML.read_text() + baseurl = server.projects.baseurl + + def respond(request, context): + params = _filter_params(request) + if params.get("name") == "NonExistent": + return empty_xml + context.status_code = 404 + return "" + + with requests_mock.mock() as m: + m.get(baseurl, text=respond) + project = server.projects.get_by_path("NonExistent") + + assert project is None + + +def test_get_by_path_not_found_child(server: TSC.Server) -> None: + """Returns None when a child component does not exist under the parent.""" + top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text() + empty_xml = GET_EMPTY_XML.read_text() + baseurl = server.projects.baseurl + + def respond(request, context): + params = _filter_params(request) + if params.get("name") == "Tableau" and "parentProjectId" not in params: + return top_level_xml + if params.get("name") == "NoSuchChild": + return empty_xml + context.status_code = 404 + return "" + + with requests_mock.mock() as m: + m.get(baseurl, text=respond) + project = server.projects.get_by_path("Tableau/NoSuchChild") + + assert project is None + + +def test_get_by_path_empty_raises(server: TSC.Server) -> None: + """An empty or slash-only path raises ValueError.""" + with pytest.raises(ValueError): + server.projects.get_by_path("") + + with pytest.raises(ValueError): + server.projects.get_by_path("/") + + +def test_get_by_path_trailing_slash(server: TSC.Server) -> None: + """Trailing slash is stripped; result is the same as the bare name.""" + response_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text() + with requests_mock.mock() as m: + m.get(server.projects.baseurl + "?filter=name:eq:Tableau", text=response_xml) + project = server.projects.get_by_path("Tableau/") + + assert project is not None + assert project.id == "1d0304cd-3796-429f-b815-7258370b9b74" + + +def test_get_by_path_root_filters_non_top_level(server: TSC.Server) -> None: + """When the API returns projects with the same name at different levels, + get_by_path keeps only the one with no parent when looking at the root.""" + ambiguous_xml = GET_BY_NAME_AMBIGUOUS_ROOT_XML.read_text() + baseurl = server.projects.baseurl + + def respond(request, context): + params = _filter_params(request) + if params.get("name") == "Shared" and "parentProjectId" not in params: + return ambiguous_xml + context.status_code = 404 + return "" + + with requests_mock.mock() as m: + m.get(baseurl, text=respond) + project = server.projects.get_by_path("Shared") + + # The implementation filters to parent_id is None at the root level, so + # only "bbbbbbbb-..." (the actual top-level project) should be returned. + assert project is not None + assert project.id == "bbbbbbbb-0000-0000-0000-000000000002" + assert project.parent_id is None + + +def test_get_by_path_duplicate_siblings_returns_first(server: TSC.Server) -> None: + """When multiple sibling projects share the same name, the first one is returned.""" + top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text() + duplicate_xml = GET_BY_NAME_DUPLICATE_SIBLINGS_XML.read_text() + baseurl = server.projects.baseurl + + def respond(request, context): + params = _filter_params(request) + if params.get("name") == "Tableau" and "parentProjectId" not in params: + return top_level_xml + if params.get("name") == "Reports" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74": + return duplicate_xml + context.status_code = 404 + return "" + + with requests_mock.mock() as m: + m.get(baseurl, text=respond) + project = server.projects.get_by_path("Tableau/Reports") + + # Duplicate siblings: implementation takes the first result from the API. + assert project is not None + assert project.id == "cccccccc-0000-0000-0000-000000000001" + + +def test_get_by_path_deep_three_levels(server: TSC.Server) -> None: + """A three-component path issues three requests and returns the deepest project.""" + top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text() + child_xml = GET_BY_NAME_CHILD_XML.read_text() + grandchild_xml = GET_BY_NAME_GRANDCHILD_XML.read_text() + baseurl = server.projects.baseurl + + def respond(request, context): + params = _filter_params(request) + if params.get("name") == "Tableau" and "parentProjectId" not in params: + return top_level_xml + if params.get("name") == "Child 1" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74": + return child_xml + if params.get("name") == "Q1" and params.get("parentProjectId") == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf": + return grandchild_xml + context.status_code = 404 + return "" + + with requests_mock.mock() as m: + m.get(baseurl, text=respond) + project = server.projects.get_by_path("Tableau/Child 1/Q1") + + assert project is not None + assert project.id == "eeeeeeee-0000-0000-0000-000000000001" + assert project.name == "Q1" + assert project.parent_id == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf" + + +def test_get_by_path_with_spaces_in_name(server: TSC.Server) -> None: + """Project names containing spaces are handled correctly.""" + # Reuses the child fixture whose name is 'Child 1' (contains a space). + top_level_xml = GET_BY_NAME_TOP_LEVEL_XML.read_text() + child_xml = GET_BY_NAME_CHILD_XML.read_text() + baseurl = server.projects.baseurl + + def respond(request, context): + params = _filter_params(request) + if params.get("name") == "Tableau" and "parentProjectId" not in params: + return top_level_xml + if params.get("name") == "Child 1" and params.get("parentProjectId") == "1d0304cd-3796-429f-b815-7258370b9b74": + return child_xml + context.status_code = 404 + return "" + + with requests_mock.mock() as m: + m.get(baseurl, text=respond) + project = server.projects.get_by_path("Tableau/Child 1") + + assert project is not None + assert project.id == "4cc52973-5e3a-4d1f-a4fb-5b5f73796edf" + assert project.name == "Child 1" From 7f7285e2d904e95d2c82a332a4d1b2a03d4f2485 Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 23 Jul 2026 16:49:42 -0700 Subject: [PATCH 192/219] security: add environment gate to production PyPI publish job (#1830) Adds `environment: pypi` to the publish-pypi job, wiring it to the GitHub Environment configured with required reviewers. Without this, any repo write holder could push a tag or invoke workflow_dispatch and publish to production PyPI with no human checkpoint. Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/publish-pypi.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 8c0289ba2..8bc11466d 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -75,6 +75,7 @@ jobs: needs: smoke-test-pypi if: ${{ github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v') }} runs-on: ubuntu-latest + environment: pypi steps: - uses: actions/download-artifact@v8 with: From 3ddfcf277514f76ea7d1ecf3413d18b1738afce6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:32:06 -0700 Subject: [PATCH 193/219] chore(deps): bump actions/stale from 9 to 11 (#1833) Bumps [actions/stale](https://github.com/actions/stale) from 9 to 11. - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/v9...v11) --- updated-dependencies: - dependency-name: actions/stale dependency-version: '11' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 74694bf4c..4fdd6e09c 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@v11 with: stale-issue-label: 'stale' stale-pr-label: 'stale' From b6d08356c23f5c78e59773a820036df3ca1ccd29 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Wed, 5 Aug 2026 19:13:24 -0700 Subject: [PATCH 194/219] Improve the stale actions config now that it's been running for a bit (#1853) - increase operations-per-run beyond the default 30 - add permission for action to write its cache Taken together, these should let the action run on the oldest issues and PRs we have. --- .github/workflows/stale.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 4fdd6e09c..576a09926 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,4 +1,4 @@ -name: Mark stale issues and pull requests +name: Stale issues and pull requests on: schedule: @@ -8,6 +8,7 @@ on: permissions: issues: write pull-requests: write + actions: write jobs: stale: @@ -25,6 +26,8 @@ jobs: days-before-pr-stale: 90 days-before-pr-close: 14 + operations-per-run: 300 + close-issue-reason: 'not_planned' stale-issue-message: > From e729e66d462658197505df047da8c643619326c8 Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 6 Aug 2026 13:20:17 -0700 Subject: [PATCH 195/219] feat: parse / into JobItem.status_notes (#1850) (#1852) The REST Query Job response schema documents a structured status-notes block: JobItem was parsing only the sibling legacy `` element (emitted by some job types like extractRefreshJob), missing the modern statusNotes entirely. For UserImport jobs and any other multi-row job where individual rows have distinct outcomes, `job.notes` came back as an empty list even when the server had sent detailed structured status. Add `JobItem.status_notes: list[dict]`, each dict with keys `type`, `value`, `text` (any of which may be None if the server omitted them). The legacy `notes: list[str]` attribute is unchanged for backwards compatibility -- it still parses the `` element still emitted by extract-refresh and similar older job types. Verified against the public REST doc: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job The existing job_get_by_id.xml test asset already contained a statusNotes block; the get_by_id test now asserts the structured value in addition to the legacy notes list. Two new tests cover the absent case (yields []) and the multi-note case with attribute omissions. Discovered while planning tabcmd createsiteusers nowait / silent-progress work (tableau/tabcmd#35); a live probe against Tableau Server 2025.1 confirmed the server emits this schema for UserImport jobs. Fixes #1850. --- CHANGELOG.md | 7 ++++ tableauserverclient/models/job_item.py | 29 +++++++++++++++- test/test_job.py | 47 ++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 943436b27..fc1430d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by level using the REST API name filter, so a path with *n* components issues *n* requests. Returns the matching `ProjectItem` or `None` if no project is found. +* Added `JobItem.status_notes` for the structured `` block documented on the Query + Job REST endpoint. Populated for UserImport and other multi-row jobs where + individual rows have distinct outcomes; each entry is a dict with keys + `type` / `value` / `text`. The existing `notes: list[str]` attribute is + unchanged (it parses the separate legacy `` element still emitted by + some job types). Fixes #1850. ## 0.18.0 (6 April 2022) * Switched to using defused_xml for xml attack protection diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index f684c22d4..ae6150eb5 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -49,7 +49,19 @@ class JobItem: The finish code of the job. 0 for success, 1 for failure, 2 for cancelled. notes : list[str] | None - Contains detailed notes about the job. + Detail notes emitted by legacy job types (e.g. extract refresh) inside + job-specific elements like `...`. + For modern job types see `status_notes`. + + status_notes : list[dict] | None + Structured per-row / per-metric status entries from the modern job response + schema (``). + Each element is a dict with keys `type`, `value`, `text` (any of which may + be None if the server omitted them). Populated for UserImport and other + multi-row jobs where individual rows have distinct outcomes; documented + types include `CountOfUsersAddedToSite`, `CountOfUsersSkipped`, + `CountOfUsersWithInsufficientLicenses`, etc. + See https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job mode : str | None @@ -100,6 +112,7 @@ def __init__( updated_at: datetime.datetime | None = None, workbook_name: str | None = None, datasource_name: str | None = None, + status_notes: list[dict] | None = None, ): self._id = id_ self._type = job_type @@ -116,6 +129,7 @@ def __init__( self._updated_at = updated_at self._workbook_name = workbook_name self._datasource_name = datasource_name + self._status_notes: list[dict] = status_notes or [] @property def id(self) -> str: @@ -149,6 +163,10 @@ def finish_code(self) -> int: def notes(self) -> list[str]: return self._notes + @property + def status_notes(self) -> list[dict]: + return self._status_notes + @property def mode(self) -> str | None: return self._mode @@ -222,6 +240,14 @@ def _parse_element(cls, element, ns): completed_at = parse_datetime(element.get("completedAt", None)) finish_code = int(element.get("finishCode", -1)) notes = [note.text for note in element.findall(".//t:notes", namespaces=ns)] or None + status_notes = [ + { + "type": note.get("type"), + "value": note.get("value"), + "text": note.get("text"), + } + for note in element.findall(".//t:statusNotes/t:statusNote", namespaces=ns) + ] or None mode = element.get("mode", None) workbook = element.find(".//t:workbook[@id]", namespaces=ns) workbook_id = workbook.get("id") if workbook is not None else None @@ -253,6 +279,7 @@ def _parse_element(cls, element, ns): updated_at, workbook_name, datasource_name, + status_notes, ) diff --git a/test/test_job.py b/test/test_job.py index 19f324d1e..7bfdd1840 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -63,6 +63,53 @@ def test_get_by_id(server: TSC.Server) -> None: assert job_id == job.id assert updated_at == job.updated_at assert job.notes == ["Job detail notes"] + # Regression for #1850: the response also carries a + # block per the public REST doc. Verify it now surfaces via job.status_notes. + assert job.status_notes == [ + { + "type": "CountOfUsersAddedToGroup", + "value": "5", + "text": "Description of how many users were added to the group during the import.", + } + ] + + +def test_status_notes_empty_when_absent() -> None: + # A job element with no yields an empty list, not None or an error. + xml = ( + b"" + b"" + b"" + ) + jobs = TSC.JobItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(jobs) == 1 + assert jobs[0].status_notes == [] + + +def test_status_notes_multiple_entries() -> None: + # Multiple statusNote elements yield an ordered list of dicts. Any of type / + # value / text may be absent on a given note; missing attributes come back as None. + xml = ( + b"" + b"" + b"" + b"" + b"" + b"" + b"" + b"" + b"" + b"" + ) + jobs = TSC.JobItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(jobs) == 1 + notes = jobs[0].status_notes + assert notes == [ + {"type": "line", "value": "0", "text": None}, + {"type": "errorCode", "value": "1", "text": None}, + {"type": "message", "value": "Actor does not have permission", "text": None}, + {"type": "username", "value": "unknown", "text": None}, + ] def test_get_before_signin(server: TSC.Server) -> None: From aa9e3a0bd3114e0dbb7ec41abd4784483fb89277 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Thu, 6 Aug 2026 15:55:18 -0700 Subject: [PATCH 196/219] fix: normalize CRLF line endings to LF in six Python files (#1816) * fix: normalize CRLF line endings to LF in six Python files Several files had CRLF line endings baked in from a prior black-version bump commit, causing ^M noise in diffs. Also reformats the multi-line .format() calls in data_alert_item.py and subscription_item.py to match current black formatting. Co-Authored-By: Claude Sonnet 5 * Run type and style checks on Python 3.13 now * Black format, run via Python 3.13 * chore: add .gitattributes to enforce LF line endings for Python files Prevents a repeat of the CRLF regression fixed in the prior commit by normalizing line endings at commit time. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .gitattributes | 4 + .github/workflows/meta-checks.yml | 2 +- samples/export.py | 214 ++++++------- samples/smoke_test.py | 30 +- tableauserverclient/datetime_helpers.py | 88 +++--- tableauserverclient/helpers/strings.py | 134 ++++----- tableauserverclient/models/column_item.py | 142 ++++----- .../models/subscription_item.py | 280 +++++++++--------- 8 files changed, 449 insertions(+), 445 deletions(-) diff --git a/.gitattributes b/.gitattributes index 040321c04..d4cabf8c3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,6 @@ tableauserverclient/_version.py export-subst tableauserverclient/bin/_version.py export-subst + +# Normalize line endings to LF to prevent CRLF from creeping into Python files +* text=auto +*.py text eol=lf diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 554febf64..abc3714dc 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -8,7 +8,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.10'] + python-version: ['3.13'] runs-on: ${{ matrix.os }} diff --git a/samples/export.py b/samples/export.py index c7f1cdb06..3200d491f 100644 --- a/samples/export.py +++ b/samples/export.py @@ -1,107 +1,107 @@ -#### -# This script demonstrates how to export a view using the Tableau -# Server Client. -# -# To run the script, you must have installed Python 3.7 or later. -#### - -import argparse -import logging - -import tableauserverclient as TSC - - -def main(): - parser = argparse.ArgumentParser(description="Export a view as an image, PDF, or CSV") - # 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 - group = parser.add_mutually_exclusive_group(required=True) - group.add_argument( - "--pdf", dest="type", action="store_const", const=("populate_pdf", "PDFRequestOptions", "pdf", "pdf") - ) - group.add_argument( - "--png", dest="type", action="store_const", const=("populate_image", "ImageRequestOptions", "image", "png") - ) - group.add_argument( - "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") - ) - # other options shown in explore_workbooks: workbook.download, workbook.preview_image - parser.add_argument( - "--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en" - ) - parser.add_argument("--workbook", action="store_true") - parser.add_argument("--custom_view", action="store_true") - - parser.add_argument("--file", "-f", help="filename to store the exported data") - parser.add_argument("--filter", "-vf", metavar="COLUMN:VALUE", help="View filter to apply to the view") - parser.add_argument("resource_id", help="LUID for the view or workbook") - - 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) - - 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): - print("Connected") - if args.workbook: - item = server.workbooks.get_by_id(args.resource_id) - elif args.custom_view: - item = server.custom_views.get_by_id(args.resource_id) - else: - item = server.views.get_by_id(args.resource_id) - - if not item: - print(f"No item found for id {args.resource_id}") - exit(1) - - print(f"Item found: {item.name}") - # We have a number of different types and functions for each different export type. - # We encode that information above in the const=(...) parameter to the add_argument function to make - # the code automatically adapt for the type of export the user is doing. - # We unroll that information into methods we can call, or objects we can create by using getattr() - populate_func_name, option_factory_name, member_name, extension = args.type - populate = getattr(server.views, populate_func_name) - if args.workbook: - populate = getattr(server.workbooks, populate_func_name) - elif args.custom_view: - populate = getattr(server.custom_views, populate_func_name) - - option_factory = getattr(TSC, option_factory_name) - options: TSC.PDFRequestOptions = option_factory() - - if args.filter: - options = options.vf(*args.filter.split(":")) - - if args.language: - options.language = args.language - - if args.file: - filename = args.file - else: - filename = f"out-{options.language}.{extension}" - - populate(item, options) - with open(filename, "wb") as f: - if member_name == "csv": - f.writelines(getattr(item, member_name)) - else: - f.write(getattr(item, member_name)) - print("saved to " + filename) - - -if __name__ == "__main__": - main() +#### +# This script demonstrates how to export a view using the Tableau +# Server Client. +# +# To run the script, you must have installed Python 3.7 or later. +#### + +import argparse +import logging + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description="Export a view as an image, PDF, or CSV") + # 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 + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + "--pdf", dest="type", action="store_const", const=("populate_pdf", "PDFRequestOptions", "pdf", "pdf") + ) + group.add_argument( + "--png", dest="type", action="store_const", const=("populate_image", "ImageRequestOptions", "image", "png") + ) + group.add_argument( + "--csv", dest="type", action="store_const", const=("populate_csv", "CSVRequestOptions", "csv", "csv") + ) + # other options shown in explore_workbooks: workbook.download, workbook.preview_image + parser.add_argument( + "--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en" + ) + parser.add_argument("--workbook", action="store_true") + parser.add_argument("--custom_view", action="store_true") + + parser.add_argument("--file", "-f", help="filename to store the exported data") + parser.add_argument("--filter", "-vf", metavar="COLUMN:VALUE", help="View filter to apply to the view") + parser.add_argument("resource_id", help="LUID for the view or workbook") + + 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) + + 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): + print("Connected") + if args.workbook: + item = server.workbooks.get_by_id(args.resource_id) + elif args.custom_view: + item = server.custom_views.get_by_id(args.resource_id) + else: + item = server.views.get_by_id(args.resource_id) + + if not item: + print(f"No item found for id {args.resource_id}") + exit(1) + + print(f"Item found: {item.name}") + # We have a number of different types and functions for each different export type. + # We encode that information above in the const=(...) parameter to the add_argument function to make + # the code automatically adapt for the type of export the user is doing. + # We unroll that information into methods we can call, or objects we can create by using getattr() + populate_func_name, option_factory_name, member_name, extension = args.type + populate = getattr(server.views, populate_func_name) + if args.workbook: + populate = getattr(server.workbooks, populate_func_name) + elif args.custom_view: + populate = getattr(server.custom_views, populate_func_name) + + option_factory = getattr(TSC, option_factory_name) + options: TSC.PDFRequestOptions = option_factory() + + if args.filter: + options = options.vf(*args.filter.split(":")) + + if args.language: + options.language = args.language + + if args.file: + filename = args.file + else: + filename = f"out-{options.language}.{extension}" + + populate(item, options) + with open(filename, "wb") as f: + if member_name == "csv": + f.writelines(getattr(item, member_name)) + else: + f.write(getattr(item, member_name)) + print("saved to " + filename) + + +if __name__ == "__main__": + main() diff --git a/samples/smoke_test.py b/samples/smoke_test.py index 237257014..778686abf 100644 --- a/samples/smoke_test.py +++ b/samples/smoke_test.py @@ -1,15 +1,15 @@ -# This sample verifies that tableau server client is installed -# and you can run it. It also shows the version of the client. - -import logging -import tableauserverclient as TSC - -logger = logging.getLogger("Sample") -logger.setLevel(logging.DEBUG) -logger.addHandler(logging.StreamHandler()) - - -server = TSC.Server("Fake-Server-Url", use_server_version=False) -print("Client details:") -logger.info(server.server_address) -logger.debug(TSC.server.endpoint.Endpoint.set_user_agent({})) +# This sample verifies that tableau server client is installed +# and you can run it. It also shows the version of the client. + +import logging +import tableauserverclient as TSC + +logger = logging.getLogger("Sample") +logger.setLevel(logging.DEBUG) +logger.addHandler(logging.StreamHandler()) + + +server = TSC.Server("Fake-Server-Url", use_server_version=False) +print("Client details:") +logger.info(server.server_address) +logger.debug(TSC.server.endpoint.Endpoint.set_user_agent({})) diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index e18db4c78..7beb0276e 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -1,44 +1,44 @@ -import datetime - -ZERO = datetime.timedelta(0) -HOUR = datetime.timedelta(hours=1) - - -def timestamp(): - return datetime.datetime.now().strftime("%H:%M:%S") - - -# This class is a concrete implementation of the abstract base class tzinfo -# docs: https://docs.python.org/2.3/lib/datetime-tzinfo.html -class UTC(datetime.tzinfo): - """UTC""" - - def utcoffset(self, dt): - return ZERO - - def tzname(self, dt): - return "UTC" - - def dst(self, dt): - return ZERO - - -utc = UTC() -TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" - - -def parse_datetime(date): - if date is None: - return None - - try: - return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc) - except ValueError: - return None - - -def format_datetime(date): - if date is None: - return None - - return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT) +import datetime + +ZERO = datetime.timedelta(0) +HOUR = datetime.timedelta(hours=1) + + +def timestamp(): + return datetime.datetime.now().strftime("%H:%M:%S") + + +# This class is a concrete implementation of the abstract base class tzinfo +# docs: https://docs.python.org/2.3/lib/datetime-tzinfo.html +class UTC(datetime.tzinfo): + """UTC""" + + def utcoffset(self, dt): + return ZERO + + def tzname(self, dt): + return "UTC" + + def dst(self, dt): + return ZERO + + +utc = UTC() +TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" + + +def parse_datetime(date): + if date is None: + return None + + try: + return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc) + except ValueError: + return None + + +def format_datetime(date): + if date is None: + return None + + return date.astimezone(tz=utc).strftime(TABLEAU_DATE_FORMAT) diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py index a095d606e..b1c868cc7 100644 --- a/tableauserverclient/helpers/strings.py +++ b/tableauserverclient/helpers/strings.py @@ -1,67 +1,67 @@ -from defusedxml.ElementTree import fromstring, tostring -from functools import singledispatch -from typing import TypeVar, cast, overload - -# the redact method can handle either strings or bytes, but it can't mix them. -# Generic type so we can write the actual logic once, then use singledispatch to -# create the replacement text with the correct type -T = TypeVar("T", str, bytes) - - -def _redact_any_type(xml: T, sensitive_word: T, replacement: T, encoding=None) -> T: - try: - root = fromstring(xml) - matches = root.findall(".//*[@password]") - for item in matches: - item.attrib["password"] = "********" - matches = root.findall(".//password") - for item in matches: - item.text = "********" - # tostring returns bytes unless an encoding value is passed; cast since - # the callers pass encoding="unicode" for str and None for bytes - return cast(T, tostring(root, encoding=encoding)) - except Exception: - # something about the xml handling failed. Just cut off the text at the first occurrence of "password" - location = xml.find(sensitive_word) - return xml[:location] + replacement - - -@singledispatch -def redact_xml(content): - # this will only be called if it didn't get directed to the str or bytes overloads - raise TypeError("Redaction only works on xml saved as str or bytes") - - -@redact_xml.register -def _(xml: str) -> str: - out = _redact_any_type(xml, "password", "...[redacted]", encoding="unicode") - return out - - -@redact_xml.register # type: ignore[no-redef] -def _(xml: bytes) -> bytes: - return cast(bytes, _redact_any_type(xml, b"password", b"..[redacted]")) - - -@overload -def nullable_str_to_int(value: None) -> None: ... - - -@overload -def nullable_str_to_int(value: str) -> int: ... - - -def nullable_str_to_int(value): - return int(value) if value is not None else None - - -@overload -def nullable_str_to_bool(value: None) -> None: ... - - -@overload -def nullable_str_to_bool(value: str) -> bool: ... - - -def nullable_str_to_bool(value): - return str(value).lower() == "true" if value is not None else None +from defusedxml.ElementTree import fromstring, tostring +from functools import singledispatch +from typing import TypeVar, cast, overload + +# the redact method can handle either strings or bytes, but it can't mix them. +# Generic type so we can write the actual logic once, then use singledispatch to +# create the replacement text with the correct type +T = TypeVar("T", str, bytes) + + +def _redact_any_type(xml: T, sensitive_word: T, replacement: T, encoding=None) -> T: + try: + root = fromstring(xml) + matches = root.findall(".//*[@password]") + for item in matches: + item.attrib["password"] = "********" + matches = root.findall(".//password") + for item in matches: + item.text = "********" + # tostring returns bytes unless an encoding value is passed; cast since + # the callers pass encoding="unicode" for str and None for bytes + return cast(T, tostring(root, encoding=encoding)) + except Exception: + # something about the xml handling failed. Just cut off the text at the first occurrence of "password" + location = xml.find(sensitive_word) + return xml[:location] + replacement + + +@singledispatch +def redact_xml(content): + # this will only be called if it didn't get directed to the str or bytes overloads + raise TypeError("Redaction only works on xml saved as str or bytes") + + +@redact_xml.register +def _(xml: str) -> str: + out = _redact_any_type(xml, "password", "...[redacted]", encoding="unicode") + return out + + +@redact_xml.register # type: ignore[no-redef] +def _(xml: bytes) -> bytes: + return cast(bytes, _redact_any_type(xml, b"password", b"..[redacted]")) + + +@overload +def nullable_str_to_int(value: None) -> None: ... + + +@overload +def nullable_str_to_int(value: str) -> int: ... + + +def nullable_str_to_int(value): + return int(value) if value is not None else None + + +@overload +def nullable_str_to_bool(value: None) -> None: ... + + +@overload +def nullable_str_to_bool(value: str) -> bool: ... + + +def nullable_str_to_bool(value): + return str(value).lower() == "true" if value is not None else None diff --git a/tableauserverclient/models/column_item.py b/tableauserverclient/models/column_item.py index 739b7e8ef..dd4c1254f 100644 --- a/tableauserverclient/models/column_item.py +++ b/tableauserverclient/models/column_item.py @@ -1,71 +1,71 @@ -from defusedxml.ElementTree import fromstring - -from .property_decorators import property_not_empty - - -class ColumnItem: - def __init__(self, name, description=None): - self._id = None - self.description = description - self.name = name - - def __repr__(self): - return f"<{self.__class__.__name__} {self._id} {self.name} {self.description}>" - - @property - def id(self): - return self._id - - @property - def name(self): - return self._name - - @name.setter - @property_not_empty - def name(self, value): - self._name = value - - @property - def description(self): - return self._description - - @description.setter - def description(self, value): - self._description = value - - @property - def remote_type(self): - return self._remote_type - - def _set_values(self, id, name, description, remote_type): - if id is not None: - self._id = id - if name: - self._name = name - if description: - self.description = description - if remote_type: - self._remote_type = remote_type - - @classmethod - def from_response(cls, resp, ns): - all_column_items = list() - parsed_response = fromstring(resp) - all_column_xml = parsed_response.findall(".//t:column", namespaces=ns) - - for column_xml in all_column_xml: - id, name, description, remote_type = cls._parse_element(column_xml, ns) - column_item = cls(name) - column_item._set_values(id, name, description, remote_type) - all_column_items.append(column_item) - - return all_column_items - - @staticmethod - def _parse_element(column_xml, ns): - id = column_xml.get("id", None) - name = column_xml.get("name", None) - description = column_xml.get("description", None) - remote_type = column_xml.get("remoteType", None) - - return id, name, description, remote_type +from defusedxml.ElementTree import fromstring + +from .property_decorators import property_not_empty + + +class ColumnItem: + def __init__(self, name, description=None): + self._id = None + self.description = description + self.name = name + + def __repr__(self): + return f"<{self.__class__.__name__} {self._id} {self.name} {self.description}>" + + @property + def id(self): + return self._id + + @property + def name(self): + return self._name + + @name.setter + @property_not_empty + def name(self, value): + self._name = value + + @property + def description(self): + return self._description + + @description.setter + def description(self, value): + self._description = value + + @property + def remote_type(self): + return self._remote_type + + def _set_values(self, id, name, description, remote_type): + if id is not None: + self._id = id + if name: + self._name = name + if description: + self.description = description + if remote_type: + self._remote_type = remote_type + + @classmethod + def from_response(cls, resp, ns): + all_column_items = list() + parsed_response = fromstring(resp) + all_column_xml = parsed_response.findall(".//t:column", namespaces=ns) + + for column_xml in all_column_xml: + id, name, description, remote_type = cls._parse_element(column_xml, ns) + column_item = cls(name) + column_item._set_values(id, name, description, remote_type) + all_column_items.append(column_item) + + return all_column_items + + @staticmethod + def _parse_element(column_xml, ns): + id = column_xml.get("id", None) + name = column_xml.get("name", None) + description = column_xml.get("description", None) + remote_type = column_xml.get("remoteType", None) + + return id, name, description, remote_type diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index f53a1e0cf..9ae99e398 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,140 +1,140 @@ -from typing import TYPE_CHECKING - -from defusedxml.ElementTree import fromstring - -from .property_decorators import property_is_boolean -from .target import Target -from tableauserverclient.models import ScheduleItem - -if TYPE_CHECKING: - from .target import Target - - -class SubscriptionItem: - def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: - self._id = None - self.attach_image = True - self.attach_pdf = False - self.message = None - self.page_orientation = None - self.page_size_option = None - self.schedule_id = schedule_id - self.send_if_view_empty = True - self.subject = subject - self.suspended = False - self.target = target - self.user_id = user_id - self.schedule = None - - def __repr__(self) -> str: - if self.id is not None: - return " bool: - return self._attach_image - - @attach_image.setter - @property_is_boolean - def attach_image(self, value: bool): - self._attach_image = value - - @property - def attach_pdf(self) -> bool: - return self._attach_pdf - - @attach_pdf.setter - @property_is_boolean - def attach_pdf(self, value: bool) -> None: - self._attach_pdf = value - - @property - def send_if_view_empty(self) -> bool: - return self._send_if_view_empty - - @send_if_view_empty.setter - @property_is_boolean - def send_if_view_empty(self, value: bool) -> None: - self._send_if_view_empty = value - - @property - def suspended(self) -> bool: - return self._suspended - - @suspended.setter - @property_is_boolean - def suspended(self, value: bool) -> None: - self._suspended = value - - @classmethod - def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]: - parsed_response = fromstring(xml) - all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) - - all_subscriptions = [SubscriptionItem._parse_element(x, ns) for x in all_subscriptions_xml] - return all_subscriptions - - @classmethod - def _parse_element(cls, element, ns): - schedule_element = element.find(".//t:schedule", namespaces=ns) - content_element = element.find(".//t:content", namespaces=ns) - user_element = element.find(".//t:user", namespaces=ns) - - # Schedule element - schedule_id = None - schedule = None - if schedule_element is not None: - schedule_id = schedule_element.get("id", None) - - # If schedule id is not provided, then TOL with full schedule provided - if schedule_id is None: - schedule = ScheduleItem.from_element(element, ns) - - # Content element - target = None - send_if_view_empty = None - if content_element is not None: - target = Target(content_element.get("id", None), content_element.get("type")) - send_if_view_empty = string_to_bool(content_element.get("sendIfViewEmpty", "")) - - # User element - user_id = None - if user_element is not None: - user_id = user_element.get("id", None) - - # Main attributes - id_ = element.get("id", None) - subject = element.get("subject", None) - attach_image = string_to_bool(element.get("attachImage", "")) - attach_pdf = string_to_bool(element.get("attachPdf", "")) - message = element.get("message", None) - page_orientation = element.get("pageOrientation", None) - page_size_option = element.get("pageSizeOption", None) - suspended = string_to_bool(element.get("suspended", "")) - - # Create SubscriptionItem and set fields - sub = cls(subject, schedule_id, user_id, target) - sub._id = id_ - sub.attach_image = attach_image - sub.attach_pdf = attach_pdf - sub.message = message - sub.page_orientation = page_orientation - sub.page_size_option = page_size_option - sub.send_if_view_empty = send_if_view_empty - sub.suspended = suspended - sub.schedule = schedule - - return sub - - -# Used to convert string represented boolean to a boolean type -def string_to_bool(s: str) -> bool: - return s.lower() == "true" +from typing import TYPE_CHECKING + +from defusedxml.ElementTree import fromstring + +from .property_decorators import property_is_boolean +from .target import Target +from tableauserverclient.models import ScheduleItem + +if TYPE_CHECKING: + from .target import Target + + +class SubscriptionItem: + def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: + self._id = None + self.attach_image = True + self.attach_pdf = False + self.message = None + self.page_orientation = None + self.page_size_option = None + self.schedule_id = schedule_id + self.send_if_view_empty = True + self.subject = subject + self.suspended = False + self.target = target + self.user_id = user_id + self.schedule = None + + def __repr__(self) -> str: + if self.id is not None: + return " bool: + return self._attach_image + + @attach_image.setter + @property_is_boolean + def attach_image(self, value: bool): + self._attach_image = value + + @property + def attach_pdf(self) -> bool: + return self._attach_pdf + + @attach_pdf.setter + @property_is_boolean + def attach_pdf(self, value: bool) -> None: + self._attach_pdf = value + + @property + def send_if_view_empty(self) -> bool: + return self._send_if_view_empty + + @send_if_view_empty.setter + @property_is_boolean + def send_if_view_empty(self, value: bool) -> None: + self._send_if_view_empty = value + + @property + def suspended(self) -> bool: + return self._suspended + + @suspended.setter + @property_is_boolean + def suspended(self, value: bool) -> None: + self._suspended = value + + @classmethod + def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]: + parsed_response = fromstring(xml) + all_subscriptions_xml = parsed_response.findall(".//t:subscription", namespaces=ns) + + all_subscriptions = [SubscriptionItem._parse_element(x, ns) for x in all_subscriptions_xml] + return all_subscriptions + + @classmethod + def _parse_element(cls, element, ns): + schedule_element = element.find(".//t:schedule", namespaces=ns) + content_element = element.find(".//t:content", namespaces=ns) + user_element = element.find(".//t:user", namespaces=ns) + + # Schedule element + schedule_id = None + schedule = None + if schedule_element is not None: + schedule_id = schedule_element.get("id", None) + + # If schedule id is not provided, then TOL with full schedule provided + if schedule_id is None: + schedule = ScheduleItem.from_element(element, ns) + + # Content element + target = None + send_if_view_empty = None + if content_element is not None: + target = Target(content_element.get("id", None), content_element.get("type")) + send_if_view_empty = string_to_bool(content_element.get("sendIfViewEmpty", "")) + + # User element + user_id = None + if user_element is not None: + user_id = user_element.get("id", None) + + # Main attributes + id_ = element.get("id", None) + subject = element.get("subject", None) + attach_image = string_to_bool(element.get("attachImage", "")) + attach_pdf = string_to_bool(element.get("attachPdf", "")) + message = element.get("message", None) + page_orientation = element.get("pageOrientation", None) + page_size_option = element.get("pageSizeOption", None) + suspended = string_to_bool(element.get("suspended", "")) + + # Create SubscriptionItem and set fields + sub = cls(subject, schedule_id, user_id, target) + sub._id = id_ + sub.attach_image = attach_image + sub.attach_pdf = attach_pdf + sub.message = message + sub.page_orientation = page_orientation + sub.page_size_option = page_size_option + sub.send_if_view_empty = send_if_view_empty + sub.suspended = suspended + sub.schedule = schedule + + return sub + + +# Used to convert string represented boolean to a boolean type +def string_to_bool(s: str) -> bool: + return s.lower() == "true" From 1f76444726a4957b9d412390f59e67fcb4306187 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 19 Aug 2026 19:16:02 -0700 Subject: [PATCH 197/219] [tabcmd] fix: preserve case of customSubscriptionEmail and customSubscriptionFooter (#1849) (#1851) * fix: preserve case of customSubscriptionEmail and customSubscriptionFooter (#1849) RequestFactory.Site.update_req and create_req called `str(value).lower()` on customSubscriptionEmail and customSubscriptionFooter when serializing to XML, silently mangling caller intent: site.custom_subscription_email = "Sales@Company.com" site.custom_subscription_footer = "Sent by Tableau -- Confidential." server.sites.update(site) # server received: customSubscriptionEmail="sales@company.com" # customSubscriptionFooter="sent by tableau -- confidential." Impact: - Email: cosmetic only (SMTP treats mailboxes case-insensitively in practice), but recipients see the wrong-case "from" address. - Footer: functionally broken. The footer is displayed verbatim in outgoing subscription emails, so lowercasing removes company-name casing, sentence capitalization, brand terms, etc. Anyone setting a customer-facing footer via TSC got a mangled result with no workaround short of the web UI. Drop the .lower() on both string values in both code paths. The paired *Enabled boolean attributes still get .lower()d (they need to serialize as "true"/"false"), just the string values are now sent verbatim. Discovered while implementing tabcmd editsite parity (tableau/tabcmd#437, tabcmd PR #452). Fixes #1849. * Refactor subscription case-preservation tests to parse XML Copilot noted that substring-matching the serialized XML is brittle: attribute quoting (single vs double), whitespace, and entity escaping can vary without changing meaning. Parse the request with defusedxml (matching the existing test_encoding_attr_capture style at line 361) and assert on the parsed attribute value instead. The load-bearing check is still "the value is not lowercased", which is what site_elem.attrib["customSubscriptionEmail"] == "Sales@Company.com" verifies. Test now survives serialization-format changes and still fails if the .lower() regression comes back. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- tableauserverclient/server/request_factory.py | 8 +++--- test/test_site.py | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index fc4694c01..b94659ca6 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -733,13 +733,13 @@ def update_req(self, site_item: "SiteItem", parent_srv: "Server | None" = None): site_item.custom_subscription_email_enabled ).lower() if site_item.custom_subscription_email is not None: - site_element.attrib["customSubscriptionEmail"] = str(site_item.custom_subscription_email).lower() + site_element.attrib["customSubscriptionEmail"] = str(site_item.custom_subscription_email) if site_item.custom_subscription_footer_enabled is not None: site_element.attrib["customSubscriptionFooterEnabled"] = str( site_item.custom_subscription_footer_enabled ).lower() if site_item.custom_subscription_footer is not None: - site_element.attrib["customSubscriptionFooter"] = str(site_item.custom_subscription_footer).lower() + site_element.attrib["customSubscriptionFooter"] = str(site_item.custom_subscription_footer) if site_item.ask_data_mode is not None: site_element.attrib["askDataMode"] = str(site_item.ask_data_mode) if site_item.named_sharing_enabled is not None: @@ -837,13 +837,13 @@ def create_req(self, site_item: "SiteItem", parent_srv: "Server | None" = None): site_item.custom_subscription_email_enabled ).lower() if site_item.custom_subscription_email is not None: - site_element.attrib["customSubscriptionEmail"] = str(site_item.custom_subscription_email).lower() + site_element.attrib["customSubscriptionEmail"] = str(site_item.custom_subscription_email) if site_item.custom_subscription_footer_enabled is not None: site_element.attrib["customSubscriptionFooterEnabled"] = str( site_item.custom_subscription_footer_enabled ).lower() if site_item.custom_subscription_footer is not None: - site_element.attrib["customSubscriptionFooter"] = str(site_item.custom_subscription_footer).lower() + site_element.attrib["customSubscriptionFooter"] = str(site_item.custom_subscription_footer) if site_item.ask_data_mode is not None: site_element.attrib["askDataMode"] = str(site_item.ask_data_mode) if site_item.named_sharing_enabled is not None: diff --git a/test/test_site.py b/test/test_site.py index e976bc1d2..3e15330ff 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -205,6 +205,34 @@ def test_update_missing_id(server: TSC.Server) -> None: server.sites.update(single_site) +def test_update_subscription_email_and_footer_preserve_case() -> None: + # Regression for #1849: RequestFactory used to .lower() customSubscriptionEmail + # and customSubscriptionFooter, silently mangling caller intent. Footer + # especially, since it is displayed verbatim in outgoing subscription emails. + site = TSC.SiteItem(name="X", content_url="x") + site.custom_subscription_email = "Sales@Company.com" + site.custom_subscription_footer = "Sent by Tableau -- Confidential. See https://Example.com/Legal" + + site_elem = ET.fromstring(RequestFactory.Site.update_req(site)).find(".//site") + assert site_elem is not None + assert site_elem.attrib["customSubscriptionEmail"] == "Sales@Company.com" + assert site_elem.attrib["customSubscriptionFooter"] == ( + "Sent by Tableau -- Confidential. See https://Example.com/Legal" + ) + + +def test_create_subscription_email_and_footer_preserve_case() -> None: + # Same regression for the create-site path. + site = TSC.SiteItem(name="X", content_url="x") + site.custom_subscription_email = "Support@Company.com" + site.custom_subscription_footer = "COMPANY, Inc. -- All Rights Reserved." + + site_elem = ET.fromstring(RequestFactory.Site.create_req(site)).find(".//site") + assert site_elem is not None + assert site_elem.attrib["customSubscriptionEmail"] == "Support@Company.com" + assert site_elem.attrib["customSubscriptionFooter"] == "COMPANY, Inc. -- All Rights Reserved." + + 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 From 47809c300dad7e5debea7fe47a3ba0dd3f95f563 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 19 Aug 2026 19:19:30 -0700 Subject: [PATCH 198/219] [tabcmd] fix: CSVImport bugs (AUTH column, case-insensitive validation, username case) (#1811) --- CHANGELOG.md | 17 ++++++ tableauserverclient/models/user_item.py | 75 +++++++++++++++++++------ test/test_user.py | 21 +++++++ test/test_user_model.py | 59 +++++++++++++++++++ 4 files changed, 154 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc1430d38..574ab480e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by level using the REST API name filter, so a path with *n* components issues *n* requests. Returns the matching `ProjectItem` or `None` if no project is found. +* `UserItem.CSVImport.create_user_from_line` no longer + lowercases the entire CSV line before parsing. Previously the whole line, + including the username, display name, fullname, and email fields, was + lowercased destructively (e.g. `JSmith` became `jsmith`). Case is now + preserved for those fields; only the comparison-relevant fields (license, + admin_level, publisher, auth_setting) are normalized internally for + validation. Callers relying on the previous lowercased output -- e.g. dict + lookups keyed on `user.name`, or assertions against lowercased values -- + need to update. This unblocks CSV imports for LDAP and other case-sensitive + auth backends where mixed-case usernames must be preserved. +* `UserItem.CSVImport._validate_attribute_value` and the too-many-columns + branch of `_validate_import_line_or_throw` now raise `ValueError` instead + of `AttributeError` for invalid CSV input. `AttributeError` was the wrong + exception type for input validation and inconsistent with + `create_user_from_line`. `validate_file_for_import` catches `Exception` so + it is unaffected; direct callers who caught `AttributeError` specifically + need to widen their handler. * Added `JobItem.status_notes` for the structured `` block documented on the Query Job REST endpoint. Populated for UserImport and other multi-row jobs where diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index 0ba1e8eb2..da23642bf 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -308,6 +308,14 @@ def _set_values( if email: self.email = email if auth_setting: + # Write directly to _auth_setting rather than going through the + # @property_is_enum(Auth) setter. This method is called from both + # CSV import and server response parsing (from_xml, populate, etc.); + # if the server ever returns an auth type we don't yet know about + # (a new Auth value in a future Tableau release), the enum guard + # would raise ValueError during response parsing. CSV callers + # already validate the auth string against CSVImport._AUTH_CANONICAL + # before calling here, so this write is safe. self._auth_setting = auth_setting if domain_name: self._domain_name = domain_name @@ -432,36 +440,59 @@ class ColumnType(IntEnum): EMAIL = 6 AUTH = 7 - MAX = 7 + # Total number of columns supported by the import format. Held outside + # the ColumnType enum so it can't be mistaken for a real column index. + COLUMN_COUNT = 8 + + # Lowercase -> canonical form mapping for the AUTH column. Class-level + # so the dict isn't rebuilt on every call to create_user_from_line / + # _validate_import_line_or_throw. The set of accepted values is derived + # from this map (see _valid_attributes[AUTH]) so there's a single + # source of truth. + _AUTH_CANONICAL: dict[str, str] = { + "saml": "SAML", + "openid": "OpenID", + "serverdefault": "ServerDefault", + "tableauidwithmfa": "TableauIDWithMFA", + } # Read a csv line and create a user item populated by the given attributes @staticmethod def create_user_from_line(line: str): if line is None or line is False or line == "\n" or line == "": return None - line = line.strip().lower() - values: list[str] = list(map(str.strip, line.split(","))) - user = UserItem(values[UserItem.CSVImport.ColumnType.USERNAME]) + values: list[str] = list(map(str.strip, line.strip().split(","))) + if len(values) > UserItem.CSVImport.COLUMN_COUNT: + raise ValueError("Too many attributes for user import") + username = values[UserItem.CSVImport.ColumnType.USERNAME] + user = UserItem(username) if len(values) > 1: - if len(values) > UserItem.CSVImport.ColumnType.MAX: - raise ValueError("Too many attributes for user import") - while len(values) <= UserItem.CSVImport.ColumnType.MAX: + while len(values) < UserItem.CSVImport.COLUMN_COUNT: values.append("") site_role = UserItem.CSVImport._evaluate_site_role( values[UserItem.CSVImport.ColumnType.LICENSE], values[UserItem.CSVImport.ColumnType.ADMIN], values[UserItem.CSVImport.ColumnType.PUBLISHER], ) - + raw_auth = values[UserItem.CSVImport.ColumnType.AUTH] + if raw_auth: + auth = UserItem.CSVImport._AUTH_CANONICAL.get(raw_auth.lower()) + if auth is None: + raise ValueError( + f"Unknown auth setting: {raw_auth!r}. " + f"Valid values: {sorted(UserItem.CSVImport._AUTH_CANONICAL.values())}" + ) + else: + auth = None user._set_values( None, - values[UserItem.CSVImport.ColumnType.USERNAME], + username, site_role, None, None, values[UserItem.CSVImport.ColumnType.DISPLAY_NAME], values[UserItem.CSVImport.ColumnType.EMAIL], - values[UserItem.CSVImport.ColumnType.AUTH], + auth, None, None, None, @@ -493,6 +524,8 @@ def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> tuple[int, l # Iterate through each field and validate the given value against hardcoded constraints @staticmethod def _validate_import_line_or_throw(incoming, logger) -> None: + # AUTH column's valid set is derived from _AUTH_CANONICAL so there's + # one source of truth for the accepted values. _valid_attributes: list[list[str]] = [ [], [], @@ -501,20 +534,26 @@ def _validate_import_line_or_throw(incoming, logger) -> None: ["system", "site", "none", "no"], # admin ["yes", "true", "1", "no", "false", "0"], # publisher [], - [UserItem.Auth.SAML, UserItem.Auth.OpenID, UserItem.Auth.ServerDefault], # auth + list(UserItem.CSVImport._AUTH_CANONICAL.values()), # auth - normalized before comparison ] line = list(map(str.strip, incoming.split(","))) - if len(line) > UserItem.CSVImport.ColumnType.MAX: - raise AttributeError("Too many attributes in line") + if len(line) > UserItem.CSVImport.COLUMN_COUNT: + raise ValueError("Too many attributes for user import") username = line[UserItem.CSVImport.ColumnType.USERNAME.value] logger.debug(f"> details - {username}") UserItem.validate_username_or_throw(username) for i in range(1, len(line)): - logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {line[i]}") - UserItem.CSVImport._validate_attribute_value( - line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i) - ) + value = line[i] + valid = _valid_attributes[i] + # normalize case for fields with a restricted value set + if valid: + if i == UserItem.CSVImport.ColumnType.AUTH: + value = UserItem.CSVImport._AUTH_CANONICAL.get(value.lower(), value) + else: + value = value.lower() + logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {value}") + UserItem.CSVImport._validate_attribute_value(value, valid, UserItem.CSVImport.ColumnType(i)) # Given a restricted set of possible values, confirm the item is in that set @staticmethod @@ -524,7 +563,7 @@ def _validate_attribute_value(item: str, possible_values: list[str], column_type return if item in possible_values or possible_values == []: return - raise AttributeError(f"Invalid value {item} for {column_type}") + raise ValueError(f"Invalid value {item} for {column_type}") # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles # This logic is hardcoded to match the existing rules for import csv files diff --git a/test/test_user.py b/test/test_user.py index fec21d25f..c3bb8dc5c 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -598,3 +598,24 @@ def test_remove_users_csv(server: TSC.Server) -> None: assert ( name == f"{user.domain_name}\\{user.name}" ), f"Name in csv does not match expected name: {user.domain_name}\\{user.name}" + + +def test_from_xml_accepts_unknown_auth_setting() -> None: + # Regression: response parsing must not enforce the UserItem.Auth enum. If a + # future Tableau release adds a new auth type, TSC's `_set_values` used to + # route through the @property_is_enum(Auth) setter, which raised ValueError + # on any value outside {SAML, OpenID, TableauIDWithMFA, ServerDefault}, + # breaking response parsing entirely. The CSV import path validates upstream + # against CSVImport._AUTH_CANONICAL, so the enum guard on _set_values was + # redundant there and harmful on server-parse paths. + ns = {"t": "http://tableau.com/api"} + xml = ET.fromstring( + b"" + ) + item = TSC.UserItem.from_xml(xml, ns) + assert ( + item.auth_setting == "FutureAuthType42" + ), "from_xml must preserve unknown auth values verbatim, not raise or drop them" + assert item.name == "alice" + assert item.site_role == "Viewer" diff --git a/test/test_user_model.py b/test/test_user_model.py index 49e8dc25c..66673cb38 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -136,3 +136,62 @@ 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)}" + + +def test_validate_mixed_case_license() -> None: + # Regression: issue #1809 - 'Viewer' (capital V) was rejected by case-sensitive check + TSC.UserItem.CSVImport._validate_import_line_or_throw("username, pword, fname, Viewer, None, no, email", logger) + TSC.UserItem.CSVImport._validate_import_line_or_throw("username, pword, fname, Creator, Site, yes, email", logger) + TSC.UserItem.CSVImport._validate_import_line_or_throw("username, pword, fname, EXPLORER, NONE, YES, email", logger) + + +def test_validate_tableauid_with_mfa_auth() -> None: + # TableauIDWithMFA is a valid auth value and must not be rejected + TSC.UserItem.CSVImport._validate_import_line_or_throw( + "username, pword, fname, creator, none, yes, email, TableauIDWithMFA", logger + ) + + +def test_create_user_preserves_username_case() -> None: + # Username must not be lowercased - case matters for LDAP and email-format usernames + user = TSC.UserItem.CSVImport.create_user_from_line("JSmith, pword, John Smith, creator, none, yes, j@example.com") + assert user is not None + assert user.name == "JSmith", f"Username was lowercased: {user.name}" + + +def test_create_user_with_auth_column() -> None: + # AUTH column (position 7) must be parsed - was broken by MAX=7 off-by-one + user = TSC.UserItem.CSVImport.create_user_from_line("username, pword, fname, creator, none, yes, email, SAML") + assert user is not None + assert user.auth_setting == "SAML", f"Expected SAML, got {user.auth_setting}" + + +def test_too_many_columns_raises() -> None: + with pytest.raises(ValueError): + TSC.UserItem.CSVImport.create_user_from_line("u, p, n, creator, none, yes, email, SAML, extra") + + +def test_create_user_with_unknown_auth_raises() -> None: + # Unknown AUTH values must raise, not silently produce a UserItem with auth_setting=None. + # A caller can catch this if lenient behavior is wanted. + with pytest.raises(ValueError, match="Unknown auth setting"): + TSC.UserItem.CSVImport.create_user_from_line("username, pword, fname, creator, none, yes, email, NotAnAuthType") + + +def test_create_user_with_lowercase_auth_accepted() -> None: + # AUTH values are canonicalized case-insensitively - 'saml' should produce 'SAML'. + user = TSC.UserItem.CSVImport.create_user_from_line("username, pword, fname, creator, none, yes, email, saml") + assert user is not None + assert user.auth_setting == "SAML" + + +def test_validate_import_line_rejects_unknown_auth() -> None: + # _validate_import_line_or_throw shares the AUTH canonicalization with + # create_user_from_line -- confirm both paths reject unknown auth values so + # that validate_file_for_import (which uses this path) doesn't silently + # accept rows create_user_from_line would refuse. + with pytest.raises(ValueError, match="Invalid value"): + TSC.UserItem.CSVImport._validate_import_line_or_throw( + "username, pword, fname, creator, none, yes, email, NotAnAuthType", + logger, + ) From e29d81e4c1af312875e7c3fbada357e125c9b576 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 19 Aug 2026 20:10:39 -0700 Subject: [PATCH 199/219] [tabcmd] fix: preserve POST body across 3xx redirects (#1127, #1828) (#1848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: preserve POST body across 3xx redirects (#1127, #1828) `requests` follows 301/302/303 by converting POST to GET and dropping the request body. Any TSC write hitting a server behind a redirect (users.add, workbooks.publish, addusers, etc.) returned 405 Method Not Allowed because the server saw a GET where it expected a POST. Disable requests' auto-redirect and walk the chain manually in Endpoint._make_request, keeping the original method and body across every hop. Hop count bounded by session.max_redirects (default 30, same as requests). Also close two nearby gaps: - Refuse HTTPS -> HTTP scheme downgrades. Silently following them would send auth material over plaintext; no legitimate server behaviour requires this. Raises RedirectError with the original and target URLs. - Raise RedirectError (with URL, method, status code) when a 3xx response has no Location header, replacing the bare KeyError('location') that requests emits deep in its internals. Sign-in retains its own single-hop 301 handler in auth_endpoint.py for backwards compatibility; the new path is additive. Test coverage: 8 new tests in test_redirect_handling.py covering POST body preservation, multi-hop chains, relative Location headers, scheme downgrade refusal, missing Location, and hop-cap enforcement. Existing 866-test suite unchanged. Fixes #1127. Fixes #1828. * Address #1848 review: fix max_redirects=0, unify signin with base redirect handling, restructure tests Fixes from Claude review pass: 1. `_follow_redirect_if_any`: move the "not a redirect?" early-return outside the loop, so a 200 response returns immediately even when session.max_redirects=0 (previously fell straight to "Exceeded 0 redirect hops" error). Also switch to getattr(method, "__name__", "REQUEST") to survive functools.partial or other callable wrappers. 2. `auth_endpoint.sign_in`: replace the inline session.post + 301 handler with `_make_request`, so signin now inherits multi-hop chain support, the HTTPS -> HTTP scheme guard, the missing-Location diagnostic, and the hop limit. This resolves the divergent behavior between signin and every other endpoint (signin previously refused to follow 302 and had no security guards). 3. `test_redirect_handling.py`: rewrite all tests to drive real endpoint calls (`server.auth.sign_in`, `server.workbooks.get`) through `requests_mock`, exercising `_make_request` end-to-end rather than calling `_follow_redirect_if_any` in isolation. Add parametrized coverage for all 5 followed redirect codes (301/302/303/307/308) and the 4 non-followed ones (300/304/305/306). Add tests for header preservation (X-Tableau-Auth reaches the redirect target), HTTP->HTTPS upgrade allowed, cross-host redirect followed, second-hop HTTPS->HTTP downgrade caught, and max_redirects=1 error path. Document why max_redirects=0 isn't tested (`requests` refuses to complete any 3xx response when max_redirects=0, regardless of `allow_redirects`, so the response never reaches our code). Full test suite: 888 passed, 1 skipped. * Promote server address to https on http->https redirect When the server redirects http://host to https://host on the same host, update `server._server_address` so subsequent requests skip the redirect round-trip. Recovers an older idea from the abandoned `jac/handle-https-better` branch, now that the manual-redirect handler from #1848 provides the right hook point. Only rewrites the stored address when: - current scheme is http, next scheme is https (upgrade, not downgrade which is already refused above) - current and next netloc match (same host, just scheme change) -- avoids the failure mode where a redirect to a completely unrelated https server silently repoints every future call at it. Two tests: one verifies the address is promoted on a same-host http->https redirect, the other verifies it is NOT promoted on a cross-host redirect. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: justify auth-material forwarding across cross-host redirects Add a code comment in `_handle_redirects` explaining why the X-Tableau- Auth header and session cookies are intentionally preserved on cross- host redirects, rather than stripped as a generic library would. The concern was raised in a security-focused fresh-eyes review: same- scheme cross-host redirects normally leak bearer tokens to whoever controls the redirect target, and RFC 7235 recommends stripping auth on cross-origin hops for that reason. But TSC is a client for a specific server the caller has already trusted, and Tableau Server is routinely deployed behind reverse proxies, load balancers, and SSO front-ends that redirect between hosts within the same infrastructure (tableau.corp.example -> east.tableau.corp.example, SSO IdP -> auth callback endpoint on a different subdomain, etc.). Stripping auth material there would break sign-in against every such deployment. The HTTPS -> HTTP downgrade guard (line 208) is the load-bearing security boundary: once the caller connects over HTTPS, the token cannot leave TLS regardless of which host receives the redirect. Comment only. No code change. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: justify uniform method preservation on 303 responses Add a code comment noting the deliberate deviation from RFC 7231 §6.4.4, which says 303 SHOULD change the method to GET on retry. TSC preserves the method and body on 303 the same as on the other redirect codes. Rationale: Tableau Server doesn't emit 303 for POST endpoints in normal operation, and PR #1848's goal is to preserve method+body across the common proxy/HA cases. If a deployment ever starts emitting 303 for writes, revisit. Marking it as a conscious deviation so a future reader doesn't submit a "fix" that reintroduces the bug we just fixed. Comment only. No code change. Co-Authored-By: Claude Opus 4.7 (1M context) * respect explicit allow_redirects override from http_options Change `parameters["allow_redirects"] = False` to `parameters.setdefault("allow_redirects", False)`. Default behavior is unchanged: with no override, TSC walks the redirect chain itself and preserves method+body across every hop. If a caller has a specific reason to override -- a security policy that requires failing loudly on any redirect rather than silently following one, or a test harness that wants requests' default behavior -- they can pass allow_redirects=True or =False on the Server's http_options and have it respected. The manual redirect walker in _follow_redirect_if_any short-circuits on non-3xx responses, so requests handling the redirect first and returning a 200 is safe. The 24 existing redirect tests all pass unchanged; none of them override allow_redirects, and both the enforced-redirect and refused- redirect paths still exercise the correct code. Co-Authored-By: Claude Opus 4.7 (1M context) * populate response.history when walking the redirect chain manually `requests`' native follower populates `Response.history` with the intermediate 3xx responses in receipt order; the final non-3xx response is what's returned, not in history. PR #1848 short-circuited the native follower by setting `allow_redirects=False` and walking the chain in `_follow_redirect_if_any`, which meant `.history` came back as an empty list even after a multi-hop chain. Fine for internal callers (nothing in TSC reads .history), but a silent behavior change for external consumers who forensically inspect responses. Collect each intermediate 3xx response in a local list and assign it to `response.history` on the final non-3xx response before returning. Matches the shape callers get from requests' native follower. Two tests: - test_response_history_populated_across_multi_hop_chain confirms the intermediate 301 and 302 land in .history in order after a 3-hop chain terminating in 200. - test_response_history_empty_when_no_redirect confirms the no-redirect short-circuit still returns .history=[] as requests would have. Co-Authored-By: Claude Opus 4.7 (1M context) * Address Copilot review findings on #1848 - endpoint.py: replace `old_address[7:].startswith(current_parsed.netloc)` with exact `old_parsed.netloc == current_parsed.netloc` comparison for the http->https address promotion. The startswith form was correct in the reviewed attacker scenarios but wrong for the common corporate case: an unqualified hostname like `TSC.Server("http://tableau")` where DNS search paths / split-horizon resolve `tableau` to different actual hosts. A redirect from `http://tableau/` to `https://tableau.other/` would previously promote `_server_address` to `https://tableau` even though the redirected netloc was `tableau.other`. Exact-netloc match kills that. - endpoint.py: change RedirectError message from "last Location was {current_url}" to "last URL attempted was {current_url}". current_url is the resolved URL of the last attempted hop, not the raw Location header value; the old phrasing was misleading during redirect-loop diagnostics. - test_redirect_handling.py: tighten test_non_followed_3xx_codes_pass_through to raise `ServerResponseError` specifically instead of `(ServerResponseError, Exception)`. The mocked XML error body deterministically produces ServerResponseError via _check_status; the broader assertion could mask an unrelated failure. - The fourth Copilot finding (namespace detection lost on the new signin path) is tracked separately as #1866; addressing there so it does not block this PR. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 8 + .../server/endpoint/auth_endpoint.py | 26 +- .../server/endpoint/endpoint.py | 123 ++++++ .../server/endpoint/exceptions.py | 7 + test/test_redirect_handling.py | 355 ++++++++++++++++++ 5 files changed, 505 insertions(+), 14 deletions(-) create mode 100644 test/test_redirect_handling.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 574ab480e..4b7e1ee19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by level using the REST API name filter, so a path with *n* components issues *n* requests. Returns the matching `ProjectItem` or `None` if no project is found. +* Preserve HTTP method and body across 3xx redirects. Previously `requests` + followed 301/302/303 by converting POST to GET and dropping the body, so + endpoints like `users.add`, `workbooks.publish`, and any write hitting a + server behind a redirect would 405. TSC now disables `requests`'s + auto-redirect and walks the chain manually, up to `session.max_redirects` + hops (default 30). Refuses HTTPS -> HTTP scheme downgrades and raises + `RedirectError` with a clear message on missing `Location` headers or hop + overflow. Fixes #1127 and #1828. * `UserItem.CSVImport.create_user_from_line` no longer lowercases the entire CSV line before parsing. Previously the whole line, including the username, display name, fullname, and email fields, was diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index fe0c9b3da..110d38a18 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -4,7 +4,7 @@ from defusedxml.ElementTree import fromstring -from tableauserverclient.server.endpoint.endpoint import Endpoint, api +from tableauserverclient.server.endpoint.endpoint import Endpoint, XML_CONTENT_TYPE, api from tableauserverclient.server.endpoint.exceptions import ServerResponseError from tableauserverclient.server.request_factory import RequestFactory @@ -68,20 +68,18 @@ def sign_in(self, auth_req: "Credentials") -> contextmgr: """ url = f"{self.baseurl}/signin" signin_req = RequestFactory.Auth.signin_req(auth_req) - server_response = self.parent_srv.session.post( - url, data=signin_req, **self.parent_srv.http_options, allow_redirects=False + # Route through _make_request so signin gets the same redirect handling + # (multi-hop, HTTPS->HTTP scheme guard, missing-Location diagnostic, + # hop limit) that every other endpoint uses. Explicit auth_token=None + # because we don't have one yet -- and self.parent_srv.auth_token + # raises NotSignedInError pre-signin, so post_request can't help here. + server_response = self._make_request( + self.parent_srv.session.post, + url, + content=signin_req, + auth_token=None, + content_type=XML_CONTENT_TYPE, ) - # manually handle a redirect so that we send the correct POST request instead of GET - # this will make e.g http://online.tableau.com work to redirect to http://east.online.tableau.com - if server_response.status_code == 301: - server_response = self.parent_srv.session.post( - server_response.headers["Location"], - data=signin_req, - **self.parent_srv.http_options, - allow_redirects=False, - ) - self.parent_srv._namespace.detect(server_response.content) - self._check_status(server_response, url) parsed_response = fromstring(server_response.content) site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None) site_url = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("contentUrl", None) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 31a0806dc..7fa34802d 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -3,6 +3,7 @@ import os from contextlib import closing from typing_extensions import Concatenate, ParamSpec +from urllib.parse import urljoin, urlparse from tableauserverclient import datetime_helpers as datetime import abc @@ -30,6 +31,7 @@ InternalServerError, NonXMLResponseError, NotSignedInError, + RedirectError, ) from tableauserverclient.server.exceptions import EndpointUnavailableError @@ -45,6 +47,21 @@ Success_codes = [200, 201, 202, 204] +# 301/302/303/307/308 all indicate the caller should re-request at a new URL. +# `requests`' default handler converts POST -> GET on 301/302/303, which drops +# the POST body and breaks sign-in / addusers / publish / any write endpoint +# whose target sits behind a redirect. We disable that and walk the chain +# manually, keeping the original method and body across every hop. +# +# RFC 7231 §6.4.4 says 303 SHOULD change the method to GET on retry. We do NOT +# follow that recommendation, deliberately: Tableau Server does not emit 303 +# for POST endpoints in normal operation (writes redirect via 301/302 in +# proxy/HA setups), and preserving the method + body uniformly is the +# behavior that fixes the reported bug (#1127). If a Tableau deployment ever +# starts emitting 303 for writes, revisit; treating it identically today is +# a conscious deviation, not an oversight. +Redirect_codes = [301, 302, 303, 307, 308] + XML_CONTENT_TYPE = "text/xml" JSON_CONTENT_TYPE = "application/json" @@ -120,6 +137,16 @@ def _make_request( parameters = Endpoint.set_parameters( self.parent_srv.http_options, auth_token, content, content_type, parameters ) + # Manual redirect handling: see Redirect_codes comment. `requests` + # follows 301/302/303 by converting POST to GET (RFC-conforming but + # loses the body). We default it off here and re-issue the same + # method ourselves in _follow_redirect_if_any. Use setdefault so a + # caller who has a specific reason to override (e.g. a security + # policy that says "fail loudly on any redirect, don't silently + # follow it") can pass allow_redirects=True or =False on their + # http_options and have it respected -- the manual redirect walk + # is a default, not a mandate. + parameters.setdefault("allow_redirects", False) logger.debug(f"request method {method.__name__}, url: {url}") if content: @@ -144,6 +171,7 @@ def _make_request( raise RuntimeError if isinstance(server_response, Exception): raise server_response + server_response, url = self._follow_redirect_if_any(method, url, parameters, server_response) self._check_status(server_response, url) loggable_response = self.log_response_safely(server_response) @@ -157,6 +185,101 @@ def _make_request( return server_response + def _follow_redirect_if_any( + self, + method: Callable[..., "Response"], + url: str, + parameters: dict[str, Any], + server_response: "Response", + ) -> tuple["Response", str]: + # Walk a 301/302/303/307/308 chain up to session.max_redirects hops, + # preserving method and body. Rejects HTTPS -> HTTP scheme downgrades + # (silent security regression). Raises RedirectError on a missing + # Location header instead of the KeyError requests emits deep in its + # internals, and on exceeding the session hop limit. + try: + max_hops = int(self.parent_srv.session.max_redirects) + except (AttributeError, TypeError): + max_hops = 30 # requests' library default + current_url = url + response = server_response + # Not a redirect? Return immediately regardless of max_hops (including 0). + if response.status_code not in Redirect_codes: + return response, current_url + # Preserve requests' `response.history` semantics: the intermediate 3xx + # responses in receipt order, with the final non-3xx response as the + # returned value. Callers doing forensic debugging on `.history` see + # the same shape they would from requests' native follower. + history: list["Response"] = [] + method_name = getattr(method, "__name__", "REQUEST").upper() + for _ in range(max_hops): + location = response.headers.get("Location") + if not location: + raise RedirectError( + f"{method_name} {current_url} returned HTTP {response.status_code} " + f"without a Location header; can't follow the redirect." + ) + # Support relative Locations per RFC 7231. + next_url = urljoin(current_url, location) + current_scheme = urlparse(current_url).scheme + next_scheme = urlparse(next_url).scheme + if current_scheme == "https" and next_scheme == "http": + raise RedirectError( + f"Refusing to follow redirect from {current_url} to {next_url}: " + f"HTTPS -> HTTP scheme downgrade would send request data over plaintext." + ) + # http -> https upgrade on the same host: promote the stored server + # address so subsequent requests skip this redirect round-trip. + # Only rewrite when the stored address's netloc exactly matches + # the redirected netloc to avoid pointing the client at an + # unrelated server (prefix matching could match e.g. "test" + # against a stored address of "test.other.example"). + if current_scheme == "http" and next_scheme == "https": + current_parsed = urlparse(current_url) + next_parsed = urlparse(next_url) + if current_parsed.netloc == next_parsed.netloc: + old_address = self.parent_srv._server_address + old_parsed = urlparse(old_address) + if old_parsed.scheme == "http" and old_parsed.netloc == current_parsed.netloc: + new_address = "https://" + old_address[len("http://") :] + self.parent_srv._server_address = new_address + logger.info(f"Server redirected to HTTPS; updated server address to {new_address}") + # Auth-material policy: the request `parameters` (including the + # X-Tableau-Auth header and any session cookies) are forwarded + # to the redirect target unchanged. This is intentional and + # required. TSC is a client library for a specific server the + # caller has already agreed to trust, and customers routinely + # deploy Tableau Server behind reverse proxies, load balancers, + # and SSO front-ends that redirect between hosts within their + # own infrastructure (e.g. tableau.corp.example -> east.tableau. + # corp.example, or an SSO IdP -> the auth-callback endpoint on + # a different subdomain). Stripping X-Tableau-Auth on cross- + # host redirects would break sign-in against every such + # deployment. The HTTPS -> HTTP downgrade guard above (line 208) + # is the boundary that keeps this from becoming a security + # regression: once the caller connects over HTTPS, the token + # never leaves TLS. + logger.debug(f"Following {response.status_code} redirect: {current_url} -> {next_url}") + history.append(response) + current_url = next_url + next_response = self._blocking_request(method, current_url, parameters) + if next_response is None: + raise RuntimeError(f"No response after redirect to {current_url}") + if isinstance(next_response, Exception): + # _blocking_request already re-raises via except -> raise, so this + # branch is defensive; keep it to satisfy the Response|Exception|None + # return type. + raise next_response + response = next_response + if response.status_code not in Redirect_codes: + response.history = history + return response, current_url + # Still a redirect after max_hops hops -> loop / misconfiguration. + raise RedirectError( + f"Exceeded {max_hops} redirect hops starting from {url}; last URL attempted was {current_url}. " + f"Increase session.max_redirects if this is legitimate." + ) + def _check_status(self, server_response: "Response", url: str | None = None): logger.debug(f"Response status: {server_response}") if not hasattr(server_response, "status_code"): diff --git a/tableauserverclient/server/endpoint/exceptions.py b/tableauserverclient/server/endpoint/exceptions.py index 49e065ed3..2a94a2969 100644 --- a/tableauserverclient/server/endpoint/exceptions.py +++ b/tableauserverclient/server/endpoint/exceptions.py @@ -130,3 +130,10 @@ class FlowRunCancelledException(FlowRunFailedException): class UnsupportedAttributeError(TableauError): pass + + +class RedirectError(TableauError): + # Raised when a manual redirect can't be followed safely or at all. + # Cases: missing Location header, HTTPS -> HTTP downgrade, redirect loop + # exceeding session.max_redirects. See Endpoint._follow_redirect_if_any. + pass diff --git a/test/test_redirect_handling.py b/test/test_redirect_handling.py new file mode 100644 index 000000000..37bca5c18 --- /dev/null +++ b/test/test_redirect_handling.py @@ -0,0 +1,355 @@ +"""Tests for manual redirect handling in Endpoint._make_request. + +`requests` follows 301/302/303 by converting POST to GET (dropping the body). +We disable auto-redirect and re-issue the same method ourselves inside +`Endpoint._make_request`. These tests drive real endpoint calls (sign_in, +workbooks.get, etc.) through a `requests_mock` transport, so they exercise +the same code path production traffic takes -- not the helper in isolation. +""" + +from pathlib import Path + +import pytest +import requests_mock + +import tableauserverclient as TSC +from tableauserverclient.server.endpoint.exceptions import RedirectError + +TEST_ASSET_DIR = Path(__file__).parent / "assets" +SIGN_IN_XML = TEST_ASSET_DIR / "auth_sign_in.xml" +GET_XML = TEST_ASSET_DIR / "workbook_get.xml" + + +@pytest.fixture +def server() -> TSC.Server: + s = TSC.Server("http://test", False) + return s + + +@pytest.fixture +def signed_in_server() -> TSC.Server: + s = TSC.Server("http://test", False) + s.version = "3.10" + s._set_auth("site-id", "user-id", "auth-token", "") + return s + + +def _sign_in_xml() -> str: + return SIGN_IN_XML.read_text() + + +def _workbooks_get_xml() -> str: + return GET_XML.read_text() + + +# --- Body / method preservation --------------------------------------------- + + +def test_post_body_preserved_across_redirect(server: TSC.Server) -> None: + # Regression for tableau/tabcmd#309: POST -> 302 previously turned into GET + # and dropped the body. Sign-in is the load-bearing POST path; drive it + # end-to-end and verify (a) the body reaches the final URL intact, and + # (b) sign_in still parses the response and sets auth state. + xml = _sign_in_xml() + seen_bodies: list[bytes | None] = [] + + def record_final(request, context): + seen_bodies.append(request.body) + context.status_code = 200 + return xml + + with requests_mock.mock() as m: + m.post( + server.auth.baseurl + "/signin", + status_code=302, + headers={"Location": "http://test/api/3.6/auth/signin"}, + ) + m.post("http://test/api/3.6/auth/signin", text=record_final) + + tableau_auth = TSC.TableauAuth("u", "p", site_id="Samples") + server.auth.sign_in(tableau_auth) + + assert server.auth_token is not None, "sign_in did not complete" + assert len(seen_bodies) == 1 + assert seen_bodies[0] is not None + assert b" None: + xml = _sign_in_xml() + with requests_mock.mock() as m: + m.post(server.auth.baseurl + "/signin", status_code=301, headers={"Location": "http://test/b"}) + m.post("http://test/b", status_code=302, headers={"Location": "http://test/c"}) + m.post("http://test/c", status_code=303, headers={"Location": "http://test/d"}) + m.post("http://test/d", text=xml) + + server.auth.sign_in(TSC.TableauAuth("u", "p")) + + assert server.auth_token is not None + + +def test_headers_survive_redirect(signed_in_server: TSC.Server) -> None: + # Regression: the whole point of the PR is method+body+*headers* + # preservation. Verify X-Tableau-Auth reaches the redirect target. + seen_headers: list[dict] = [] + + def capture(request, context): + seen_headers.append(dict(request.headers)) + context.status_code = 200 + return _workbooks_get_xml() + + baseurl = signed_in_server.workbooks.baseurl + with requests_mock.mock() as m: + m.get(baseurl, status_code=302, headers={"Location": baseurl + "?redirected=1"}) + m.get(baseurl + "?redirected=1", text=capture) + signed_in_server.workbooks.get() + + # Last hop was the terminal 200 -- inspect its headers. + assert seen_headers, "final GET never fired" + final = seen_headers[-1] + assert final.get("x-tableau-auth") == "auth-token" or final.get("X-Tableau-Auth") == "auth-token", final + + +def test_get_redirect_still_works(signed_in_server: TSC.Server) -> None: + baseurl = signed_in_server.workbooks.baseurl + with requests_mock.mock() as m: + m.get(baseurl, status_code=301, headers={"Location": baseurl + "?v=2"}) + m.get(baseurl + "?v=2", text=_workbooks_get_xml()) + result = signed_in_server.workbooks.get() + assert result[0] is not None + + +# --- Redirect codes --------------------------------------------------------- + + +def test_response_history_populated_across_multi_hop_chain(server: TSC.Server) -> None: + # Regression: `requests` normally populates `response.history` with the + # intermediate 3xx responses when it follows a redirect chain. TSC's + # manual walker took over that job, so we have to populate .history + # ourselves for callers doing forensic debugging. + from unittest.mock import patch + + xml = _sign_in_xml() + captured: list = [] + original_check = TSC.server.endpoint.Endpoint._check_status # type: ignore[attr-defined] + + def capture(self, response, url=None): + captured.append(response) + return original_check(self, response, url) + + with requests_mock.mock() as m: + m.post(server.auth.baseurl + "/signin", status_code=301, headers={"Location": "http://test/b"}) + m.post("http://test/b", status_code=302, headers={"Location": "http://test/c"}) + m.post("http://test/c", text=xml) + with patch.object(TSC.server.endpoint.Endpoint, "_check_status", capture): # type: ignore[attr-defined] + server.auth.sign_in(TSC.TableauAuth("u", "p")) + + assert len(captured) == 1, f"Expected one final response, got {len(captured)}" + final = captured[0] + assert final.status_code == 200 + assert len(final.history) == 2, f"Expected 2 intermediate hops in history, got {len(final.history)}" + assert final.history[0].status_code == 301 + assert final.history[1].status_code == 302 + + +def test_response_history_empty_when_no_redirect(signed_in_server: TSC.Server) -> None: + # When there's no redirect, .history stays as whatever requests set it + # (empty list). We don't overwrite it in the no-redirect short-circuit. + from unittest.mock import patch + + captured: list = [] + original_check = TSC.server.endpoint.Endpoint._check_status # type: ignore[attr-defined] + + def capture(self, response, url=None): + captured.append(response) + return original_check(self, response, url) + + baseurl = signed_in_server.workbooks.baseurl + with requests_mock.mock() as m: + m.get(baseurl, text=_workbooks_get_xml()) + with patch.object(TSC.server.endpoint.Endpoint, "_check_status", capture): # type: ignore[attr-defined] + signed_in_server.workbooks.get() + + assert len(captured) == 1 + assert captured[0].status_code == 200 + assert captured[0].history == [] + + +@pytest.mark.parametrize("code", [301, 302, 303, 307, 308]) +def test_all_supported_redirect_codes_preserve_post_body(server: TSC.Server, code: int) -> None: + xml = _sign_in_xml() + seen_bodies: list[bytes | None] = [] + + def capture(request, context): + seen_bodies.append(request.body) + context.status_code = 200 + return xml + + with requests_mock.mock() as m: + m.post(server.auth.baseurl + "/signin", status_code=code, headers={"Location": "http://test/new"}) + m.post("http://test/new", text=capture) + server.auth.sign_in(TSC.TableauAuth("u", "p")) + + assert len(seen_bodies) == 1 + assert seen_bodies[0] is not None + assert b" None: + # Only 301/302/303/307/308 are in Redirect_codes. Others should reach + # _check_status unchanged and surface as ServerResponseError or similar. + baseurl = signed_in_server.workbooks.baseurl + with requests_mock.mock() as m: + m.get( + baseurl, + status_code=code, + text="xy", + ) + with pytest.raises(TSC.ServerResponseError): + signed_in_server.workbooks.get() + + +# --- Scheme handling -------------------------------------------------------- + + +def test_https_to_http_downgrade_rejected() -> None: + s = TSC.Server("https://secure.test", False) + with requests_mock.mock() as m: + m.post( + s.auth.baseurl + "/signin", + status_code=301, + headers={"Location": "http://insecure.test/api/3.6/auth/signin"}, + ) + with pytest.raises(RedirectError, match="HTTPS -> HTTP"): + s.auth.sign_in(TSC.TableauAuth("u", "p")) + + +def test_https_to_http_downgrade_rejected_on_later_hop() -> None: + # First hop is https->https (safe), second hop tries to downgrade. + # Regression coverage that the guard runs each iteration, not just once. + s = TSC.Server("https://a.test", False) + with requests_mock.mock() as m: + m.post(s.auth.baseurl + "/signin", status_code=301, headers={"Location": "https://b.test/signin"}) + m.post("https://b.test/signin", status_code=301, headers={"Location": "http://c.test/signin"}) + with pytest.raises(RedirectError, match="HTTPS -> HTTP"): + s.auth.sign_in(TSC.TableauAuth("u", "p")) + + +def test_http_to_https_upgrade_allowed(server: TSC.Server) -> None: + # Not a security concern -- the whole point of #309 is that + # http://.../signin -> https://.../signin should work. + xml = _sign_in_xml() + with requests_mock.mock() as m: + m.post( + server.auth.baseurl + "/signin", status_code=301, headers={"Location": "https://test/api/3.6/auth/signin"} + ) + m.post("https://test/api/3.6/auth/signin", text=xml) + server.auth.sign_in(TSC.TableauAuth("u", "p")) + assert server.auth_token is not None + + +def test_http_to_https_upgrade_promotes_stored_server_address(server: TSC.Server) -> None: + # When the server redirects http://host -> https://host on the same host, + # promote server._server_address so subsequent requests skip the redirect. + assert server._server_address == "http://test" + xml = _sign_in_xml() + with requests_mock.mock() as m: + m.post( + server.auth.baseurl + "/signin", status_code=301, headers={"Location": "https://test/api/3.6/auth/signin"} + ) + m.post("https://test/api/3.6/auth/signin", text=xml) + server.auth.sign_in(TSC.TableauAuth("u", "p")) + assert server._server_address == "https://test" + + +def test_http_to_https_upgrade_does_not_promote_on_different_host(server: TSC.Server) -> None: + # If the redirect target is on a different host, do NOT rewrite the stored + # server address -- the redirect might be to a completely unrelated server + # and rewriting would silently point every future call at it. + assert server._server_address == "http://test" + xml = _sign_in_xml() + with requests_mock.mock() as m: + m.post( + server.auth.baseurl + "/signin", + status_code=301, + headers={"Location": "https://other-host/api/3.6/auth/signin"}, + ) + m.post("https://other-host/api/3.6/auth/signin", text=xml) + server.auth.sign_in(TSC.TableauAuth("u", "p")) + assert server._server_address == "http://test" + + +def test_cross_host_redirect_followed(server: TSC.Server) -> None: + # e.g. http://online.tableau.com -> http://east.online.tableau.com. + # This is the scenario in the original inline signin comment. + xml = _sign_in_xml() + with requests_mock.mock() as m: + m.post( + server.auth.baseurl + "/signin", + status_code=301, + headers={"Location": "http://east.test/api/3.6/auth/signin"}, + ) + m.post("http://east.test/api/3.6/auth/signin", text=xml) + server.auth.sign_in(TSC.TableauAuth("u", "p")) + assert server.auth_token is not None + + +# --- Location edge cases ---------------------------------------------------- + + +def test_relative_location_header(server: TSC.Server) -> None: + # RFC 7231 allows relative Location values; urljoin against request URL. + xml = _sign_in_xml() + with requests_mock.mock() as m: + m.post(server.auth.baseurl + "/signin", status_code=302, headers={"Location": "/api/3.6/auth/signin"}) + m.post("http://test/api/3.6/auth/signin", text=xml) + server.auth.sign_in(TSC.TableauAuth("u", "p")) + assert server.auth_token is not None + + +def test_missing_location_header_raises_redirecterror(server: TSC.Server) -> None: + # `requests`' internal resolve_redirects raises KeyError('location') with no + # context. We raise RedirectError with URL, method, status code. + with requests_mock.mock() as m: + m.post(server.auth.baseurl + "/signin", status_code=302) # no Location header + with pytest.raises(RedirectError, match="without a Location header"): + server.auth.sign_in(TSC.TableauAuth("u", "p")) + + +# --- Hop limits ------------------------------------------------------------- + + +def test_redirect_loop_hits_max_hops(server: TSC.Server) -> None: + server.session.max_redirects = 3 + with requests_mock.mock() as m: + m.post(server.auth.baseurl + "/signin", status_code=302, headers={"Location": "http://test/loop"}) + m.post("http://test/loop", status_code=302, headers={"Location": "http://test/loop"}) + with pytest.raises(RedirectError, match="Exceeded 3 redirect hops"): + server.auth.sign_in(TSC.TableauAuth("u", "p")) + + +def test_max_redirects_zero_passes_non_redirect_response(signed_in_server: TSC.Server) -> None: + # Regression for the review finding: with the loop bounded by + # `range(max_hops)`, max_redirects=0 previously fell straight into the + # "exceeded" error even for a 200 response. + signed_in_server.session.max_redirects = 0 + baseurl = signed_in_server.workbooks.baseurl + with requests_mock.mock() as m: + m.get(baseurl, text=_workbooks_get_xml()) + result = signed_in_server.workbooks.get() + assert result[0] is not None + + +def test_max_redirects_one_rejects_second_hop(signed_in_server: TSC.Server) -> None: + # max_redirects=1 allows one non-redirect response but errors on a second + # 3xx. (max_redirects=0 is not tested because `requests` itself refuses to + # complete any request that returns 3xx when max_redirects=0, regardless of + # allow_redirects; the response never reaches _make_request.) + signed_in_server.session.max_redirects = 1 + baseurl = signed_in_server.workbooks.baseurl + with requests_mock.mock() as m: + m.get(baseurl, status_code=302, headers={"Location": baseurl + "?v=2"}) + m.get(baseurl + "?v=2", status_code=302, headers={"Location": baseurl + "?v=3"}) + with pytest.raises(RedirectError, match="Exceeded 1 redirect hops"): + signed_in_server.workbooks.get() From 1823c78a5acc7c5195709e120f2fcf568b303912 Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 20 Aug 2026 00:35:58 -0700 Subject: [PATCH 200/219] docs: add one-line docstrings to root-level helper modules (#1859) * docs: add docstrings to exponential_backoff.py * docs: add docstrings to datetime_helpers.py * docs: add docstrings to namespace.py * docs: add docstrings to filesys_helpers.py * docs: name CHUNK_SIZE_MB default and get_file_type's ValueError --- tableauserverclient/config.py | 5 +++++ tableauserverclient/datetime_helpers.py | 5 ++++- tableauserverclient/exponential_backoff.py | 3 +++ tableauserverclient/filesys_helpers.py | 4 ++++ tableauserverclient/namespace.py | 5 ++++- 5 files changed, 20 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index a75112754..fae211601 100644 --- a/tableauserverclient/config.py +++ b/tableauserverclient/config.py @@ -8,19 +8,24 @@ class Config: + """Runtime configuration for TSC, controllable via environment variables.""" + # The maximum size of a file that can be published in a single request is 64MB @property def FILESIZE_LIMIT_MB(self): + """Maximum single-request publish size in MB (env: TSC_FILESIZE_LIMIT_MB, capped at 64).""" return min(int(os.getenv("TSC_FILESIZE_LIMIT_MB", 64)), 64) # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks @property def CHUNK_SIZE_MB(self): + """Chunk size in MB for multipart publish requests (env: TSC_CHUNK_SIZE_MB, default 50).""" return int(os.getenv("TSC_CHUNK_SIZE_MB", 5 * 10)) # 5MB felt too slow, upped it to 50 # Default page size @property def PAGE_SIZE(self): + """Default page size for paginated API requests (env: TSC_PAGE_SIZE, default 100).""" return int(os.getenv("TSC_PAGE_SIZE", 100)) diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index 7beb0276e..ce5d60697 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -5,13 +5,14 @@ def timestamp(): + """Return the current local time as an HH:MM:SS string.""" return datetime.datetime.now().strftime("%H:%M:%S") # This class is a concrete implementation of the abstract base class tzinfo # docs: https://docs.python.org/2.3/lib/datetime-tzinfo.html class UTC(datetime.tzinfo): - """UTC""" + """UTC timezone implementation for use with datetime objects.""" def utcoffset(self, dt): return ZERO @@ -28,6 +29,7 @@ def dst(self, dt): def parse_datetime(date): + """Parse a Tableau API datetime string into a UTC-aware datetime, or None if absent or unparseable.""" if date is None: return None @@ -38,6 +40,7 @@ def parse_datetime(date): def format_datetime(date): + """Format a datetime as a Tableau API datetime string, or None if absent.""" if date is None: return None diff --git a/tableauserverclient/exponential_backoff.py b/tableauserverclient/exponential_backoff.py index 69ffdc96b..8e735d73a 100644 --- a/tableauserverclient/exponential_backoff.py +++ b/tableauserverclient/exponential_backoff.py @@ -7,12 +7,15 @@ class ExponentialBackoffTimer: + """Timer for polling server-side events with exponential backoff between attempts.""" + def __init__(self, *, timeout=None): self.start_time = time.time() self.timeout = timeout self.current_sleep_interval = ASYNC_POLL_MIN_INTERVAL def sleep(self): + """Sleep for the next backoff interval, raising TimeoutError if the timeout deadline has passed.""" max_sleep_time = ASYNC_POLL_MAX_INTERVAL if self.timeout is not None: elapsed = time.time() - self.start_time diff --git a/tableauserverclient/filesys_helpers.py b/tableauserverclient/filesys_helpers.py index 1663ee9e8..3e113bc58 100644 --- a/tableauserverclient/filesys_helpers.py +++ b/tableauserverclient/filesys_helpers.py @@ -4,11 +4,13 @@ def to_filename(string_to_sanitize): + """Strip characters from a string that are not safe for use in a filename.""" sanitized = (c for c in string_to_sanitize if c.isalnum() or c in ALLOWED_SPECIAL) return "".join(sanitized) def make_download_path(filepath, filename): + """Resolve a download destination path from an optional target filepath and the server-provided filename.""" download_path = None if filepath is None: @@ -24,6 +26,7 @@ def make_download_path(filepath, filename): def get_file_object_size(file): + """Return the size in bytes of an open binary file object.""" # Returns the size of a file object file.seek(0, os.SEEK_END) file_size = file.tell() @@ -32,6 +35,7 @@ def get_file_object_size(file): def get_file_type(file): + """Detect the type of an open binary file by inspecting its magic bytes, returning one of 'zip', 'tde', 'xml', or 'hyper'. Raises ValueError if none match.""" # Tableau workbooks (twb) and data sources (tds) are both stored as xml files. # Packaged workbooks (twbx) and data sources (tdsx) are zip files # containing original files accompanied with supporting local files. diff --git a/tableauserverclient/namespace.py b/tableauserverclient/namespace.py index 54ac46d8d..2b5e5b2b5 100644 --- a/tableauserverclient/namespace.py +++ b/tableauserverclient/namespace.py @@ -8,10 +8,12 @@ class UnknownNamespaceError(Exception): - pass + """Raised when an XML response contains an unrecognized Tableau API namespace.""" class Namespace: + """Detects and stores the Tableau REST API XML namespace from server responses.""" + def __init__(self): self._namespace = {"t": NEW_NAMESPACE} self._detected = False @@ -20,6 +22,7 @@ def __call__(self): return self._namespace def detect(self, xml): + """Detect the XML namespace from raw response bytes, updating the stored namespace on first call. Raises UnknownNamespaceError if the namespace is not a recognized Tableau REST API namespace.""" if self._detected: return From 51b3efc3c396a477e31c4facf98ea3985aa2e13e Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 20 Aug 2026 00:39:51 -0700 Subject: [PATCH 201/219] docs: expand RequestOptions.vf() with vf_ wire-format rules (#1840) * docs: expand RequestOptions.vf() with vf_ wire-format rules * docs: drop irrelevant filter= reference from vf() docstring * docs: document backslash as vf_ escape character * docs: rephrase vf_ backslash bullet to lead with examples * docs: clarify vf_ escaping is only for ',' and '\' * docs: clarify vf() percent-encoding vs escaping * docs: label empirical vf() claims and tighten wildcard note The vf_ view filter has several server-side quirks that aren't obvious from the current one-line docstring. Document what values actually mean on the wire: exact match by default, comma as OR-list, backslash-escape for literal comma, empty-value override behavior, no wildcards, no operators, boolean 'true'/'false' only. This surfaces the behavior verified end-to-end against Tableau Cloud while working through --filter parsing issues in tabcmd, so callers who build vf_ requests through TSC know what the server will accept without having to reverse-engineer it. --- tableauserverclient/server/request_options.py | 49 ++++++++++++++++++- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 5a8600908..da1c70826 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -351,9 +351,54 @@ def get_query_params(self): def vf(self, name: str, value: str) -> Self: """Apply a filter based on a column within the view. - Note that when filtering on a boolean type field, the only valid values are 'true' and 'false' - For more detail see: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_filtering_and_sorting.htm#Filter-query-views + Serialized to the REST API as ``vf_=``. The rules below + describe how the server interprets the wire value. ``vf()`` itself + does not apply any Tableau-specific escaping or semantic transforms + to your value; it only percent-encodes the value for HTTP transport, + which the server decodes back before applying the rules below. + + **Value syntax** + + - **Exact match (default):** a single value matches rows where the + column equals that value exactly. Case-sensitivity follows the + underlying data source. + - **OR-list:** commas separate alternatives, so ``"East,West"`` + matches rows where the column is ``East`` OR ``West``. + - **Escaping** applies to two characters only, comma and backslash; + all other characters (``&``, ``=``, ``/``, ``#``, ``%``, ``+``, + quotes, brackets, etc.) pass through untouched and the server + treats them as literal data. The ``\\,`` escape for a literal + comma is documented on the Tableau REST API filtering page. The + ``\\\\`` escape for a literal backslash, and the observation that + percent-encoding is transport-only (``%2C`` and ``%5C`` reach the + server as ``,`` and ``\\`` and are then processed like any other + comma or backslash, so URL-encoding does NOT escape them), are + empirical -- verified end-to-end against Tableau REST API 3.30 + in August 2026. Examples below give the wire form (what the + server sees after URL decoding) and the data value it matches. + To match a value containing a literal comma, wire form + ``Rock\, Paper\, Scissors`` matches data value ``Rock, Paper, + Scissors`` (without escaping, the comma starts an OR-list). To + match a literal backslash, double it: wire form ``C:\\temp\\file`` + matches data value ``C:\temp\file``. + - **Empty value** (``vf_=``) is observed to override any + workbook-embedded filter on that column, effectively widening it + to all values. Not officially documented; behavior may change + without notice. + - **No wildcards, ranges, comparisons, or operators.** Characters + like ``*`` and ``%`` are treated as literal data, not glob or SQL + wildcards; ``vf_Product=Widget*`` matches only a product literally + named ``Widget*``. Verified end-to-end against Tableau REST API 3.30 + in August 2026 (``vf_Product Name=*`` and ``vf_Product Name=%`` + returned zero rows; ``vf_Product Name=META /star*/`` returned the + single row whose value contains a literal ``*``). If you need + contains / prefix / range matching, configure the equivalent + filter inside the workbook. + - **Booleans:** the only valid values are ``'true'`` and ``'false'``. + + For more detail see: + https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_filtering_and_sorting.htm#Filter-query-views Parameters ---------- From 58e6fd9e49a0b5316e10084041a6097e31146b1d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:50:02 -0700 Subject: [PATCH 202/219] chore(deps-dev): bump mypy from 2.3.0 to 2.3.1 (#1867) Bumps [mypy](https://github.com/python/mypy) from 2.3.0 to 2.3.1. - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v2.3.0...v2.3.1) --- updated-dependencies: - dependency-name: mypy dependency-version: 2.3.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e5a330c39..3084719bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["black==26.5.1", "build", "mypy==2.3.0", "pytest>=7.0", "pytest-cov", "pytest-subtests", +test = ["black==26.5.1", "build", "mypy==2.3.1", "pytest>=7.0", "pytest-cov", "pytest-subtests", "pytest-xdist", "requests-mock>=1.0,<2.0", "types-requests>=2.32.4.20250913"] [tool.setuptools.package-data] From dba5cd17e1a9105192685cfb984d052501695c11 Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 20 Aug 2026 17:15:22 -0700 Subject: [PATCH 203/219] docs: add pull-request template with Motivation / Behavior / Test plan (#1856) * docs: add pull-request template with Motivation / Behavior / Test plan Reviewers of PRs to this repo have to guess "why now" for most changes, whether a PR is a behavior change for downstream users, and which claims in the body are covered by tests vs. verified manually. Adding a template with three prompts lets contributors surface those answers without me nagging in every review. The sections are opinionated but small: Motivation, Behavior change, and Test plan. Each has a comment explaining the intent. Contributors can delete sections that don't apply. Co-Authored-By: Claude Opus 4.7 (1M context) * Address fresh-eyes review on PR template Two nits from review: - Swap literal `--` inside HTML comments for single `-`. GitHub renders both fine, but double-dash is invalid SGML inside a comment and some markdown linters flag it. - Add a one-line pointer to contributing.md and CHANGELOG.md so external contributors don't have to hunt for the CHANGELOG expectation. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/PULL_REQUEST_TEMPLATE.md | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..5d042a9b8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,33 @@ + + +## Motivation + + + +## Behavior change + + + +## Test plan + + From 56aa33f9751f75b378bf24569bdf52fd8f2a3028 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 25 Aug 2026 02:09:59 -0700 Subject: [PATCH 204/219] security: bump urllib3 floor to 2.6.3 for CVE-2026-21441 (#1869) FOSSA flagged CVE-2026-21441 (GHSA-38jv-5279-wg99, 8.9 High) against urllib3 2.6.0. The fix landed in urllib3 2.6.3: streaming decompression safeguards were bypassed when HTTP redirects were followed. The existing `urllib3>=2.6.0,<3` specifier already permits 2.6.3, but because 2.6.0 remains a resolvable install, Dependabot did not open a range-bump PR, and this repo did not have the security-updates side of Dependabot expressed in its config either. This change: * Raises the floor in pyproject.toml to `urllib3>=2.6.3,<3` so fresh resolves cannot land on a vulnerable release. * Adds `open-pull-requests-limit: 10` to both dependabot ecosystems so security PRs are not squeezed out by the default cap of 5, and documents on the pip block that security advisories fire independently of the weekly schedule as long as "Dependabot security updates" is enabled under Settings -> Code security. * Adds a CHANGELOG entry naming the CVE and GHSA. Co-authored-by: Claude Opus 4.7 (1M context) --- .github/dependabot.yml | 5 +++++ CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 38411afcc..1e2ae4b6a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,11 +3,16 @@ updates: - package-ecosystem: "pip" directory: "/" target-branch: "development" + # Security advisories fire independently of the weekly schedule as long + # as the repo has "Dependabot security updates" enabled under + # Settings -> Code security. Weekly = non-security updates. schedule: interval: "weekly" + open-pull-requests-limit: 10 - package-ecosystem: "github-actions" directory: "/" target-branch: "development" schedule: interval: "weekly" + open-pull-requests-limit: 10 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b7e1ee19..db9780ee9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ ## Unreleased +* Bumped the urllib3 floor to 2.6.3 to pick up the fix for CVE-2026-21441 + (GHSA-38jv-5279-wg99, 8.9 High): urllib3's streaming decompression + safeguards were bypassed when HTTP redirects were followed. TSC's manual + redirect walker (#1848) disables urllib3's built-in follower on new code + paths, but downstream callers using urllib3 directly (and TSC endpoints + that predate #1848) still relied on the built-in path, so the floor bump + closes the gap for all callers. The existing `<3` upper bound is unchanged. * Added `Projects.get_by_path(path)` to look up a project by its slash-separated hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by level using the REST API name filter, so a path with *n* components issues *n* diff --git a/pyproject.toml b/pyproject.toml index 3084719bb..9c058c150 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,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.6.0,<3', + 'urllib3>=2.6.3,<3', 'typing_extensions>=4.0', ] requires-python = ">=3.10" From 68201912354bece1222ecdae620d5b4e25052eba Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 25 Aug 2026 02:10:14 -0700 Subject: [PATCH 205/219] [tabcmd] refactor: extract CSVImport._decompose_site_role, inverse of _evaluate_site_role (#1812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: CSVImport AUTH column, case-insensitive validation, username preservation; add find_by_name Fixes for UserItem.CSVImport (issue #1809): - MAX=8 (was 7=AUTH index): 8-column lines with auth type no longer rejected as "too many columns" - create_user_from_line no longer lowercases the whole line before splitting — username case is preserved - _validate_import_line_or_throw normalizes license/admin/publisher to lowercase and auth to canonical form before comparison, so 'Viewer', 'Creator', 'SAML', 'tableauidwithmfa' etc. are all accepted - Add TableauIDWithMFA to valid auth values in validation (was missing) - 5 new tests covering each fix Add QuerysetEndpoint.find_by_name(name) (issue #1810): - Thin wrapper over .filter(name=name) returning a list - Available on all content-item endpoints (workbooks, datasources, views, users, projects, groups) Co-Authored-By: Claude Sonnet 4.6 * fix: add auth column to create_users_csv, fix create_from_file extension check and debug prints - create_users_csv was producing 7-column CSV, silently dropping auth_setting; bulk_add roundtrip would lose auth type - create_from_file extension check used filepath.find("csv") which evaluates as falsy only when "csv" is at index 0, letting all other paths through; fixed to "csv" not in filepath - remove two debug print() calls left in create_from_file Co-Authored-By: Claude Sonnet 4.6 * refactor: extract _decompose_site_role into CSVImport, symmetrical with _evaluate_site_role Moves the ad-hoc site role → (license, admin_level, publish) logic from create_users_csv into UserItem.CSVImport._decompose_site_role, making it the explicit inverse of _evaluate_site_role. Also fixes pre-existing bugs in the decomposition: - ExplorerCanPublish was emitted as license="ExplorerCanPublish" (not a valid CSV license value); now correctly "Explorer" with publish=1 - SiteAdministrator (legacy role) was emitting license="" via str.replace; now maps to ("Explorer", "Site", "1") - Non-admin roles now emit admin_level="None" (explicit CSV spec value) rather than "" (empty string); both are accepted by the server but "None" is consistent with the spec and _evaluate_site_role input Co-Authored-By: Claude Sonnet 4.6 * fix: decompose unsupported site roles to license="Invalid" not "Unlicensed" Legacy values in UserItem.Roles (UnlicensedWithPublish, ViewerWithPublish, Guest, SupportUser) have never been accepted by the server-side CSV license parser (workgroup: CsvLicenseRoleTypeConverter). The initial _decompose_site_role default of ("Unlicensed", "None", "0") silently coerced these to a valid but semantically wrong Unlicensed user, replacing an old server-side per-row rejection with silent success. Default to license="Invalid" instead so the server continues to reject rows with USER_CSV_INVALID_LICENSE, preserving the pre-refactor observable behavior for callers who inspect job results for per-row failures. Batch resilience is unaffected: bad rows fail, good rows succeed, no client-side throw takes down the whole bulk_add call. Follow-up: deprecate UnlicensedWithPublish/ViewerWithPublish from UserItem.Roles (never worked on any code path); see forthcoming issue. * docs: clarify _decompose_site_role's asymmetric legacy-role handling The role map handles legacy UserItem.Roles values in two ways depending on whether the server has a modern equivalent. SiteAdministrator, Publisher, Interactor, and ReadOnly map to their current-model equivalents; UnlicensedWithPublish, ViewerWithPublish, Guest, and SupportUser fall to license="Invalid" so the server rejects the row. Reviewers otherwise read the code and see "why does Publisher get mapped but Guest doesn't?" - the answer is server behavior, not arbitrary choice. Docstring now says so. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: accept "1"/"0" and "true"/"false" for publisher, add round-trip test Two related changes to make `_evaluate_site_role` and `_decompose_site_role` a real inverse pair: - `_evaluate_site_role` now accepts "1", "true", and "yes" for the publisher column (and, symmetrically, treats anything else as "no"). Previously only "yes" was accepted, which meant `_decompose_site_role` emitting "1" for Creator/ExplorerCanPublish would round-trip as Explorer. The set of publisher values mirrors what `_valid_attributes[publisher]` already documents as legal (["yes", "true", "1", "no", "false", "0"]), so this brings the two code paths in agreement. - Added a parametrized `test_decompose_then_evaluate_round_trips` covering every entry in `_role_map` plus the two documented label asymmetries (ServerAdministrator -> SiteAdministrator on the way back; legacy roles Publisher/Interactor/ReadOnly/SiteAdministrator folded into their modern equivalents). If either function drifts, a specific input names the broken case rather than the whole loop dying on the first mismatch. - `_decompose_site_role` docstring updated with a Round-trip note naming the two intentional asymmetries so readers do not have to reason about them from the mapping table. Full suite still passes: 884 passed, 1 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) * Address fresh-eyes review nits before un-drafting - Strip five non-ASCII characters (arrow + em-dashes) from docstrings and comments; project convention is ASCII-only. - create_user_from_line now passes an unknown auth value through unchanged (previously silently .get'd to None). _validate_import_ line_or_throw on the same input raises, so the two entry points no longer disagree on what counts as valid. Not addressed here (per fresh-eyes review, non-blocking): - Deprecated create_from_file's "csv" substring check remains loose (matches report.csv.bak, rejects USERS.CSV); pre-existing on a @deprecated method, out of scope for this refactor. - PR body test-plan checkbox that's actually complete: tick before un-drafting. 59 user tests pass. * Warn (don't raise) on unknown auth values during CSV import Post-merge follow-up on #1811 -- the strict behavior it introduced (create_user_from_line raises ValueError on any auth value not in _AUTH_CANONICAL) would block CSV imports against newer servers as soon as Tableau ships an auth type TSC's hardcoded list doesn't yet know about. This is the same category of stale-list problem the _set_values enum-guard bypass exists to avoid on the server-parse path. Both entry points now warn and pass the value through: - create_user_from_line: unknown values raise a UserWarning naming the value and known set, then get assigned to auth_setting as-is. If the value really is a typo, the server rejects the row when the request posts -- a slightly-later error, but the import stays possible against forward-compatible servers. - _validate_import_line_or_throw: same shape. Skips the allowlist check for the AUTH column when the value isn't in _AUTH_CANONICAL, so validate_file_for_import doesn't return the row as invalid. Server-version-aware validation would be the cleaner long-term fix here (and for the enum-guard bypass in _set_values); noted for planning, not filing an issue. Tests updated: two former "raises ValueError" cases now assert pytest.warns(UserWarning) and confirm the raw value round-trips onto UserItem.auth_setting. * Strip password-mask line: it belongs to #1862, not this PR Inadvertently pulled the password-column mask (safe_value = "***" if column == PASS else value) into this PR while restructuring the validation loop for the warn-on-unknown-auth change. The mask is #1862's core content and shouldn't sneak in through the site-role decompose PR -- reviewers on either PR would see mysterious overlap. #1862's full feature (log mask + INFO->DEBUG downgrade + _redact_password_column helper for invalid_lines sanitization) stays where it belongs, on jac/csv-import-privacy. The `column = ColumnType(i)` local rename stays because it's used by the log line's `{column.name}` format and by the AUTH branch's comparison; that's plain cleanup and doesn't overlap with #1862. --------- Co-authored-by: Claude Sonnet 4.6 --- tableauserverclient/models/user_item.py | 94 +++++++++++++++++-- .../server/endpoint/endpoint.py | 3 + .../server/endpoint/users_endpoint.py | 23 +---- test/test_user.py | 19 +++- test/test_user_model.py | 61 +++++++++--- 5 files changed, 158 insertions(+), 42 deletions(-) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index da23642bf..f906fa8ec 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -1,4 +1,5 @@ import io +import warnings import xml.etree.ElementTree as ET from datetime import datetime from enum import IntEnum @@ -476,12 +477,23 @@ def create_user_from_line(line: str): ) raw_auth = values[UserItem.CSVImport.ColumnType.AUTH] if raw_auth: - auth = UserItem.CSVImport._AUTH_CANONICAL.get(raw_auth.lower()) - if auth is None: - raise ValueError( - f"Unknown auth setting: {raw_auth!r}. " - f"Valid values: {sorted(UserItem.CSVImport._AUTH_CANONICAL.values())}" + canonical = UserItem.CSVImport._AUTH_CANONICAL.get(raw_auth.lower()) + if canonical is None: + # Unknown auth value: pass it through instead of raising. + # TSC's _AUTH_CANONICAL is a hardcoded list that will lag + # server-side additions; refusing to build the UserItem + # here would block CSV imports against newer servers as + # soon as Tableau ships a new auth type. If it is a + # typo, the server rejects the row when the request + # posts. Warn so the caller has a shot at noticing. + warnings.warn( + f"Unknown auth setting {raw_auth!r}; passing through unchanged. " + f"Known values: {sorted(UserItem.CSVImport._AUTH_CANONICAL.values())}", + stacklevel=2, ) + auth = raw_auth + else: + auth = canonical else: auth = None user._set_values( @@ -546,14 +558,33 @@ def _validate_import_line_or_throw(incoming, logger) -> None: for i in range(1, len(line)): value = line[i] valid = _valid_attributes[i] + column = UserItem.CSVImport.ColumnType(i) # normalize case for fields with a restricted value set + skip_validation = False if valid: if i == UserItem.CSVImport.ColumnType.AUTH: - value = UserItem.CSVImport._AUTH_CANONICAL.get(value.lower(), value) + canonical = UserItem.CSVImport._AUTH_CANONICAL.get(value.lower()) + if canonical is not None: + value = canonical + elif value: + # Unknown auth value: warn and pass through instead + # of raising. TSC's _AUTH_CANONICAL is a hardcoded + # list that lags server-side additions; refusing + # would block CSV imports against newer servers as + # soon as Tableau ships a new auth type. Skip the + # allowlist check so the row still validates. + # Matches create_user_from_line's warn-and-pass. + warnings.warn( + f"Unknown auth setting {value!r}; passing through unchanged. " + f"Known values: {sorted(UserItem.CSVImport._AUTH_CANONICAL.values())}", + stacklevel=2, + ) + skip_validation = True else: value = value.lower() - logger.debug(f"column {UserItem.CSVImport.ColumnType(i).name}: {value}") - UserItem.CSVImport._validate_attribute_value(value, valid, UserItem.CSVImport.ColumnType(i)) + logger.debug(f"column {column.name}: {value}") + if not skip_validation: + UserItem.CSVImport._validate_attribute_value(value, valid, column) # Given a restricted set of possible values, confirm the item is in that set @staticmethod @@ -565,6 +596,49 @@ def _validate_attribute_value(item: str, possible_values: list[str], column_type return raise ValueError(f"Invalid value {item} for {column_type}") + # Inverse of _evaluate_site_role: decompose a site role back to (license, admin_level, publish) + # for writing the CSV import format. + @staticmethod + def _decompose_site_role(site_role: str) -> tuple[str, str, str]: + """Return (license, admin_level, publish) CSV column values for a given site role. + + Legacy `UserItem.Roles` values are handled in two ways depending on whether + the server has a sensible modern equivalent: + + - **Mapped to modern equivalents** (row emitted, server accepts): the legacy + roles `SiteAdministrator`, `Publisher`, `Interactor`, and `ReadOnly` each + map to the current-model role that best matches their historical intent + (SiteAdministratorExplorer, ExplorerCanPublish, Explorer, Viewer). + - **Emitted as `license="Invalid"`** (row rejected server-side with + USER_CSV_INVALID_LICENSE): the legacy roles `UnlicensedWithPublish`, + `ViewerWithPublish`, `Guest`, and `SupportUser` have no equivalent in the + current server model (`RestApiSiteRole` does not accept them on any code + path). Emitting `"Invalid"` preserves the per-row error semantics callers + of `bulk_add` had before this refactor, rather than silently coercing + those users to a valid-but-wrong Unlicensed account. + + Round-trip note: `_evaluate_site_role(*_decompose_site_role(r)) == r` for + every current-model role. Two label asymmetries: `ServerAdministrator` + round-trips through the legacy label `SiteAdministrator` (that's the only + label `_evaluate_site_role` emits for `admin="System"`), and the legacy + roles above are folded into their modern equivalents by design. + """ + _role_map: dict[str, tuple[str, str, str]] = { + "ServerAdministrator": ("Creator", "System", "1"), + "SiteAdministratorCreator": ("Creator", "Site", "1"), + "SiteAdministratorExplorer": ("Explorer", "Site", "1"), + "SiteAdministrator": ("Explorer", "Site", "1"), # legacy, mapped to SiteAdministratorExplorer + "Creator": ("Creator", "None", "1"), + "ExplorerCanPublish": ("Explorer", "None", "1"), + "Explorer": ("Explorer", "None", "0"), + "Viewer": ("Viewer", "None", "0"), + "Unlicensed": ("Unlicensed", "None", "0"), + "ReadOnly": ("Viewer", "None", "0"), # legacy, mapped to Viewer + "Publisher": ("Explorer", "None", "1"), # legacy, mapped to ExplorerCanPublish + "Interactor": ("Explorer", "None", "0"), # legacy, mapped to Explorer + } + return _role_map.get(site_role, ("Invalid", "None", "0")) + # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles # This logic is hardcoded to match the existing rules for import csv files @staticmethod @@ -586,14 +660,14 @@ def _evaluate_site_role(license_level, admin_level, publisher): else: site_role = "SiteAdministratorExplorer" else: # if it wasn't 'system' or 'site' then we can treat it as 'none' - if publisher == "yes": + if publisher in ("yes", "true", "1"): if license_level == "creator": site_role = "Creator" elif license_level == "explorer": site_role = "ExplorerCanPublish" else: site_role = "Unlicensed" # is this the expected outcome? - else: # publisher == 'no': + else: # publisher is "no" / "false" / "0" / any other value: if license_level == "explorer" or license_level == "creator": site_role = "Explorer" elif license_level == "viewer": diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 7fa34802d..64601fcbc 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -554,6 +554,9 @@ def fields(self: Self, *fields: str) -> QuerySet: queryset.request_options.fields |= set(fields) | set(("_default_",)) return queryset + def find_by_name(self, name: str) -> list[T]: + return list(self.filter(name=name)) + def only_fields(self: Self, *fields: str) -> QuerySet: """ Add fields to the request options. If no fields are provided, the diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 48c77da66..099dc3531 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -527,7 +527,7 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us warnings.warn("This method is deprecated, use bulk_add instead", DeprecationWarning) created = [] failed = [] - if not filepath.find("csv"): + if "csv" not in filepath: raise ValueError("Only csv files are accepted") with open(filepath) as csv_file: @@ -536,11 +536,9 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us while line and line != "": user: UserItem = UserItem.CSVImport.create_user_from_line(line) try: - print(user) result = self.add(user) created.append(result) except ServerResponseError as serverError: - print("failed") failed.append((user, serverError)) line = csv_file.readline() return created, failed @@ -751,6 +749,7 @@ def create_users_csv(users: Iterable[UserItem]) -> bytes: - Admin Level - Publish capability - Email + - Auth setting Parameters ---------- @@ -765,22 +764,7 @@ def create_users_csv(users: Iterable[UserItem]) -> bytes: 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 - + license, admin_level, publish = UserItem.CSVImport._decompose_site_role(user.site_role or "Unlicensed") writer.writerow( ( f"{user.domain_name}\\{user.name}" if user.domain_name else user.name, @@ -790,6 +774,7 @@ def create_users_csv(users: Iterable[UserItem]) -> bytes: admin_level, publish, user.email, + user.auth_setting or "", ) ) output.seek(0) diff --git a/test/test_user.py b/test/test_user.py index c3bb8dc5c..841233e38 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -405,7 +405,7 @@ def test_create_users_csv() -> None: "ServerAdministrator": "System", } - csv_columns = ["name", "password", "fullname", "license", "admin", "publish", "email"] + csv_columns = ["name", "password", "fullname", "license", "admin", "publish", "email", "auth"] csv_data = create_users_csv(users) csv_file = io.StringIO(csv_data.decode("utf-8")) csv_reader = csv.reader(csv_file) @@ -417,8 +417,23 @@ def test_create_users_csv() -> None: 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 admin_map.get(site_role, "None") == csv_user["admin"] assert publish_map[site_role] == int(csv_user["publish"]) + assert (user.auth_setting or "") == csv_user["auth"] + + +def test_decompose_unsupported_role_emits_invalid_license() -> None: + # UnlicensedWithPublish and ViewerWithPublish are in UserItem.Roles for + # historical reasons but the server-side CSV license parser has never + # accepted them. _decompose_site_role emits license="Invalid" for these + # (and any other unmapped role) so the server rejects the row with + # USER_CSV_INVALID_LICENSE, preserving the per-row error semantics + # callers of bulk_add had before this refactor. + for role in ("UnlicensedWithPublish", "ViewerWithPublish", "Guest", "SupportUser"): + license, admin, publish = TSC.UserItem.CSVImport._decompose_site_role(role) + assert license == "Invalid" + assert admin == "None" + assert publish == "0" def test_bulk_add(server: TSC.Server) -> None: diff --git a/test/test_user_model.py b/test/test_user_model.py index 66673cb38..ccb5cedc6 100644 --- a/test/test_user_model.py +++ b/test/test_user_model.py @@ -80,6 +80,39 @@ def test_evaluate_role() -> None: assert actual == line[3], line + [actual] +# _decompose_site_role writes CSV rows that the server (and TSC's own +# _evaluate_site_role) parse back into a site role. This parametrized test +# pins the round-trip so a change in either direction can't drift silently. +# The two documented asymmetries are the ServerAdministrator/SiteAdministrator +# label pair and the legacy-role fold; both are captured explicitly below. +@pytest.mark.parametrize( + "role, expected", + [ + # Canonical current-model roles round-trip identity. + ("SiteAdministratorCreator", "SiteAdministratorCreator"), + ("SiteAdministratorExplorer", "SiteAdministratorExplorer"), + ("Creator", "Creator"), + ("ExplorerCanPublish", "ExplorerCanPublish"), + ("Explorer", "Explorer"), + ("Viewer", "Viewer"), + ("Unlicensed", "Unlicensed"), + # admin="System" always evaluates back to the legacy "SiteAdministrator" + # label -- that's the only label _evaluate_site_role emits for System. + ("ServerAdministrator", "SiteAdministrator"), + # Legacy roles fold into their modern equivalents on the way through. + # Documented in _decompose_site_role's docstring. + ("SiteAdministrator", "SiteAdministratorExplorer"), + ("ReadOnly", "Viewer"), + ("Publisher", "ExplorerCanPublish"), + ("Interactor", "Explorer"), + ], +) +def test_decompose_then_evaluate_round_trips(role: str, expected: str) -> None: + license_level, admin_level, publish = TSC.UserItem.CSVImport._decompose_site_role(role) + actual = TSC.UserItem.CSVImport._evaluate_site_role(license_level, admin_level, publish) + assert actual == expected, (role, license_level, admin_level, publish, actual) + + def test_get_user_detail_empty_line() -> None: test_line = "" test_user = TSC.UserItem.CSVImport.create_user_from_line(test_line) @@ -171,11 +204,18 @@ def test_too_many_columns_raises() -> None: TSC.UserItem.CSVImport.create_user_from_line("u, p, n, creator, none, yes, email, SAML, extra") -def test_create_user_with_unknown_auth_raises() -> None: - # Unknown AUTH values must raise, not silently produce a UserItem with auth_setting=None. - # A caller can catch this if lenient behavior is wanted. - with pytest.raises(ValueError, match="Unknown auth setting"): - TSC.UserItem.CSVImport.create_user_from_line("username, pword, fname, creator, none, yes, email, NotAnAuthType") +def test_create_user_with_unknown_auth_passes_through_with_warning() -> None: + # Unknown AUTH values pass through with a UserWarning rather than raising. + # TSC's _AUTH_CANONICAL is a hardcoded list that lags server-side auth-type + # additions; refusing would block CSV imports against newer servers as + # soon as Tableau ships a new auth type. If the value really is a typo, + # the server rejects the row when the request posts. + with pytest.warns(UserWarning, match="Unknown auth setting"): + user = TSC.UserItem.CSVImport.create_user_from_line( + "username, pword, fname, creator, none, yes, email, NotAnAuthType" + ) + assert user is not None + assert user.auth_setting == "NotAnAuthType" def test_create_user_with_lowercase_auth_accepted() -> None: @@ -185,12 +225,11 @@ def test_create_user_with_lowercase_auth_accepted() -> None: assert user.auth_setting == "SAML" -def test_validate_import_line_rejects_unknown_auth() -> None: - # _validate_import_line_or_throw shares the AUTH canonicalization with - # create_user_from_line -- confirm both paths reject unknown auth values so - # that validate_file_for_import (which uses this path) doesn't silently - # accept rows create_user_from_line would refuse. - with pytest.raises(ValueError, match="Invalid value"): +def test_validate_import_line_warns_on_unknown_auth() -> None: + # _validate_import_line_or_throw matches create_user_from_line's warn-and- + # pass behavior on unknown auth values: the same row shouldn't be accepted + # by one path and rejected by the other. + with pytest.warns(UserWarning, match="Unknown auth setting"): TSC.UserItem.CSVImport._validate_import_line_or_throw( "username, pword, fname, creator, none, yes, email, NotAnAuthType", logger, From 8d9d2f91f88cd27c1bf495cbfc9228b88d2a59ca Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 2 Sep 2026 11:02:30 -0700 Subject: [PATCH 206/219] ci: shield issues with an open PR from the stale bot (#1864) * ci: shield issues with an open PR from the stale bot `actions/stale` only considers issue-level events when it decides whether to mark or close an issue. A PR that references an issue via `Closes #NNN` / `Fixes #NNN` does not reset the issue's stale timer or remove the `stale` label, so an issue can be auto-closed by the bot even while a PR that closes it is in review. Add a companion workflow that reacts to PR opens/edits and stamps the `in-progress` label on every referenced issue. The existing stale workflow already exempts `in-progress`. When a PR closes without being merged, the label is removed so a genuinely abandoned effort does not keep its referenced issues shielded forever. Merged PRs auto-close the referenced issues via GitHub's usual behavior, so leaving the label on them is harmless (they're closed). Uses `pull_request_target` for permissions on external-contributor PRs. The script only reads `pr.body`, extracts decimal issue numbers with a fixed regex, and passes those numbers to the REST API -- body content never reaches a `run:` step or a shell, so there is no command-injection surface even though the trigger runs with write permissions. Companion to #1841. Co-Authored-By: Claude Opus 4.7 (1M context) * ci: unlabel on edited body, cap ref count for pull_request_target safety Two review findings: - On `edited` events the script now diffs `payload.changes.body.from` against the new body and removes 'in-progress' from any issue whose Closes/Fixes/Resolves reference was deleted. Previously, editing a PR to drop `Closes #42` left #42 shielded from the stale bot indefinitely. - Cap the number of references processed per event at 50 (`MAX_REFS`). `pull_request_target` runs on PRs from forks, so an accidental or malicious PR body with thousands of matches would burn the repo's REST budget on labeling calls. Also adds a one-line comment noting that cross-repo refs (owner/repo#N) are intentionally out of scope; this workflow only labels issues in the current repo. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/link-issues-to-prs.yml | 104 +++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 .github/workflows/link-issues-to-prs.yml diff --git a/.github/workflows/link-issues-to-prs.yml b/.github/workflows/link-issues-to-prs.yml new file mode 100644 index 000000000..26f55715e --- /dev/null +++ b/.github/workflows/link-issues-to-prs.yml @@ -0,0 +1,104 @@ +name: Mark linked issues in-progress + +# When an open PR references an issue with Closes/Fixes/Resolves, apply the +# 'in-progress' label to that issue so the stale bot leaves it alone (the +# stale workflow already exempts 'in-progress'). Remove the label when the +# PR closes without merging, so a genuinely abandoned PR does not keep its +# referenced issues shielded forever. +# +# actions/stale looks at issue-level events only; a PR that references an +# issue does not reset the issue's stale timer or move it off the stale +# label. This workflow bridges that gap. +# +# Security note: the script only reads pr.body, extracts decimal issue +# numbers via a fixed regex, and passes those numbers to the REST API. +# Body content is never expanded into a run: command or a shell. + +on: + pull_request_target: + types: [opened, edited, reopened, synchronize, ready_for_review, closed] + +permissions: + issues: write + pull-requests: read + +jobs: + link: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const body = pr.body || ''; + const re = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi; + // Cross-repo references (owner/repo#123) are intentionally skipped; + // this workflow only labels issues in the current repo. + const MAX_REFS = 50; // cap runaway PR bodies from forks (pull_request_target). + const extract = (text) => [...new Set( + [...(text || '').matchAll(re)].map(m => Number(m[1])) + )].slice(0, MAX_REFS); + + const current = extract(body); + + // On `edited`, compute which references were REMOVED so their + // labels come off. Without this, a PR that once said "Closes #42" + // and no longer does would leave #42 shielded indefinitely. + let removed = []; + if (context.payload.action === 'edited') { + const prevBody = context.payload.changes?.body?.from; + if (prevBody !== undefined) { + const previous = extract(prevBody); + const now = new Set(current); + removed = previous.filter(n => !now.has(n)); + } + } + + // Apply the label while the PR is open. Remove it when the PR + // closes without merging (merged PRs also close, but the issue + // will be auto-closed by GitHub once the merge lands, so the + // in-progress label on it is harmless). Also remove on `edited` + // when a reference was deleted from the body. + const shouldLabel = pr.state === 'open'; + const closeUnlabel = pr.state === 'closed' && !pr.merged ? current : []; + const toUnlabel = [...new Set([...removed, ...closeUnlabel])]; + + if (current.length === 0 && toUnlabel.length === 0) { + core.info('No Closes/Fixes/Resolves references to process; nothing to do.'); + return; + } + + const removeLabelSafe = async (n) => { + await github.rest.issues.removeLabel({ + ...context.repo, + issue_number: n, + name: 'in-progress', + }).catch(err => { + // 404 just means the label was not present; not an error. + if (err.status !== 404) throw err; + }); + core.info(`#${n}: removed in-progress`); + }; + + if (shouldLabel) { + for (const n of current) { + try { + await github.rest.issues.addLabels({ + ...context.repo, + issue_number: n, + labels: ['in-progress'], + }); + core.info(`#${n}: added in-progress`); + } catch (err) { + core.warning(`#${n}: ${err.message}`); + } + } + } + + for (const n of toUnlabel) { + try { + await removeLabelSafe(n); + } catch (err) { + core.warning(`#${n}: ${err.message}`); + } + } From f76d9f3e68a3c820f6db9bca073b5d84da514b98 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 2 Sep 2026 11:02:57 -0700 Subject: [PATCH 207/219] ci: exempt Server-Side Enhancement label from stale bot (#1841) * ci: exempt server-side-gap label from stale bot Issues blocked on server-side REST API work have no client-side activity to accumulate, so the stale bot would repeatedly flag them by default. Exempt the `server-side-gap` label (used to mark issues that need a server-side fix and cannot be resolved in TSC alone) so those items stay open until the underlying server work lands. Co-Authored-By: Claude Opus 4.7 (1M context) * fixup: use existing 'Server-Side Enhancement' label instead of new one The repo already has a 'Server-Side Enhancement' label applied to issues blocked on server-side REST API work (e.g. #1716, #1658, #1537, #1508). Adding a parallel 'server-side-gap' label would have been redundant. Point the stale-bot exemption at the existing label. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 576a09926..09d376a21 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -18,7 +18,7 @@ jobs: with: stale-issue-label: 'stale' stale-pr-label: 'stale' - exempt-issue-labels: 'good first issue,Design Proposal,in-progress' + exempt-issue-labels: 'good first issue,Design Proposal,in-progress,Server-Side Enhancement' exempt-pr-labels: 'in-progress' days-before-issue-stale: 60 From fa69a606a4b956ac582c4ee3e261dd7060a33cae Mon Sep 17 00:00:00 2001 From: Daniel Caspi Date: Tue, 8 Sep 2026 22:43:21 -0700 Subject: [PATCH 208/219] Make Server thread-safe with per-thread sessions (#1871) * fix: make Server thread-safe with per-thread sessions * fix torn auth-state reads exposed by free-threaded Python * restore Server._session as a backwards-compatible property shim --- tableauserverclient/server/server.py | 216 +++++++++++++++++--- test/test_thread_safety.py | 292 +++++++++++++++++++++++++++ 2 files changed, 482 insertions(+), 26 deletions(-) create mode 100644 test/test_thread_safety.py diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 6b0338970..9e5797dc4 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,8 +1,12 @@ from tableauserverclient.helpers.logging import logger import requests +import threading import urllib3 import ssl +import weakref + +from typing import NamedTuple, Optional from defusedxml.ElementTree import fromstring, ParseError from packaging.version import Version @@ -60,6 +64,23 @@ default_server_version = "2.4" # first version that dropped the legacy auth endpoint +class _AuthState(NamedTuple): + """ + Immutable bundle of the auth fields that must change together. + + The whole state is swapped by assigning a new instance, and a single + reference assignment or read is atomic on every CPython build, + free-threaded (no-GIL) builds included. Readers take one snapshot of + ``Server._auth_state`` and destructure it, so they can never observe a + token paired with the site or user of a different sign in. + """ + + site_id: Optional[str] = None + user_id: Optional[str] = None + auth_token: Optional[str] = None + site_url: Optional[str] = None + + class Server: """ In the Tableau REST API, the server (https://MY-SERVER/) is the base or core @@ -121,6 +142,18 @@ class Server: Notes ----- + A single Server instance may be shared across threads (for example with + ``concurrent.futures.ThreadPoolExecutor``). Authentication state (the auth + token, site and user IDs, and API version) is shared by all threads, while + each thread transparently gets its own ``requests.Session`` for HTTP calls, + since ``requests.Session`` itself is not guaranteed to be thread-safe. Sign + in, and call ``use_server_version()`` if you need it, before spawning + worker threads; signing out invalidates the sessions of all threads. + ``session_factory`` may be called once per thread, so a custom factory + should be safe to call concurrently. Call ``close()`` (or use the Server + as a context manager) after worker threads finish to release the pooled + HTTP connections of every thread's session. + When using Python 3.12 or later with older versions of Tableau Server, you may encounter SSL errors related to weak Diffie-Hellman keys. This is because newer Python versions enforce stronger security requirements. You can temporarily work around this using @@ -141,9 +174,7 @@ class PublishMode: Replace = "Replace" def __init__(self, server_address, use_server_version=False, http_options=None, session_factory=None): - self._auth_token = None - self._site_id = None - self._user_id = None + self._auth_state = _AuthState() self._ssl_context = None # TODO: this needs to change to default to https, but without breaking existing code @@ -153,6 +184,22 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self._server_address: str = server_address self._session_factory = session_factory or requests.session + # Thread-safety machinery. requests.Session is not guaranteed to be + # thread-safe (see psf/requests#2766), so each thread that makes calls + # through this Server instance gets its own Session object, created + # lazily from session_factory. The epoch counter invalidates every + # thread's cached session when auth state is cleared (sign out). The + # lock guards the epoch counter and the session WeakSet; auth state + # itself is an immutable _AuthState swapped by reference and needs + # no lock. + self._session_lock = threading.Lock() + self._session_epoch = 0 + self._thread_sessions = threading.local() + # Weak references to every session created for any thread, so close() + # can release their pooled connections. Weak so that sessions belonging + # to threads that have exited can still be garbage collected. + self._all_sessions: "weakref.WeakSet[requests.Session]" = weakref.WeakSet() + self.auth = Auth(self) self.views = Views(self) self.users = Users(self) @@ -187,7 +234,6 @@ def __init__(self, server_address, use_server_version=False, http_options=None, 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 if http_options: self.add_http_options(http_options) @@ -203,7 +249,7 @@ def validate_connection_settings(self): Endpoint.set_user_agent(params) if not self._server_address.startswith("http://") and not self._server_address.startswith("https://"): self._server_address = "http://" + self._server_address - self._session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options)) + self.session.prepare_request(requests.Request("GET", url=self._server_address, params=self._http_options)) except Exception as req_ex: raise ValueError("Server connection settings not valid", req_ex) @@ -226,21 +272,64 @@ def clear_http_options(self): self._http_options = dict() def _clear_auth(self): - self._site_id = None - self._user_id = None - self._auth_token = None - self._site_url = None - self._session = self._session_factory() + # swapping the whole immutable state in one assignment is atomic on + # every CPython build, free-threaded (no-GIL) builds included + self._auth_state = _AuthState() + with self._session_lock: + # Invalidate the cached session of every thread so state such as + # cookies does not leak into a later sign in. Sessions are replaced + # lazily on next use rather than closed here, so requests already + # in flight on other threads are not disrupted (this matches the + # previous behavior of re-assigning the shared session). The lock + # guards the epoch increment, which is not atomic without the GIL. + self._session_epoch += 1 def _set_auth(self, site_id, user_id, auth_token, site_url=None): - self._site_id = site_id - self._user_id = user_id - self._auth_token = auth_token - self._site_url = site_url + # swapping the whole immutable state in one assignment is atomic on + # every CPython build, free-threaded (no-GIL) builds included, so + # readers can never observe a partially-updated state + self._auth_state = _AuthState(site_id, user_id, auth_token, site_url) + + # Backwards-compatible access to the individual auth fields. Existing code + # (including this project's tests) reads and assigns these directly as a + # sign-in shortcut. Each assignment swaps in a fully-formed state, so + # readers never see torn fields; assigning fields one at a time is not + # atomic as a group, though, so concurrent writers should use _set_auth. + @property + def _auth_token(self): + return self._auth_state.auth_token + + @_auth_token.setter + def _auth_token(self, value) -> None: + self._auth_state = self._auth_state._replace(auth_token=value) + + @property + def _site_id(self): + return self._auth_state.site_id + + @_site_id.setter + def _site_id(self, value) -> None: + self._auth_state = self._auth_state._replace(site_id=value) + + @property + def _user_id(self): + return self._auth_state.user_id + + @_user_id.setter + def _user_id(self, value) -> None: + self._auth_state = self._auth_state._replace(user_id=value) + + @property + def _site_url(self): + return self._auth_state.site_url + + @_site_url.setter + def _site_url(self, value) -> None: + self._auth_state = self._auth_state._replace(site_url=value) def _get_legacy_version(self): # the serverInfo call was introduced in 2.4, earlier than that we have this different call - response = self._session.get(self.server_address + "/auth?format=xml") + response = self.session.get(self.server_address + "/auth?format=xml") try: info_xml = fromstring(response.content) except ParseError as parseError: @@ -294,31 +383,37 @@ def namespace(self): @property def auth_token(self): - if self._auth_token is None: + # read the state once: a second self._auth_state read could observe + # a concurrent sign out and return None instead of raising + token = self._auth_state.auth_token + if token is None: error = "Missing authentication token. You must sign in first." raise NotSignedInError(error) - return self._auth_token + return token @property def site_id(self): - if self._site_id is None: + site_id = self._auth_state.site_id + if site_id is None: error = "Missing site ID. You must sign in first." raise NotSignedInError(error) - return self._site_id + return site_id @property def site_url(self): - if self._site_url is None: + site_url = self._auth_state.site_url + if site_url is None: error = "Missing site URL. You must sign in first." raise NotSignedInError(error) - return self._site_url + return site_url @property def user_id(self): - if self._user_id is None: + user_id = self._auth_state.user_id + if user_id is None: error = "Missing user ID. You must sign in first." raise NotSignedInError(error) - return self._user_id + return user_id @property def server_address(self): @@ -329,11 +424,50 @@ def http_options(self): return self._http_options @property - def session(self): - return self._session + def session(self) -> requests.Session: + """ + The requests.Session used for HTTP calls made by the current thread. + + requests.Session is not guaranteed to be thread-safe (see + psf/requests#2766), so each thread that makes calls through this Server + instance transparently gets its own Session object, created from + ``session_factory``. Sessions are cached per thread, so a thread pool + worker reuses its session (and its connection pool) across tasks. + Signing out invalidates the cached sessions of all threads. + """ + local = self._thread_sessions + epoch = self._session_epoch + if getattr(local, "session", None) is None or local.epoch != epoch: + session = self._session_factory() + with self._session_lock: + self._all_sessions.add(session) + local.session = session + # `epoch` was read before the factory ran: if a sign out happened + # in between, local.epoch is already stale and the session will be + # replaced on the next access. + local.epoch = epoch + return local.session + + # Backwards-compatible access to the pre-thread-safety private attribute. + # Reading returns the current thread's session; assigning replaces the + # CURRENT thread's session only (other threads keep sessions created by + # session_factory), which preserves the common single-threaded pattern of + # injecting a prepared session before making calls. The injected session + # is registered so close() still reaches it. + @property + def _session(self) -> requests.Session: + return self.session + + @_session.setter + def _session(self, value: requests.Session) -> None: + local = self._thread_sessions + with self._session_lock: + self._all_sessions.add(value) + local.session = value + local.epoch = self._session_epoch def is_signed_in(self): - return self._auth_token is not None + return self._auth_state.auth_token is not None def configure_ssl(self, *, allow_weak_dh=False): """Configure SSL/TLS settings for the server connection. @@ -357,3 +491,33 @@ def configure_ssl(self, *, allow_weak_dh=False): # Remove any custom SSL context if we're reverting to default settings if "verify" in self._http_options: del self._http_options["verify"] + + def close(self) -> None: + """ + Release the pooled HTTP connections held by every thread's session. + + Call this when you are done with the server, after any worker threads + have finished their requests. Closing is a transport-level operation: + it does not sign out, so the auth token remains valid on the server + (use ``auth.sign_out()`` for that). The Server object remains usable + after close; any subsequent call transparently creates a new session. + """ + with self._session_lock: + sessions = list(self._all_sessions) + self._all_sessions.clear() + # Invalidate every thread's cached (now closed) session so later + # use creates a fresh one instead of hitting closed pools. + self._session_epoch += 1 + for session in sessions: + session.close() + + def __enter__(self) -> "Server": + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + # Note: does not sign out. server.auth.sign_in() already returns a + # context manager that signs out; nesting the two composes cleanly: + # with TSC.Server(...) as server: + # with server.auth.sign_in(auth): + # ... + self.close() diff --git a/test/test_thread_safety.py b/test/test_thread_safety.py new file mode 100644 index 000000000..378d500e1 --- /dev/null +++ b/test/test_thread_safety.py @@ -0,0 +1,292 @@ +import threading +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest +import requests +import requests_mock + +import tableauserverclient as TSC + +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +SIGN_IN_XML = TEST_ASSET_DIR / "auth_sign_in.xml" + +NUM_THREADS = 8 +CALLS_PER_THREAD = 5 + + +@pytest.fixture(scope="function") +def server() -> TSC.Server: + return TSC.Server("http://test", False) + + +@pytest.fixture(scope="function") +def signed_in_server(server: TSC.Server) -> TSC.Server: + 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) + server.auth.sign_in(TSC.TableauAuth("testuser", "password", site_id="Samples")) + return server + + +def test_each_thread_gets_its_own_session(server: TSC.Server) -> None: + main_session = server.session + assert server.session is main_session # stable within a thread + + # Hold strong references to the session objects (not just id()s, which can + # be reused once a thread's session is garbage collected) and keep all + # threads alive at the same time behind a barrier. + barrier = threading.Barrier(NUM_THREADS) + results: dict[str, requests.Session] = {} + lock = threading.Lock() + + def worker(name: str) -> None: + barrier.wait() + first = server.session + assert server.session is first # stable within the worker thread too + with lock: + results[name] = first + + threads = [threading.Thread(target=worker, args=(f"t{i}",)) for i in range(NUM_THREADS)] + for t in threads: + t.start() + for t in threads: + t.join() + + sessions = list(results.values()) + [main_session] + # every thread saw a distinct session object + assert len({id(s) for s in sessions}) == NUM_THREADS + 1 + + +def test_session_factory_called_once_per_thread() -> None: + lock = threading.Lock() + created: list[requests.Session] = [] + + def counting_factory() -> requests.Session: + session = requests.Session() + with lock: + created.append(session) + return session + + server = TSC.Server("http://test", False, session_factory=counting_factory) + # constructing the server creates the constructing thread's session + assert len(created) == 1 + + barrier = threading.Barrier(NUM_THREADS) + + def worker() -> None: + barrier.wait() + for _ in range(CALLS_PER_THREAD): + server.session # repeated access must not create new sessions + + threads = [threading.Thread(target=worker) for _ in range(NUM_THREADS)] + for t in threads: + t.start() + for t in threads: + t.join() + + # one factory call for the main thread + exactly one per worker thread, + # despite CALLS_PER_THREAD accesses in each worker + assert len(created) == NUM_THREADS + 1 + + +def test_sign_out_invalidates_sessions_of_all_threads(signed_in_server: TSC.Server) -> None: + server = signed_in_server + before_main = server.session + + barrier = threading.Barrier(2) + worker_sessions: dict[str, int] = {} + + def worker() -> None: + worker_sessions["before"] = id(server.session) + barrier.wait() # let the main thread sign out + barrier.wait() + worker_sessions["after"] = id(server.session) + + t = threading.Thread(target=worker) + t.start() + barrier.wait() # worker has cached its session + with requests_mock.mock() as m: + m.post(server.auth.baseurl + "/signout", text="") + server.auth.sign_out() + barrier.wait() # worker re-reads its session + t.join() + + # both the main thread and the worker thread got fresh sessions + assert server.session is not before_main + assert worker_sessions["after"] != worker_sessions["before"] + assert not server.is_signed_in() + + +def test_concurrent_api_calls_use_per_thread_sessions(signed_in_server: TSC.Server) -> None: + server = signed_in_server + response_xml = (TEST_ASSET_DIR / "user_get_empty.xml").read_text() + + with requests_mock.mock() as m: + m.get(server.users.baseurl, text=response_xml) + + def worker() -> int: + for _ in range(CALLS_PER_THREAD): + _, pagination_item = server.users.get() + assert pagination_item.total_available == 0 + return id(server.session) + + with ThreadPoolExecutor(max_workers=NUM_THREADS) as executor: + session_ids = list(executor.map(lambda _: worker(), range(NUM_THREADS))) + + # every request was actually made + assert m.call_count == NUM_THREADS * CALLS_PER_THREAD + # every request carried the shared auth token + assert all(r.headers["x-tableau-auth"] == server.auth_token for r in m.request_history) + + # the pool had NUM_THREADS workers; each distinct worker thread used a + # distinct session, and threads reused their session across tasks + assert 1 <= len(set(session_ids)) <= NUM_THREADS + + +def test_auth_state_is_set_atomically(server: TSC.Server) -> None: + """Readers must never observe a half-updated (site_id, user_id, token) state. + + The reader takes ONE snapshot of the immutable auth state and asserts + consistency within it. Asserting across two separate property reads + (server.auth_token then server.site_id) would be a bug in the test: a + writer can legitimately complete a full swap between the two reads, + which free-threaded (no-GIL) builds surface readily. Individual + property reads are each internally consistent; only the snapshot + guarantees a consistent multi-field view. + """ + stop = threading.Event() + errors: list[Exception] = [] + + def reader() -> None: + while not stop.is_set(): + try: + state = server._auth_state + if state.auth_token is not None: + # all fields come from the same snapshot; a mismatched + # pair means a writer published a partial update + assert (state.auth_token, state.site_id, state.user_id) in ( + ("token-a", "site-a", "user-a"), + ("token-b", "site-b", "user-b"), + ) + # single property reads must be internally consistent too: + # either a value or NotSignedInError, never None + try: + assert server.auth_token is not None + except TSC.server.endpoint.exceptions.NotSignedInError: + pass # signed out at the moment of the read; that's fine + except Exception as e: # pragma: no cover - only on failure + errors.append(e) + stop.set() + + readers = [threading.Thread(target=reader) for _ in range(4)] + for t in readers: + t.start() + try: + for _ in range(500): + server._set_auth("site-a", "user-a", "token-a", "url-a") + server._set_auth("site-b", "user-b", "token-b", "url-b") + server._clear_auth() + finally: + stop.set() + for t in readers: + t.join() + + assert errors == [] + + +class _TrackingSession(requests.Session): + def __init__(self) -> None: + super().__init__() + self.closed = False + + def close(self) -> None: + self.closed = True + super().close() + + +def _tracking_server() -> tuple[TSC.Server, list["_TrackingSession"]]: + created: list[_TrackingSession] = [] + lock = threading.Lock() + + def factory() -> requests.Session: + session = _TrackingSession() + with lock: + created.append(session) + return session + + return TSC.Server("http://test", False, session_factory=factory), created + + +def test_close_closes_sessions_of_all_threads() -> None: + server, created = _tracking_server() + barrier = threading.Barrier(NUM_THREADS) + + def worker() -> None: + barrier.wait() + server.session + + threads = [threading.Thread(target=worker) for _ in range(NUM_THREADS)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(created) == NUM_THREADS + 1 # workers + constructing thread + assert not any(s.closed for s in created) + + server.close() + assert all(s.closed for s in created) + + # the server remains usable after close: a fresh session is created + reopened = server.session + assert isinstance(reopened, _TrackingSession) + assert not reopened.closed + assert reopened not in created[: NUM_THREADS + 1] or len(created) == NUM_THREADS + 2 + + +def test_close_does_not_sign_out(signed_in_server: TSC.Server) -> None: + server = signed_in_server + token = server.auth_token + server.close() + # close() is transport-level only; auth state is untouched + assert server.is_signed_in() + assert server.auth_token == token + + +def test_context_manager_closes_on_exit() -> None: + server, created = _tracking_server() + with server as entered: + assert entered is server + server.session + assert len(created) == 1 + assert all(s.closed for s in created) + + +def test_private_session_attribute_shim() -> None: + # backwards compatibility: external code reads and assigns + # Server._session (the pre-thread-safety attribute) + server, created = _tracking_server() + assert server._session is server.session # alias for this thread's session + + injected = _TrackingSession() + server._session = injected + assert server.session is injected # this thread now uses the injection + + # injection is per-thread: another thread still gets a factory session + other: dict[str, requests.Session] = {} + + def worker() -> None: + other["session"] = server.session + + t = threading.Thread(target=worker) + t.start() + t.join() + assert other["session"] is not injected + assert isinstance(other["session"], _TrackingSession) + + # close() reaches the injected session too + server.close() + assert injected.closed From 1e8669fc3f3f85d41bf66c61f12c70ab328fc430 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:44:19 -0700 Subject: [PATCH 209/219] chore(deps): bump actions/github-script from 7 to 9 (#1878) Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 9. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v7...v9) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/link-issues-to-prs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/link-issues-to-prs.yml b/.github/workflows/link-issues-to-prs.yml index 26f55715e..1ab69132f 100644 --- a/.github/workflows/link-issues-to-prs.yml +++ b/.github/workflows/link-issues-to-prs.yml @@ -26,7 +26,7 @@ jobs: link: runs-on: ubuntu-latest steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@v9 with: script: | const pr = context.payload.pull_request; From d63ff9dc5f77fd0c79077dc073a049de293f64c4 Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 10 Sep 2026 12:29:11 -0700 Subject: [PATCH 210/219] docs: numpy-style docstrings on Favorites endpoint (15 methods) (#1855) * docs: numpy-style docstrings on Favorites endpoint (15 methods) Adds a class-level docstring plus 15 method docstrings on Favorites (get, add_favorite, 6x add_favorite_, delete_favorite, 6x delete_favorite_). Content ported from docs/api-ref.md's Favorites section on gh-pages so that the Sphinx pipeline in #1832 will produce equivalent output once wired up. Part of the api-ref -> Sphinx migration; see the migration audit report for the full list of methods still needing docstrings. Favorites was the largest single chunk in the "needs_docstring" bucket (15/41 methods). No behavior change. Docs only. * docs: address fresh-eyes review on Favorites docstrings - Class docstring: add missing "collections" FavoriteType (7, not 6). - add_favorite: drop misleading #add_workbook_to_favorites anchor on polymorphic method; link to Favorites Methods page root. - add_favorite_metric: add REST API link (#add_metric_to_favorites). - delete_favorite: add REST API link; polymorphic, so page root. - delete_favorite_metric: add REST API link; no per-type anchor exists, so page root. - delete_favorite_flow: fix anchor drift (#delete_flow_from_favorites -> #ref_delete_flow_from_favorites). --- .../server/endpoint/favorites_endpoint.py | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index 228792e14..abe9e3e0c 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -17,6 +17,18 @@ class Favorites(Endpoint): + """Get, add, and remove favorites for a user. + + Favorites can be workbooks, views, datasources, flows, projects, metrics, + or collections. Retrieved favorites are stored on the target ``UserItem`` + object as a dictionary keyed by content type (e.g. ``"workbooks"``, + ``"views"``, ``"datasources"``, ``"flows"``, ``"projects"``, ``"metrics"``, + ``"collections"``), where each value is a list of the corresponding item + objects. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_favorites.htm + """ + @property def baseurl(self) -> str: return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/favorites" @@ -24,6 +36,33 @@ def baseurl(self) -> str: # Gets all favorites @api(version="2.5") def get(self, user_item: UserItem, req_options: RequestOptions | None = None) -> None: + """Populate the favorites on the specified user. + + After calling this method, the favorites are available through + ``user_item.favorites``, keyed by content type. + + REST API: `Get Favorites for User `_ + + Parameters + ---------- + user_item : UserItem + The user for whom to retrieve favorites. The user's ``id`` attribute + must be set. + + req_options : RequestOptions, optional + Request options such as page size and page number. + + Returns + ------- + None + Favorites are populated on ``user_item.favorites``. + + Examples + -------- + >>> server.favorites.get(user_item) + >>> for workbook in user_item.favorites["workbooks"]: + ... print(workbook.name) + """ logger.info(f"Querying all favorites for user {user_item.name}") url = f"{self.baseurl}/{user_item.id}" server_response = self.get_request(url, req_options) @@ -33,6 +72,32 @@ def get(self, user_item: UserItem, req_options: RequestOptions | None = None) -> @api(version="3.15") def add_favorite(self, user_item: UserItem, content_type: str, item: TableauItem) -> "Response": + """Add a content item of any supported type to the user's favorites. + + Type-specific helpers (``add_favorite_workbook``, ``add_favorite_view``, + etc.) exist for each individual content type; this method is the + polymorphic entry point. + + REST API: `Favorites Methods `_ + + Parameters + ---------- + user_item : UserItem + The user to add the favorite for. + + content_type : str + The type of content as a string (e.g. ``"workbook"``, ``"view"``, + ``"datasource"``, ``"flow"``, ``"project"``, ``"metric"``). + + item : TableauItem + The content item to favorite. Must have ``id`` and ``name`` + attributes. + + Returns + ------- + requests.Response + The server response. + """ url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_request(item.id, content_type, item.name) server_response = self.put_request(url, add_req) @@ -41,6 +106,22 @@ def add_favorite(self, user_item: UserItem, content_type: str, item: TableauItem @api(version="2.0") def add_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: + """Add a workbook to the user's favorites. + + REST API: `Add Workbook to Favorites `_ + + Parameters + ---------- + user_item : UserItem + The user to add the favorite for. + + workbook_item : WorkbookItem + The workbook to add to favorites. + + Returns + ------- + None + """ url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_workbook_req(workbook_item.id, workbook_item.name) server_response = self.put_request(url, add_req) @@ -48,6 +129,22 @@ def add_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem @api(version="2.0") def add_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: + """Add a view to the user's favorites. + + REST API: `Add View to Favorites `_ + + Parameters + ---------- + user_item : UserItem + The user to add the favorite for. + + view_item : ViewItem + The view to add to favorites. + + Returns + ------- + None + """ url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_view_req(view_item.id, view_item.name) server_response = self.put_request(url, add_req) @@ -55,6 +152,22 @@ def add_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: @api(version="2.3") def add_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: + """Add a datasource to the user's favorites. + + REST API: `Add Data Source to Favorites `_ + + Parameters + ---------- + user_item : UserItem + The user to add the favorite for. + + datasource_item : DatasourceItem + The datasource to add to favorites. + + Returns + ------- + None + """ url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_datasource_req(datasource_item.id, datasource_item.name) server_response = self.put_request(url, add_req) @@ -62,6 +175,22 @@ def add_favorite_datasource(self, user_item: UserItem, datasource_item: Datasour @api(version="3.1") def add_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: + """Add a project to the user's favorites. + + REST API: `Add Project to Favorites `_ + + Parameters + ---------- + user_item : UserItem + The user to add the favorite for. + + project_item : ProjectItem + The project to add to favorites. + + Returns + ------- + None + """ url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_project_req(project_item.id, project_item.name) server_response = self.put_request(url, add_req) @@ -69,6 +198,22 @@ def add_favorite_project(self, user_item: UserItem, project_item: ProjectItem) - @api(version="3.3") def add_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: + """Add a flow to the user's favorites. + + REST API: `Add Flow to Favorites `_ + + Parameters + ---------- + user_item : UserItem + The user to add the favorite for. + + flow_item : FlowItem + The flow to add to favorites. + + Returns + ------- + None + """ url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_flow_req(flow_item.id, flow_item.name) server_response = self.put_request(url, add_req) @@ -76,6 +221,22 @@ def add_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: @api(version="3.3") def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: + """Add a metric to the user's favorites. + + REST API: `Add Metric to Favorites `_ + + Parameters + ---------- + user_item : UserItem + The user to add the favorite for. + + metric_item : MetricItem + The metric to add to favorites. + + Returns + ------- + None + """ url = f"{self.baseurl}/{user_item.id}" add_req = RequestFactory.Favorite.add_request(metric_item.id, Resource.Metric, metric_item.name) server_response = self.put_request(url, add_req) @@ -93,42 +254,167 @@ def add_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> N @api(version="3.15") def delete_favorite(self, user_item: UserItem, content_type: Resource, item: TableauItem) -> None: + """Remove a content item of any supported type from the user's favorites. + + Type-specific helpers (``delete_favorite_workbook``, + ``delete_favorite_view``, etc.) exist for each individual content + type; this method is the polymorphic entry point. + + REST API: `Favorites Methods `_ + + Parameters + ---------- + user_item : UserItem + The user to remove the favorite from. + + content_type : Resource + The ``Resource`` type of the content (e.g. ``Resource.Workbook``, + ``Resource.View``). + + item : TableauItem + The content item to remove from favorites. Must have an ``id`` + attribute. + + Returns + ------- + None + + Examples + -------- + >>> server.favorites.delete_favorite(user_item, TSC.Resource.Workbook, workbook_item) + """ url = f"{self.baseurl}/{user_item.id}/{content_type}/{item.id}" logger.info(f"Removing favorite {content_type}({item.id}) for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.0") def delete_favorite_workbook(self, user_item: UserItem, workbook_item: WorkbookItem) -> None: + """Remove a workbook from the user's favorites. + + REST API: `Delete Workbook from Favorites `_ + + Parameters + ---------- + user_item : UserItem + The user to remove the favorite from. + + workbook_item : WorkbookItem + The workbook to remove from favorites. + + Returns + ------- + None + """ url = f"{self.baseurl}/{user_item.id}/workbooks/{workbook_item.id}" logger.info(f"Removing favorite workbook {workbook_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.0") def delete_favorite_view(self, user_item: UserItem, view_item: ViewItem) -> None: + """Remove a view from the user's favorites. + + REST API: `Delete View from Favorites `_ + + Parameters + ---------- + user_item : UserItem + The user to remove the favorite from. + + view_item : ViewItem + The view to remove from favorites. + + Returns + ------- + None + """ url = f"{self.baseurl}/{user_item.id}/views/{view_item.id}" logger.info(f"Removing favorite view {view_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="2.3") def delete_favorite_datasource(self, user_item: UserItem, datasource_item: DatasourceItem) -> None: + """Remove a datasource from the user's favorites. + + REST API: `Delete Data Source from Favorites `_ + + Parameters + ---------- + user_item : UserItem + The user to remove the favorite from. + + datasource_item : DatasourceItem + The datasource to remove from favorites. + + Returns + ------- + None + """ url = f"{self.baseurl}/{user_item.id}/datasources/{datasource_item.id}" logger.info(f"Removing favorite {datasource_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.1") def delete_favorite_project(self, user_item: UserItem, project_item: ProjectItem) -> None: + """Remove a project from the user's favorites. + + REST API: `Delete Project from Favorites `_ + + Parameters + ---------- + user_item : UserItem + The user to remove the favorite from. + + project_item : ProjectItem + The project to remove from favorites. + + Returns + ------- + None + """ url = f"{self.baseurl}/{user_item.id}/projects/{project_item.id}" logger.info(f"Removing favorite project {project_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.3") def delete_favorite_flow(self, user_item: UserItem, flow_item: FlowItem) -> None: + """Remove a flow from the user's favorites. + + REST API: `Delete Flow from Favorites `_ + + Parameters + ---------- + user_item : UserItem + The user to remove the favorite from. + + flow_item : FlowItem + The flow to remove from favorites. + + Returns + ------- + None + """ url = f"{self.baseurl}/{user_item.id}/flows/{flow_item.id}" logger.info(f"Removing favorite flow {flow_item.id} for user (ID: {user_item.id})") self.delete_request(url) @api(version="3.15") def delete_favorite_metric(self, user_item: UserItem, metric_item: MetricItem) -> None: + """Remove a metric from the user's favorites. + + REST API: `Favorites Methods `_ + + Parameters + ---------- + user_item : UserItem + The user to remove the favorite from. + + metric_item : MetricItem + The metric to remove from favorites. + + Returns + ------- + None + """ url = f"{self.baseurl}/{user_item.id}/metrics/{metric_item.id}" logger.info(f"Removing favorite metric {metric_item.id} for user (ID: {user_item.id})") self.delete_request(url) From 3113794fb74b11e8ad108cdb51705e1569022399 Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 10 Sep 2026 12:29:15 -0700 Subject: [PATCH 211/219] docs: clarify username formats, ProjectLeader-as-group, and filter special-char behavior (#1854) * docs: clarify username formats, ProjectLeader-as-group, and filter special-char behavior Bundles three long-standing docs gaps flagged in stale issues: #993 - users.add example used a fake short username with no explanation that `name` is the server auth identifier (email on Cloud, SAM@Domain on AD-backed Server), not the person's display name. Rewrote the users.add docstring intro to describe the required formats per auth scheme, split the example into a Cloud invocation and an AD invocation, and added matching guidance to UserItem.name. #1067 - projects.update_permissions had no examples, so callers had no guide for the common "assign group as Project Leader" case. Added an example that shows constructing a PermissionsRule with grantee= and capability=ProjectLeader, plus a note that update_permissions is a full replacement (call populate_permissions first if preserving existing rules). #1200 - the REST filter grammar treats ',', '&', ':', '[' and ']' as delimiters and the server does not support escaping, so `filter(name="T(L-F,SZ&V-MY)")` fails with 400065 for any name containing those characters. Documented the constraint on the Filter class docstring and on QuerySet.filter, along with the working wildcard workaround (Equals with '*' substituted). No behavior change. Docs only. Closes #993, #1067, #1200. * docs: cite wildcard-filter server version requirement The Equals-operator wildcard workaround for special characters requires Tableau Cloud May 2023 or Tableau Server 2022.1.14 (per the REST API "Filtering and Sorting" docs). Older servers reject `*` as a literal, so readers on those need to know before copying the example. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: ASCII em dashes, drop dead docs link, soften AD claim Three fresh-eyes findings: - Replace Unicode em dashes (U+2014) in filter.py and users_endpoint.py docstrings with ASCII `--` or `;`, matching the repo's ASCII-only convention that other docstrings follow. - query.py referenced docs/filter-sort.md, which lives on the gh-pages branch and does not exist in an installed package. Swap for the published URL so a `help()` reader can actually reach the doc. - Soften the AD username claim in users_endpoint.py: a bare SAMAccountName's resolution depends on the AD configuration, so "typically will not resolve" was stated more strongly than the surrounding server-behavior guidance warranted. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- tableauserverclient/models/user_item.py | 11 ++++- .../server/endpoint/projects_endpoint.py | 26 +++++++++++ .../server/endpoint/users_endpoint.py | 34 ++++++++++++--- tableauserverclient/server/filter.py | 43 +++++++++++++++++++ tableauserverclient/server/query.py | 13 ++++++ 5 files changed, 120 insertions(+), 7 deletions(-) diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index f906fa8ec..85f0d96f1 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -33,7 +33,16 @@ class UserItem: Parameters ---------- name: str - The name of the user. + The username used to authenticate the user, NOT the person's display + name (that is ``fullname``). The required format depends on the site's + authentication scheme: + + - Tableau Cloud: the sign-in email address (e.g. ``user@example.com``). + - Local authentication (on-prem Tableau Server): any unique username + for the site (e.g. ``jsmith``). + - Active Directory: the fully-qualified AD username, typically + ``SAMAccountName@FullyQualifiedDomain`` (e.g. + ``jsmith@corp.example.com``), or the User Principal Name (UPN). site_role: str The role of the user on the site. diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index edc6ef670..e0b529bc0 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -293,6 +293,32 @@ def update_permissions(self, item: ProjectItem, rules: list[PermissionsRule]) -> ------- list[PermissionsRule] Returns the updated list of permissions rules. + + Examples + -------- + Assign a group as Project Leader on a project. ``ProjectLeader`` is + the capability that grants the group the ability to publish and + manage content in the project as if they were the project owner. + + >>> import tableauserverclient as TSC + >>> server = TSC.Server('https://SERVERURL') + >>> # Login to the server + + >>> project = next(p for p in TSC.Pager(server.projects) if p.name == 'Marketing') + >>> group = next(g for g in TSC.Pager(server.groups) if g.name == 'Marketing Leads') + + >>> rule = TSC.PermissionsRule( + ... grantee=group, + ... capabilities={ + ... TSC.Permission.Capability.ProjectLeader: TSC.Permission.Mode.Allow, + ... }, + ... ) + >>> server.projects.update_permissions(project, [rule]) + + Note: ``update_permissions`` REPLACES the full permissions list on the + project. Any existing rules not included in the call will be removed. + To preserve existing rules, call ``populate_permissions(project)`` + first, then modify ``project.permissions`` and pass the full list. """ return self._permissions.update(item, rules) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index 099dc3531..a0b2011b8 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -254,11 +254,26 @@ def add(self, user_item: UserItem) -> UserItem: To add a new user to the site you need to first create a new user_item (from UserItem class). When you create a new user, you specify the name of the user and their site role. For Tableau Cloud, you also specify - the auth_setting attribute in your request. When you add user to - Tableau Cloud, the name of the user must be the email address that is - used to sign in to Tableau Cloud. After you add a user, Tableau Cloud - sends the user an email invitation. The user can click the link in the - invitation to sign in and update their full name and password. + the auth_setting attribute in your request. After you add a user, Tableau + Cloud sends the user an email invitation. The user can click the link in + the invitation to sign in and update their full name and password. + + The value of ``user_item.name`` is the username the server uses to + authenticate the user, NOT the person's display name. Its required + format depends on the site's authentication scheme: + + - Tableau Cloud: the user's email address (e.g. ``user@example.com``), + which is also what they sign in with. + - Local authentication (on-prem Tableau Server): any username unique to + the site (e.g. ``jsmith``). + - Active Directory: the fully-qualified AD username, either + ``SAMAccountName@FullyQualifiedDomain`` (e.g. + ``jsmith@corp.example.com``) or the User Principal Name (UPN) if AD + is configured to use UPNs. A bare ``SAMAccountName`` may not + resolve depending on how AD is configured. + + Set the person's display name via ``user_item.fullname``; it is a + separate attribute. Parameters ---------- @@ -333,9 +348,16 @@ def add(self, user_item: UserItem) -> UserItem: >>> server = TSC.Server('https://SERVERURL') >>> # Login to the server - >>> new_user = TSC.UserItem(name='new_user', site_role=TSC.UserItem.Role.Unlicensed) + >>> # Tableau Cloud: name must be the sign-in email address + >>> new_user = TSC.UserItem(name='jsmith@example.com', site_role=TSC.UserItem.Role.Explorer) + >>> new_user.auth_setting = TSC.UserItem.Auth.TableauIDWithMFA + >>> new_user.fullname = 'Jane Smith' >>> new_user = server.users.add(new_user) + >>> # Active Directory-backed Tableau Server: name is SAMAccountName@Domain + >>> ad_user = TSC.UserItem(name='jsmith@corp.example.com', site_role=TSC.UserItem.Role.Viewer) + >>> ad_user = server.users.add(ad_user) + """ url = self.baseurl logger.info(f"Add user {user_item.name}") diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index 2e64361ba..656957d87 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -5,6 +5,49 @@ class Filter: + """Represents a single Tableau REST API filter clause. + + A `Filter` is one entry in a `?filter=` query parameter and serializes as + ``::``. Use one filter per attribute, and pass + multiple filters via `RequestOptions.filter.add(...)` if you need to AND + conditions. + + Special characters in filter values + ---------------------------------- + The REST API's filter grammar treats several characters as delimiters. The + server does NOT support escaping them, so a value containing any of these + characters cannot be matched exactly with the `Equals` operator: + + - ``,`` -- separates values in an ``in`` list. + - ``&`` -- separates filter clauses. + - ``:`` -- separates field/operator/value. + - ``[`` and ``]`` -- bracket an ``in`` value list. + + Workaround for names containing these characters: use the ``Equals`` + operator with an asterisk substituted for the special character. Asterisk + behaves as a wildcard (starts-with, ends-with, and contains matching are + supported by the server; see the Tableau REST API docs for "Filtering + and Sorting"), so for example filtering a workbook named + ``T(L-F,SZ&V-MY) - PC`` can be found via ``name="T(L-F*SZ*V-MY) - PC"``. + Wildcard filtering requires Tableau Cloud May 2023 or Tableau Server + 2022.1.14 or later. Post-filter the result client-side to disambiguate + if multiple names could match. + + Parameters + ---------- + field : str + The field to filter on (e.g. ``RequestOptions.Field.Name``). + + operator : str + The operator to apply (e.g. ``RequestOptions.Operator.Equals``, + ``In``, ``GreaterThan``). + + value : str | int | bool | datetime | list + The value to compare against. Lists require operator ``In``. Datetimes + must be timezone-aware and serialize as ISO-8601 UTC. Bools serialize + as lowercase ``true``/``false``. + """ + def __init__(self, field, operator, value): self.field = field self.operator = operator diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index eb84248b5..a57e36a85 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -186,6 +186,19 @@ def page_size(self: Self) -> int: return self._pagination_item.page_size or self.request_options.pagesize def filter(self: Self, *invalid, page_size: int | None = None, **kwargs) -> Self: + """Add filter clauses to the queryset. + + Each keyword argument becomes one filter. Shorthand suffixes select + the operator (e.g. ``name__gt="A"`` -> operator ``GreaterThan``); a + bare keyword uses ``Equals``. See + https://tableau.github.io/server-client-python/docs/filter-sort for + the supported suffixes. + + Special-character caveat: values containing ``,``, ``&``, ``:``, ``[`` + or ``]`` cannot be matched exactly with ``Equals``; the REST filter + grammar treats them as delimiters and the server does not support + escaping. See ``Filter`` for details and the wildcard workaround. + """ if invalid: raise RuntimeError("Only accepts keyword arguments.") for kwarg_key, value in kwargs.items(): From a1a7ed4b09fb2f496eac3d14fe604f22a1762458 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 16 Sep 2026 17:30:22 -0700 Subject: [PATCH 212/219] docs: expand update_connection_auth sample header for Snowflake keypair (#1844) * feat: add sample for converting connection auth to Snowflake keypair Adds samples/update_connection_to_keypair.py demonstrating how to convert an existing Tableau Cloud datasource or workbook connection from username/password to Snowflake keypair authentication using the REST API v3.27 flow. Documents the prerequisite that the private key must first be saved under Site Settings -> Saved Credentials for Data Sources on the site. Refs #1602 Co-Authored-By: Claude Opus 4.7 (1M context) * Tighten embed_password comment and note flow-connection version The prior comment said embed_password=True "tells the server to use the pre-saved credential" which was vague. Server code (RestApiAppService updateConnectionImpl -> embedOAuthUserKeychainConnections) looks up the credential by (dbClass, username, role, auth) and binds it to the connection; without that pre-saved match, the update still writes 'auth-keypair' into metadata but subsequent extract refreshes fail. Spell that out so a copy-paste reader knows what "pre-saved" actually requires. Also add a note that flow connections gained the same capability in REST API v3.28 (datasources and workbook connections were v3.27). Co-Authored-By: Claude Opus 4.7 (1M context) * Fold keypair sample into existing update_connection_auth update_connection_auth.py already covers exactly this workflow (change a connection's auth_type + credentials via the update-connection endpoint). The prior keypair-specific sample added no new code path -- only Snowflake-keypair context that belongs in the shared sample's header. Delete the dedicated keypair sample; expand update_connection_auth's header comment to list common authentication_type values (including auth-keypair) and to spell out the pre-saved-credential prerequisite that applies to any embed_password=True conversion. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(samples): fix wire values, add security callout, add workbook REST ref Round-1 review of update_connection_auth.py header caught three real inaccuracies and one missing security note: - "Username Password" was not the wire value; it's "UsernamePassword" (confirmed against test/assets/datasource_connections_update_no_auth.xml, test/assets/workbook_update_connections_no_auth.xml, and the UsernamePassword assertions in test_datasource.py and test_workbook.py). - "oauth" had no grep-able backing as an authentication_type wire value on this endpoint. Dropped. - "sqlserver" is a connection type (type=), not an authentication_type wire value. Dropped. - v3.27 (datasource/workbook) and v3.28 (flow) version claim had no code backing -- @api decorators on update_connection are 2.3 for datasource/workbook and 3.3 for flow. Dropped rather than restating. - Added a SECURITY callout: datasource_password is a positional CLI argument, so private-key material passed there leaks to shell history, ps output, and audit logs. - Added the workbooks_and_views REST reference alongside the data-sources one; this sample updates both. - Softened the credential lookup-key description ("attributes including..." instead of asserting a specific tuple). Docs-only. samples/update_connection_auth.py parses cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) * Correct authentication_type wire values in the sample header The prior list included "UsernamePassword" (one word) as a canonical value. Grepping the monolith source of truth shows that string is not a valid authentication_type -- it does not appear in codegen/constants.data, in the connection.authentication values that Tableau Desktop writes into .tds/.twb, or in the REST reference docs. It is only present in TSC's hand-authored test fixtures, where it survives round-trip tests because TSC doesn't validate the string -- so a sample-user who copies it and posts to a real server hits an unhelpful rejection. Replaced with the actual documented set from codegen/constants.data and the public REST reference: - auth-user-pass (canonical username+password) - username-password (SAP HANA / Sybase ASE / NetWeaver / Denodo / Salesforce) - auth-keypair (Snowflake keypair) - oauth - auth-none - AD Service Principal - Azure AD Password TSC's `UsernamePassword` test fixtures are a separate follow-up -- they don't break tests, but they steer future readers wrong. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- samples/update_connection_auth.py | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/samples/update_connection_auth.py b/samples/update_connection_auth.py index 19134e60c..12019f7c7 100644 --- a/samples/update_connection_auth.py +++ b/samples/update_connection_auth.py @@ -1,3 +1,41 @@ +#### +# This script updates a single connection on a datasource or workbook to embed +# credentials. It's a generic authentication-change helper: the same code path +# works whether you're setting username+password or moving a Snowflake +# connection to keypair auth. +# +# Common authentication_type values (case-sensitive wire values): +# - "auth-user-pass" -- username + password (canonical) +# - "username-password" -- alternate spelling used by some connectors +# (SAP HANA, SAP Sybase ASE, SAP NetWeaver BW, +# Denodo, Salesforce) +# - "auth-keypair" -- Snowflake keypair (see prerequisite below) +# - "oauth" -- OAuth +# - "auth-none" -- no auth on the connection +# - "AD Service Principal" -- Azure AD Service Principal +# - "Azure AD Password" -- Azure AD username/password +# For the full list including connector-specific values, see the REST reference +# link below or query an existing connection to observe its wire value. +# +# SECURITY: datasource_password is a positional CLI argument. For keypair auth +# it carries the private-key material, which will leak into shell history, ps +# output, and audit logs on the machine running this script. Prefer supplying +# the key via an environment variable or stdin, or adapt this sample to read +# the key from a file that is protected by filesystem permissions. +# +# When embed_password=True the server binds the connection to a matching +# pre-saved credential on the site (looked up by attributes including username +# and connection class). For keypair-auth conversions this means the Snowflake +# private key MUST already be saved on the site under Site Settings -> Saved +# Credentials for Data Sources before running this script. If not, the update +# writes the new auth type into metadata but subsequent extract refreshes and +# connection tests fail because no bound credential is found. +# +# See: +# https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm#update_data_source_connection +# https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#update_workbook_connection +#### + import argparse import logging import tableauserverclient as TSC From b7134a182400a2ffb173fedbf5d6157fad997959 Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 17 Sep 2026 23:23:51 -0700 Subject: [PATCH 213/219] samples: credential helper, pagination fixes, and new jobs/subscriptions samples (#1843) * samples: add shared credential resolver that avoids the command line Introduces samples/_shared.py with resolve_credentials(args), which fills missing sign-in values from env vars (TABLEAU_SERVER, TABLEAU_TOKEN_NAME, etc.) or a .env-style file, and falls back to interactive getpass so secrets never touch shell history. CLI args still work for CI use. Wires the new helper into login.py, publish_workbook.py, and publish_datasource.py to establish the pattern; the remaining samples still accept the same CLI args and continue to work as before. Addresses tableau/server-client-python#1551 item 1. * samples: fix mispagination in samples that treated a single page as all Several samples called `server..get()` and named the result `all_workbooks`, `all_datasources`, etc. This only returns the first page (default 100 items); if the item of interest was not on that page it was silently missed and the sample failed with a "not found" message. Replace those calls with `TSC.Pager(server.)` so every page is walked. Where a total count was being displayed we still make one plain `.get()` up front so the total_available field is available without paging through the whole site twice. Also corrects an unrelated typo in getting_started/3_hello_universe.py where the "workbooks" section actually queried datasources. Addresses tableau/server-client-python#1551 item 2 (and #1531). * samples: add list_jobs and manage_subscriptions for coverage gaps The existing samples cover workbooks, datasources, schedules, extracts, projects, users, groups, favorites, and webhooks, but there was no sample for two frequently asked-about endpoints: * list_jobs.py -- lists background jobs (extract refreshes, publishes, flow runs, etc.), demonstrating the .filter() queryset with date/status/type filters and the wait_for_job helper. * manage_subscriptions.py -- list/create/delete site subscriptions, demonstrating the SubscriptionItem + Target pattern and paginated listing with TSC.Pager. Both samples use the new samples/_shared.py credential resolver so the sign-in pattern matches the rest of the samples. Addresses tableau/server-client-python#1551 item 3. * samples: align sign-in short flags with tabcmd Restore -t for --site, -u for --username, -p for --password; drop short flags on --token-name and --token-value. This matches tabcmd's canonical short flags in tabcmd/execution/parent_parser.py so users running both tools have one convention to remember. The initial refactor picked new short flags without noticing that the old samples/login.py already followed tabcmd's convention (-p was --password, -t was --site). Reassigning -p to --token-name meant `python login.py -p ` silently sent the password as a token name. Co-Authored-By: Claude Opus 4.7 (1M context) * feat: expose refreshExtractTriggered on SubscriptionItem (#1658) The Tableau REST API supports a `refreshExtractTriggered="true"` attribute on subscription payloads that makes the subscription fire when its referenced schedule's extract refresh completes, rather than on the schedule's time trigger. On Tableau Cloud, this is the wire form of an "On Extract Refresh" subscription. TSC never exposed this attribute; users trying to create these subscriptions were passing `schedule_id=None` and hitting a confusing wire error deep in the endpoint layer. Changes: - `SubscriptionItem.on_extract_refresh(...)` classmethod factory constructs a subscription with an extract-refresh schedule id and the flag set. - `refresh_extract_triggered` exposed as a property with a docstring covering the two ways the server surprises callers (server rejects True with a non-extract schedule; server silently clears the flag when a schedule change is included in an update). - `Subscriptions.create()` and `.update()` now raise `ValueError` up front when `schedule_id` is missing, so the wire error becomes an actionable client-side message. - `create_req` emits `refreshExtractTriggered="true"` only when set; `update_req` emits both true and false so callers can turn the flag off on an existing subscription. - `_parse_element` reads the attribute back into the property; parse continues to accept inline-schedule responses (schedule_id=None). Tests cover: factory sets flag + schedule id; default false; create_req emit-when-set/omit-when-false; update_req always emits; parse round-trip for both true and missing; parse of inline-schedule responses; create() and update() reject missing schedule_id. Related to #1658. Co-Authored-By: Claude Opus 4.7 (1M context) * Address fresh-eyes review on refreshExtractTriggered subscriptions - Docstring on `refresh_extract_triggered` now warns about the manual- build update() footgun: because every subscriptions.update() payload carries the attribute, a caller who builds a fresh SubscriptionItem locally, stamps _id, and updates will silently flip an existing on-extract-refresh subscription off. Fetch first. - Soften create()'s "schedule_id is required" error so someone who just forgot to set schedule_id on a time-based subscription doesn't get steered exclusively toward SubscriptionItem.on_extract_refresh(...); the factory is now mentioned as a conditional pointer. - __init__'s schedule_id parameter is now typed str | None, matching the real state: _parse_element sets it to None on inline-schedule responses. Drop the two `# type: ignore` markers in test/test_subscription.py that were papering over the earlier lie. - create_req asserts schedule_id non-None to satisfy mypy after the parameter widening; subscriptions.create() already guards this path before request emission. - Add samples/create_extract_refresh_subscription.py demonstrating the full flow: sign in, resolve view/workbook and user by name, pick an extract-refresh schedule from the schedules list, build the subscription via on_extract_refresh(), post it. Highest-leverage discoverability artifact for callers searching "on extract refresh". - CHANGELOG entry. * samples: add shared credential resolver that avoids the command line Introduces samples/_shared.py with resolve_credentials(args), which fills missing sign-in values from env vars (TABLEAU_SERVER, TABLEAU_TOKEN_NAME, etc.) or a .env-style file, and falls back to interactive getpass so secrets never touch shell history. CLI args still work for CI use. Wires the new helper into login.py, publish_workbook.py, and publish_datasource.py to establish the pattern; the remaining samples still accept the same CLI args and continue to work as before. Addresses tableau/server-client-python#1551 item 1. * samples: fix mispagination in samples that treated a single page as all Several samples called `server..get()` and named the result `all_workbooks`, `all_datasources`, etc. This only returns the first page (default 100 items); if the item of interest was not on that page it was silently missed and the sample failed with a "not found" message. Replace those calls with `TSC.Pager(server.)` so every page is walked. Where a total count was being displayed we still make one plain `.get()` up front so the total_available field is available without paging through the whole site twice. Also corrects an unrelated typo in getting_started/3_hello_universe.py where the "workbooks" section actually queried datasources. Addresses tableau/server-client-python#1551 item 2 (and #1531). * samples: add list_jobs and manage_subscriptions for coverage gaps The existing samples cover workbooks, datasources, schedules, extracts, projects, users, groups, favorites, and webhooks, but there was no sample for two frequently asked-about endpoints: * list_jobs.py -- lists background jobs (extract refreshes, publishes, flow runs, etc.), demonstrating the .filter() queryset with date/status/type filters and the wait_for_job helper. * manage_subscriptions.py -- list/create/delete site subscriptions, demonstrating the SubscriptionItem + Target pattern and paginated listing with TSC.Pager. Both samples use the new samples/_shared.py credential resolver so the sign-in pattern matches the rest of the samples. Addresses tableau/server-client-python#1551 item 3. * samples: align sign-in short flags with tabcmd Restore -t for --site, -u for --username, -p for --password; drop short flags on --token-name and --token-value. This matches tabcmd's canonical short flags in tabcmd/execution/parent_parser.py so users running both tools have one convention to remember. The initial refactor picked new short flags without noticing that the old samples/login.py already followed tabcmd's convention (-p was --password, -t was --site). Reassigning -p to --token-name meant `python login.py -p ` silently sent the password as a token name. Co-Authored-By: Claude Opus 4.7 (1M context) * samples: fix argparse blocker + 7 bugs, add JWT + on-extract-refresh Round of fixes for the sample-scripts refactor after fresh-eyes review. Blocker: publish_workbook.py reused `-u` for --thumbnails-user-id while _shared.py add_common_arguments already binds `-u` to --username, so argparse raised ArgumentError on module load and the script would not start. Renamed to `-U`. Real bugs: - _shared.py .env search now checks cwd, samples/, and repo root (in that order) so the docstring stops lying about "next to the sample or cwd." - resolve_credentials now gates input()/getpass on sys.stdin.isatty() as the docstring already promised, so piped/CI invocations no longer hang forever. - manage_subscriptions.py --attach-image switched to argparse.BooleanOptionalAction so users can actually pass --no-attach-image; the previous store_true+default=True made the flag a permanent True. - Header docstring in _shared.py no longer claims "no existing command line breaks" (which was false: -p migrated from --token-name to --password in an earlier commit). Documented the tabcmd-aligned short flags instead. - Corrected Python-version headers on login.py, list_jobs.py, manage_subscriptions.py, publish_workbook.py, refresh_tasks.py, move_workbook_sites.py, publish_datasource.py, and update_workbook_data_freshness_policy.py -- repo floor is 3.10 per pyproject.toml. - list_jobs._wait_for_job: reordered excepts so JobCancelledException (a subclass of JobFailedException) is caught first, otherwise cancelled jobs were reported as failed with the wrong exit code. - login.py sign-in banner now branches on JWTAuth as well, so JWT logins no longer print "Username: None". Header env-var list updated to include TABLEAU_JWT / TABLEAU_JWT_FILE. New JWT support: _shared.py add_common_arguments now exposes --jwt and --jwt-file, resolves TABLEAU_JWT / TABLEAU_JWT_FILE from env, reads a JWT file path into args.jwt during resolve_credentials, and returns TSC.JWTAuth from build_auth when a JWT is present. JWT takes priority over PAT and username/password. Extract-refresh subscription: manage_subscriptions.py create now accepts --on-extract-refresh, which calls SubscriptionItem.on_extract_refresh() to construct a subscription that fires when the referenced extract-refresh schedule completes (the flow introduced in #1861). Rebased this branch onto jac/subscription-refresh-extract-triggered so the flag lands on top of the new API without conflicts. Co-Authored-By: Claude Opus 4.7 (1M context) * samples: address remaining fresh-eyes review followups (#1843) Follow-up round of fixes on top of the fresh-eyes review pass. Each change maps to a specific finding from that review. Migrate stragglers to _shared (M4). Eight samples still had their own inline argparse and inline PersonalAccessTokenAuth construction: explore_{datasource,favorites,webhooks,workbook}.py, extracts.py, move_workbook_sites.py, refresh_tasks.py, and update_workbook_data_freshness_policy.py. All now call _shared.add_common_arguments and _shared.build_auth so the tabcmd-aligned short-flag convention (-s -t -u -p -l) applies uniformly and any future credential-handling fix lives in one place. Skip getting_started/3_hello_universe.py: intentionally a hardcoded starter with no argparse, aimed at teaching new users to edit the source directly. Different pedagogy from the CLI samples. Fix explore_favorites empty-site handling (L8). The favorite-datasource add and delete calls used to run unconditionally with my_datasource initialized to None, so on an empty site the sample failed partway. Both calls are now guarded (add inside the existing `if all_datasource_items:` block, delete under a new `if my_datasource is not None:` check). Drop verify=False TLS bypass (L11). Removed http_options={"verify": False} from publish_workbook.py and the equivalent server.add_http_options({"verify": False}) pattern from extracts.py and update_workbook_data_freshness_policy.py. A sample teaching users to bypass TLS validation is the wrong first impression; TSC defaults to verify=True, which is what a paved-path deployment expects. Users on self-signed dev servers can still set the option at their own call site. Delete dead _shared.sign_in() helper (L9). It was not called by any migrated sample: they all use resolve_credentials + build_auth + `with server.auth.sign_in(auth):` for the auto-signout context manager. The helper did not compose with `with` because it returned a Server object rather than a context manager. Co-Authored-By: Claude Opus 4.7 (1M context) * Defer subscription refreshExtractTriggered to #1861 Restore subscription_item, subscriptions_endpoint, request_factory, and test_subscription to origin/development state, and drop the matching "On Extract Refresh subscriptions" bullet from CHANGELOG's Unreleased section. That work is being landed via #1861 so it does not need to ride along in this samples-focused PR. Leaves this PR as a pure samples/CHANGELOG-free contribution: shared credential resolver, pagination fixes, list_jobs, manage_subscriptions, and the small samples cleanups already staged. Co-Authored-By: Claude Opus 4.7 (1M context) * Drop create_extract_refresh_subscription sample, defer to #1861 samples/create_extract_refresh_subscription.py depends on SubscriptionItem.on_extract_refresh(), which is added by #1861 and was already removed from this PR's diff along with the rest of the refreshExtractTriggered work. #1861's branch already carries the same sample byte-identical. Co-Authored-By: Claude Opus 4.7 (1M context) * Address Copilot + fresh-eyes review on samples Copilot round-2 (2026-09-17) findings: - _shared.py: build_auth now validates args.server so non-TTY callers hit a clear ValueError instead of TSC.Server(None, ...) downstream. - explore_favorites.py: favorite-delete cleanup moved inside the `with server.auth.sign_in(...)` block; each delete guarded by `if my_workbook is not None:` etc. to match the add-side. - update_workbook_data_freshness_policy.py: all_workbooks[2] -> [0] with a follow-up comment; argparse description corrected. Fresh-eyes findings this pass caught: - manage_subscriptions.py: drop the --on-extract-refresh path entirely (docstring, code branch, argparse flag). That relies on SubscriptionItem.on_extract_refresh which lands with #1861 and is not present on this branch after the earlier subscription revert. - extracts.py: `all_workbooks[3]` -> `[0]`; guard the create/delete branches against `wb is None` so `--datasource ... --create` no longer AttributeErrors on `wb.name`; --workbook/--datasource made mutually exclusive to match how the sample is meant to be used. - publish_datasource.py: raise a clear "no project named X" error when the project filter matches zero; fix a swapped-argument print so the datasource id no longer prefixes the "Datasource published" message with the timestamp reading as the id. - refresh_tasks.py: subparsers marked required=True so running the sample with no subcommand prints usage instead of AttributeError. Not fixed in this PR (pre-existing, flagged for follow-up): - explore_workbook.py:120-149 has three latent bugs (missing `=` on `changed`, `c` referenced outside its loop, `--delete` not in this script's argparse). This PR only adds the _shared import; the bugs pre-date it and belong in a separate cleanup PR. Co-Authored-By: Claude Opus 4.7 (1M context) * Address Copilot rounds 3/4 remaining findings Round 3 nits: - publish_workbook.py: `-U` comment now says the conflict would happen when the parser is built at run time inside main(), not "at import". - update_workbook_data_freshness_policy.py: `first_page` was assigned but unused; renamed to `_`. Round 4 (after last push): - _shared.py: added the two missing partial-credential branches so a user who supplies TABLEAU_TOKEN_VALUE without TABLEAU_TOKEN_NAME is prompted for the (non-secret) name, and one who supplies a password without a username is prompted for the username. Previously both fell through to the "fully unspecified" PAT prompt. - _shared.py: `--jwt` help text now describes the JWT > PAT > username/password precedence build_auth actually implements, rather than claiming a mutual exclusion that argparse doesn't enforce. - list_jobs.py: `--hours` now passes the tz-aware datetime directly to QuerySet.filter(created_at__gte=...) rather than `.isoformat()`. TSC serializes it as UTC with a trailing Z; the raw isoformat string could produce `+00:00` offsets that older Tableau Server versions reject. Co-Authored-By: Claude Opus 4.7 (1M context) * Fix publish_workbook empty-projects guard + move_workbook_sites help text publish_workbook.py: mirror the empty-projects guard that landed in publish_datasource.py earlier this PR. A --project filter that matches zero results would have slipped past `if len(projects) > 1` and hit `projects[0].id` with an IndexError; now raises a clear ValueError. move_workbook_sites.py: argparse description used implicit string concatenation with missing spaces at the boundaries, so --help printed "...from thedefault project of the default site tothe default project of another site." Reflowed as a parenthesized single-string so the sentence reads correctly. Co-Authored-By: Claude Opus 4.7 (1M context) * Update publish_datasource header comment for build_auth Comment claimed the sample "uses personal access tokens" for sign-in, but the file now delegates to build_auth() which supports JWT, PAT, and username/password. Reword so users see the full auth surface. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- samples/_shared.py | 267 ++++++++++++++++++ samples/explore_datasource.py | 37 +-- samples/explore_favorites.py | 67 +++-- samples/explore_webhooks.py | 30 +- samples/explore_workbook.py | 40 ++- samples/extracts.py | 64 ++--- samples/getting_started/3_hello_universe.py | 2 +- samples/list_jobs.py | 141 +++++++++ samples/login.py | 79 ++---- samples/manage_subscriptions.py | 135 +++++++++ samples/move_workbook_sites.py | 36 +-- samples/publish_datasource.py | 59 ++-- samples/publish_workbook.py | 39 +-- samples/refresh_tasks.py | 31 +- .../update_workbook_data_freshness_policy.py | 48 ++-- 15 files changed, 750 insertions(+), 325 deletions(-) create mode 100644 samples/_shared.py create mode 100644 samples/list_jobs.py create mode 100644 samples/manage_subscriptions.py diff --git a/samples/_shared.py b/samples/_shared.py new file mode 100644 index 000000000..d24cdddc9 --- /dev/null +++ b/samples/_shared.py @@ -0,0 +1,267 @@ +#### +# Shared helpers for the sample scripts in this directory. +# +# The most important thing here is `resolve_credentials`, which lets samples +# accept a Tableau server URL, site, and credentials from three sources: +# +# 1. Command-line arguments (useful for CI, but note that these end up in +# shell history and process listings, so avoid them for real secrets). +# 2. Environment variables. We look for a `.env` file in the current +# working directory, in the samples/ directory, and at the repository +# root, in that order, and load whichever we find first -- only the +# standard `KEY=value` lines, no external dependency required. +# 3. Interactive prompts. Missing values are asked for on stdin when +# stdin is a terminal; secrets are read with `getpass.getpass` so they +# are not echoed. In non-interactive contexts (CI, piped input) we skip +# the prompts and let `build_auth` raise instead of hanging on `input()`. +# +# CLI args take precedence, then environment, then interactive prompt. +# This lets a user set defaults in a `.env` file and override individual +# values on the command line. +# +# Sign-in short flags follow the tabcmd convention (-s server, -t site, +# -u username, -p password). --token-name and --token-value do not have +# short flags because tabcmd does not either and re-using a letter here +# would silently accept a token as a password on old command lines. +#### + +from __future__ import annotations + +import argparse +import getpass +import os +import sys +from pathlib import Path +from typing import Iterable + +import tableauserverclient as TSC + +# Recognized environment variable names, in the order we look them up. +# Older samples used TABLEAU_SERVER etc; keep those working as aliases. +_ENV_ALIASES: dict[str, tuple[str, ...]] = { + "server": ("TABLEAU_SERVER", "SERVER"), + "site": ("TABLEAU_SITE", "SITE"), + "token_name": ("TABLEAU_TOKEN_NAME", "TOKEN_NAME"), + "token_value": ("TABLEAU_TOKEN_VALUE", "TOKEN_VALUE"), + "username": ("TABLEAU_USERNAME", "USERNAME"), + "password": ("TABLEAU_PASSWORD", "PASSWORD"), + "jwt": ("TABLEAU_JWT", "JWT"), + "jwt_file": ("TABLEAU_JWT_FILE", "JWT_FILE"), +} + + +def add_common_arguments(parser: argparse.ArgumentParser) -> None: + """Add the sign-in and logging arguments used by every sample. + + Short flags follow the tabcmd convention: -s server, -t site, + -u username, -p password, -l logging-level. --token-name / + --token-value and --jwt / --jwt-file intentionally have no short + flag; re-using letters here risked silently accepting a token as + a password on scripts that pre-date the shared helper. All args + are optional; missing values are pulled from the environment or + prompted for interactively. + """ + parser.add_argument("--server", "-s", help="server address (env: TABLEAU_SERVER)") + parser.add_argument("--site", "-t", help="site content URL (env: TABLEAU_SITE)") + parser.add_argument( + "--token-name", + help="name of the personal access token used to sign into the server " "(env: TABLEAU_TOKEN_NAME)", + ) + parser.add_argument( + "--token-value", + help="value of the personal access token used to sign into the server " + "(env: TABLEAU_TOKEN_VALUE). Prefer the env var or interactive prompt over the " + "command line so the secret does not land in shell history.", + ) + parser.add_argument( + "--username", + "-u", + help="username to sign into the server (env: TABLEAU_USERNAME). Only used if " + "no personal access token or JWT is supplied.", + ) + parser.add_argument( + "--password", + "-p", + help="password (env: TABLEAU_PASSWORD). Prefer the env var or interactive " "prompt over the command line.", + ) + parser.add_argument( + "--jwt", + help="encoded JSON Web Token for Connected-App sign-in (env: TABLEAU_JWT). " + "When multiple auth options are set, JWT wins over PAT and PAT wins over " + "username/password (see build_auth in _shared.py and JWTAuth in the docs).", + ) + parser.add_argument( + "--jwt-file", + help="path to a file whose contents are the encoded JWT (env: TABLEAU_JWT_FILE). " + "Useful for pipelines that mint a JWT into a file rather than an env var.", + ) + parser.add_argument( + "--env-file", + help="path to a .env-style file with KEY=value lines to load. If omitted, " + ".env is looked for in the current directory, the samples/ directory, and " + "the repository root, and the first one found is loaded.", + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + + +def _load_env_file(path: Path) -> None: + """Very small `.env` loader: `KEY=value` per line, `#` for comments. + + We do not want a runtime dependency on python-dotenv for the samples, + so this parses just the common cases. Existing env vars are not + overwritten -- a value already in `os.environ` wins. + """ + try: + text = path.read_text(encoding="utf-8") + except OSError: + return + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip("'\"") + if key and key not in os.environ: + os.environ[key] = value + + +def _first_env(names: Iterable[str]) -> str | None: + for name in names: + val = os.environ.get(name) + if val: + return val + return None + + +def _candidate_env_paths() -> list[Path]: + """Locations we check for a .env file, in priority order. + + cwd first (so the invoker can override), then the directory that holds + this shared module (samples/), then the repository root one level up. + """ + module_dir = Path(__file__).resolve().parent + return [ + Path.cwd() / ".env", + module_dir / ".env", + module_dir.parent / ".env", + ] + + +def resolve_credentials(args: argparse.Namespace, *, allow_prompt: bool = True) -> None: + """Fill in server/site/credential values on `args` from env or prompt. + + Precedence for each field: existing value on `args` > environment variable + > interactive prompt (only when allow_prompt is true AND stdin is a TTY). + + Pass `allow_prompt=False`, or run with stdin redirected (CI, piped input), + to skip the prompts entirely; the caller should then verify the fields it + needs are set, or let `build_auth` raise a clear ValueError. + """ + # Load `.env` file if one is requested or available. + env_file = getattr(args, "env_file", None) + if env_file: + _load_env_file(Path(env_file)) + else: + for candidate in _candidate_env_paths(): + if candidate.is_file(): + _load_env_file(candidate) + break + + # For each field, prefer the CLI arg, then env, then prompt. + for field, env_names in _ENV_ALIASES.items(): + current = getattr(args, field, None) + if current: + continue + env_val = _first_env(env_names) + if env_val: + setattr(args, field, env_val) + + # If a JWT file was provided, read its contents into args.jwt (unless the + # caller also passed --jwt directly, in which case the direct value wins). + jwt_file = getattr(args, "jwt_file", None) + if jwt_file and not getattr(args, "jwt", None): + try: + args.jwt = Path(jwt_file).read_text(encoding="utf-8").strip() + except OSError as exc: + raise SystemExit(f"Could not read --jwt-file {jwt_file!r}: {exc}") from exc + + # Skip prompting entirely if the caller opted out or stdin is not a + # terminal. `input()` on a closed/piped stdin either blocks forever or + # raises EOFError; neither is what a scripted invocation wants. + if not allow_prompt or not sys.stdin.isatty(): + return + + # Prompt for what's still missing. We only prompt for the pieces we + # actually need: server URL, and one of JWT / token / username+password. + if not getattr(args, "server", None): + args.server = input("Tableau server URL: ").strip() + + # Site is optional (empty string is the default site) so we don't prompt. + + has_jwt = bool(getattr(args, "jwt", None)) + has_token = bool(getattr(args, "token_name", None) and getattr(args, "token_value", None)) + has_user = bool(getattr(args, "username", None) and getattr(args, "password", None)) + + if has_jwt or has_token or has_user: + return + + # Partial info supplied -- fill in the matching missing piece. Handle + # both directions of each pair so a user who set only the secret half + # (e.g. TABLEAU_TOKEN_VALUE without TABLEAU_TOKEN_NAME) is prompted for + # the non-secret half, not asked to re-type the secret they already have. + if getattr(args, "token_name", None) and not getattr(args, "token_value", None): + args.token_value = getpass.getpass(f"Personal access token value for '{args.token_name}': ") + return + if getattr(args, "token_value", None) and not getattr(args, "token_name", None): + args.token_name = input("Personal access token name: ").strip() + return + if getattr(args, "username", None) and not getattr(args, "password", None): + args.password = getpass.getpass(f"Password for '{args.username}': ") + return + if getattr(args, "password", None) and not getattr(args, "username", None): + args.username = input("Username: ").strip() + return + + # Fully unspecified: default to PAT since that's what the docs recommend. + print("No credentials found in args or environment. Sign in with a personal access token.") + print("(Set TABLEAU_TOKEN_NAME / TABLEAU_TOKEN_VALUE in your env or a .env file to skip this prompt.)") + args.token_name = input("Personal access token name: ").strip() + args.token_value = getpass.getpass("Personal access token value: ") + + +def build_auth(args: argparse.Namespace) -> TSC.TableauAuth | TSC.PersonalAccessTokenAuth | TSC.JWTAuth: + """Return the appropriate auth object based on what's set on `args`. + + Priority is JWT > PAT > username/password: a script that has a JWT + minted for a specific session should never fall back to a longer-lived + credential if the JWT-adjacent fields were left set by accident. + + Also validates that `--server` is set. `resolve_credentials` skips prompting + in non-interactive contexts (CI, piped stdin), so a missing server URL would + otherwise reach `TSC.Server(None, ...)` and fail with a confusing error; + catching it here gives the caller a clear message. + """ + if not getattr(args, "server", None): + raise ValueError( + "No Tableau server URL. Provide --server, set the TABLEAU_SERVER env " + "var, or run in an interactive terminal to be prompted." + ) + site = getattr(args, "site", None) or "" + if getattr(args, "jwt", None): + return TSC.JWTAuth(args.jwt, site_id=site) + if getattr(args, "token_name", None) and getattr(args, "token_value", None): + return TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=site) + if getattr(args, "username", None) and getattr(args, "password", None): + return TSC.TableauAuth(args.username, args.password, site_id=site) + raise ValueError( + "No usable credentials found. Provide --jwt/--jwt-file, " + "--token-name/--token-value, --username/--password, or set the " + "corresponding env vars." + ) diff --git a/samples/explore_datasource.py b/samples/explore_datasource.py index c9f35d5be..88ac5ec31 100644 --- a/samples/explore_datasource.py +++ b/samples/explore_datasource.py @@ -14,38 +14,28 @@ import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + def main(): parser = argparse.ArgumentParser(description="Explore datasource functions supported by the Server API.") - # 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)", - ) + add_common_arguments(parser) # Options specific to this sample parser.add_argument("--publish", metavar="FILEPATH", help="path to datasource to publish") parser.add_argument("--download", metavar="FILEPATH", help="path to save downloaded datasource") 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) + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) - # SIGN IN - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Query projects for use when demonstrating publishing and updating - all_projects, pagination_item = server.projects.get() - default_project = next((project for project in all_projects if project.is_default()), None) + # Query projects for use when demonstrating publishing and updating. + # Use TSC.Pager (or `.all()` / `.filter()`) to iterate every page; + # a raw `server.projects.get()` only returns the first page. + default_project = next((project for project in TSC.Pager(server.projects) if project.is_default()), None) # Publish datasource if publish flag is set (-publish, -p) if args.publish: @@ -59,9 +49,12 @@ def main(): else: print("Publish failed. Could not find the default project.") - # Gets all datasource items - all_datasources, pagination_item = server.datasources.get() + # Gets all datasource items. `.get()` returns only one page; use + # TSC.Pager to iterate every page. The first response also gives us + # the total_available count without paging through everything. + first_page, pagination_item = server.datasources.get() print(f"\nThere are {pagination_item.total_available} datasources on site: ") + all_datasources = list(TSC.Pager(server.datasources)) print([datasource.name for datasource in all_datasources]) if all_datasources: diff --git a/samples/explore_favorites.py b/samples/explore_favorites.py index f199522ed..aecd06685 100644 --- a/samples/explore_favorites.py +++ b/samples/explore_favorites.py @@ -5,30 +5,19 @@ import tableauserverclient as TSC from tableauserverclient.models import Resource +from _shared import add_common_arguments, build_auth, resolve_credentials + def main(): parser = argparse.ArgumentParser(description="Explore favoriting functions supported by the Server API.") - # 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)", - ) + add_common_arguments(parser) 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) + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) - # SIGN IN - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): print(server) @@ -43,8 +32,9 @@ def main(): server.favorites.get(user) print(user.favorites) - # get list of workbooks - all_workbook_items, pagination_item = server.workbooks.get() + # get list of workbooks. `.get()` only returns one page; use + # TSC.Pager to iterate every workbook on the site. + all_workbook_items = list(TSC.Pager(server.workbooks)) if all_workbook_items is not None and len(all_workbook_items) > 0: my_workbook = all_workbook_items[0] server.favorites.add_favorite(user, Resource.Workbook, all_workbook_items[0]) @@ -59,25 +49,32 @@ def main(): server.favorites.add_favorite_view(user, my_view) print(f"View added to favorites. View Name: {my_view.name}, View ID: {my_view.id}") - all_datasource_items, pagination_item = server.datasources.get() + all_datasource_items = list(TSC.Pager(server.datasources)) if all_datasource_items: my_datasource = all_datasource_items[0] - server.favorites.add_favorite_datasource(user, my_datasource) - print( - "Datasource added to favorites. Datasource Name: {}, Datasource ID: {}".format( - my_datasource.name, my_datasource.id + server.favorites.add_favorite_datasource(user, my_datasource) + print( + "Datasource added to favorites. Datasource Name: {}, Datasource ID: {}".format( + my_datasource.name, my_datasource.id + ) ) - ) - server.favorites.delete_favorite_workbook(user, my_workbook) - print(f"Workbook deleted from favorites. Workbook Name: {my_workbook.name}, Workbook ID: {my_workbook.id}") + # Cleanup — delete the favorites we just created. Must stay inside the + # `with server.auth.sign_in(...)` block; a delete after sign-out fails + # with a not-signed-in error. Each guard mirrors the "add" check above + # so we do not try to delete a favorite we never created. + if my_workbook is not None: + server.favorites.delete_favorite_workbook(user, my_workbook) + print(f"Workbook deleted from favorites. Workbook Name: {my_workbook.name}, Workbook ID: {my_workbook.id}") - server.favorites.delete_favorite_view(user, my_view) - print(f"View deleted from favorites. View Name: {my_view.name}, View ID: {my_view.id}") + if my_view is not None: + server.favorites.delete_favorite_view(user, my_view) + print(f"View deleted from favorites. View Name: {my_view.name}, View ID: {my_view.id}") - server.favorites.delete_favorite_datasource(user, my_datasource) - print( - "Datasource deleted from favorites. Datasource Name: {}, Datasource ID: {}".format( - my_datasource.name, my_datasource.id - ) - ) + if my_datasource is not None: + server.favorites.delete_favorite_datasource(user, my_datasource) + print( + "Datasource deleted from favorites. Datasource Name: {}, Datasource ID: {}".format( + my_datasource.name, my_datasource.id + ) + ) diff --git a/samples/explore_webhooks.py b/samples/explore_webhooks.py index f25c41849..64a73a430 100644 --- a/samples/explore_webhooks.py +++ b/samples/explore_webhooks.py @@ -11,37 +11,25 @@ import argparse import logging -import os.path import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + def main(): parser = argparse.ArgumentParser(description="Explore webhook functions supported by the Server API.") - # 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)", - ) + add_common_arguments(parser) # Options specific to this sample parser.add_argument("--create", help="create a webhook") parser.add_argument("--delete", help="delete a webhook", action="store_true") 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) + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) - # SIGN IN - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Create webhook if create flag is set (-create, -c) @@ -54,9 +42,11 @@ def main(): new_webhook = server.webhooks.create(new_webhook) print(f"Webhook created. ID: {new_webhook.id}") - # Gets all webhook items - all_webhooks, pagination_item = server.webhooks.get() + # Gets all webhook items. `.get()` returns only one page; use + # TSC.Pager to iterate every webhook on the site. + first_page, pagination_item = server.webhooks.get() print(f"\nThere are {pagination_item.total_available} webhooks on site: ") + all_webhooks = list(TSC.Pager(server.webhooks)) print([webhook.name for webhook in all_webhooks]) if all_webhooks: diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index d537f21d6..033dbe594 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -15,21 +15,12 @@ import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + def main(): parser = argparse.ArgumentParser(description="Explore workbook functions supported by the Server API.") - # 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)", - ) + add_common_arguments(parser) # Options specific to this sample parser.add_argument("--publish", metavar="FILEPATH", help="path to workbook to publish") parser.add_argument("--download", metavar="FILEPATH", help="path to save downloaded workbook") @@ -42,19 +33,18 @@ def main(): 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) + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) - # SIGN IN - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Publish workbook if publish flag is set (-publish, -p) overwrite_true = TSC.Server.PublishMode.Overwrite if args.publish: - all_projects, pagination_item = server.projects.get() - default_project = next((project for project in all_projects if project.is_default()), None) + # Use TSC.Pager rather than a raw `.get()` because `.get()` only + # returns the first page of results. + default_project = next((project for project in TSC.Pager(server.projects) if project.is_default()), None) if default_project is not None: new_workbook = TSC.WorkbookItem(default_project.id) @@ -63,9 +53,11 @@ def main(): else: print("Publish failed. Could not find the default project.") - # Gets all workbook items - all_workbooks, pagination_item = server.workbooks.get() + # Gets all workbook items. Note that `.get()` only returns the first + # page of results; use TSC.Pager to iterate every page. + first_page, pagination_item = server.workbooks.get() print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + all_workbooks = list(TSC.Pager(server.workbooks)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: @@ -123,9 +115,9 @@ def main(): f.write(sample_workbook.preview_image) print(f"\nDownloaded preview image of workbook to {os.path.abspath(args.preview_image)}") - # get custom views - cvs, _ = server.custom_views.get() - for c in cvs: + # Get custom views. `.get()` only returns the first page; + # use TSC.Pager to iterate every custom view on the site. + for c in TSC.Pager(server.custom_views): print(c) # for the last custom view in the list diff --git a/samples/extracts.py b/samples/extracts.py index d9289452a..dfc4541df 100644 --- a/samples/extracts.py +++ b/samples/extracts.py @@ -5,42 +5,31 @@ import argparse import logging -import os.path import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + def main(): parser = argparse.ArgumentParser(description="Explore extract functions supported by the Server API.") - # Common options; please keep those in sync across all samples - parser.add_argument("--server", "-s", help="server address") - parser.add_argument("--site", help="site name") - parser.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server") - parser.add_argument("--token-value", "-tv", 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)", - ) + add_common_arguments(parser) # Options specific to this sample parser.add_argument("--create", action="store_true") parser.add_argument("--delete", action="store_true") parser.add_argument("--refresh", action="store_true") - parser.add_argument("--workbook", required=False) - parser.add_argument("--datasource", required=False) + # --workbook / --datasource are mutually exclusive; if neither is passed we + # fall back to picking the first workbook on the site (see below). + target = parser.add_mutually_exclusive_group() + target.add_argument("--workbook") + target.add_argument("--datasource") 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) + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) - # SIGN IN - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=False) - server.add_http_options({"verify": False}) - server.use_server_version() + tableau_auth = build_auth(args) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): wb = None ds = None @@ -53,19 +42,25 @@ def main(): if ds is None: raise ValueError(f"Datasource not found for id {args.datasource}") else: - # Gets all workbook items - all_workbooks, pagination_item = server.workbooks.get() + # Gets all workbook items. `.get()` returns only the first page, + # so we use TSC.Pager to iterate every page. + first_page, pagination_item = server.workbooks.get() print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + all_workbooks = list(TSC.Pager(server.workbooks)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: - # Pick one workbook from the list - wb = all_workbooks[3] + # Fall back to the first workbook on the site. For a real run, + # pass --workbook for a workbook you know has an extract. + wb = all_workbooks[0] if args.create: - print("create extract on wb ", wb.name) - extract_job = server.workbooks.create_extract(wb, includeAll=True) - print(extract_job) + if wb is None: + print("no workbook selected to create an extract on") + else: + print(f"create extract on workbook {wb.name}") + extract_job = server.workbooks.create_extract(wb, includeAll=True) + print(extract_job) if args.refresh: extract_job = None @@ -81,9 +76,12 @@ def main(): print(extract_job) if args.delete: - print("delete extract on wb ", wb.name) - jj = server.workbooks.delete_extract(wb) - print(jj) + if wb is None: + print("no workbook selected to delete an extract from") + else: + print(f"delete extract on workbook {wb.name}") + jj = server.workbooks.delete_extract(wb) + print(jj) if __name__ == "__main__": diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py index a2c4301d0..057298b4d 100644 --- a/samples/getting_started/3_hello_universe.py +++ b/samples/getting_started/3_hello_universe.py @@ -40,7 +40,7 @@ def main(): for project in projects: print(project.name) - workbooks, pagination = server.datasources.get() + workbooks, pagination = server.workbooks.get() if workbooks: print(f"{pagination.total_available} workbooks") print(workbooks[0]) diff --git a/samples/list_jobs.py b/samples/list_jobs.py new file mode 100644 index 000000000..b72d6a7b5 --- /dev/null +++ b/samples/list_jobs.py @@ -0,0 +1,141 @@ +#### +# This script demonstrates how to list background jobs on a Tableau site +# and (optionally) wait for a specific job to finish. +# +# Background jobs are created when you run an extract refresh, publish +# asynchronously, run a flow, delete a site asynchronously, and so on. +# See the REST API "Query Jobs" reference for the full list of job types. +# +# Examples: +# +# # List every job on the site, most recent first. +# python samples/list_jobs.py +# +# # Only jobs from the last 24 hours. +# python samples/list_jobs.py --hours 24 +# +# # Only in-progress refresh_extracts jobs. +# python samples/list_jobs.py --status InProgress --type refresh_extracts +# +# # Wait for a specific job to finish. +# python samples/list_jobs.py --wait +# +# To run the script, you must have installed Python 3.10 or later. +#### + +import argparse +import datetime +import logging + +import tableauserverclient as TSC +from tableauserverclient.server.endpoint.exceptions import JobCancelledException, JobFailedException + +from _shared import add_common_arguments, build_auth, resolve_credentials + + +def main(): + parser = argparse.ArgumentParser(description="List background jobs on the site, or wait for one to finish.") + add_common_arguments(parser) + + parser.add_argument( + "--hours", + type=int, + help="Only show jobs created in the last N hours (uses the filter endpoint).", + ) + parser.add_argument( + "--status", + help="Filter by job status, e.g. Success, Failed, InProgress, Cancelled, Pending.", + ) + parser.add_argument( + "--type", + dest="job_type", + help="Filter by job type, e.g. refresh_extracts, publish, run_flow.", + ) + parser.add_argument( + "--wait", + metavar="JOB_ID", + help="Instead of listing, wait for the given job ID to complete and print the result.", + ) + parser.add_argument( + "--timeout", + type=float, + help="Max seconds to wait when --wait is used. Defaults to no timeout.", + ) + + args = parser.parse_args() + + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) + + tableau_auth = build_auth(args) + server = TSC.Server(args.server, use_server_version=True) + + with server.auth.sign_in(tableau_auth): + if args.wait: + _wait_for_job(server, args.wait, args.timeout) + return + + _list_jobs(server, args) + + +def _list_jobs(server, args): + """List jobs using the queryset filter API, which handles pagination for us.""" + + # `server.jobs.filter(...)` returns a QuerySet that is directly iterable + # and pages through the server automatically. This is the recommended + # way to iterate every job on the site -- do NOT use a raw + # `server.jobs.get()`, which only returns the first page. + query = server.jobs.filter() + + if args.hours is not None: + cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=args.hours) + # Filter operator suffixes: __gt / __gte / __lt / __lte / __in / __has + # See tableauserverclient.server.query.QuerySet for the full list. + # Pass the tz-aware datetime directly; QuerySet serializes it as UTC + # with a trailing `Z`, which older Tableau Server versions require. + # A raw isoformat() string can end up with a `+00:00` offset that + # those versions reject. + query = query.filter(created_at__gte=cutoff) + + if args.status: + query = query.filter(status=args.status) + + if args.job_type: + query = query.filter(job_type=args.job_type) + + # Newest first is usually what a human wants when scanning. + query = query.order_by("-created_at") + + printed = 0 + for job in query: + # BackgroundJobItem fields: id, type, status, created_at, started_at, ended_at, ... + print( + f"{job.id} {job.type or '-':<24} {job.status or '-':<12} " + f"created={job.created_at} ended={job.ended_at}" + ) + printed += 1 + + if printed == 0: + print("No jobs matched the given filters.") + + +def _wait_for_job(server, job_id, timeout): + """Poll a single job until it finishes, using the built-in helper.""" + try: + job = server.jobs.wait_for_job(job_id, timeout=timeout) + except JobCancelledException: + # JobCancelledException is a subclass of JobFailedException, so this + # branch must come first or cancelled jobs get reported as failed + # with the wrong exit code. + print(f"Job {job_id} was cancelled.") + raise SystemExit(2) + except JobFailedException as exc: + # The exception carries the failed JobItem so callers can inspect it. + print(f"Job {job_id} failed: notes={exc.job.notes}") + raise SystemExit(1) from exc + + print(f"Job {job_id} finished. finish_code={job.finish_code} notes={job.notes}") + + +if __name__ == "__main__": + main() diff --git a/samples/login.py b/samples/login.py index bc99385b3..13e05294f 100644 --- a/samples/login.py +++ b/samples/login.py @@ -1,83 +1,48 @@ #### # This script demonstrates how to log in to Tableau Server Client. # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 or later. +# +# Credentials can be supplied on the command line, from environment variables +# (TABLEAU_SERVER, TABLEAU_SITE, TABLEAU_TOKEN_NAME, TABLEAU_TOKEN_VALUE, +# TABLEAU_USERNAME, TABLEAU_PASSWORD, TABLEAU_JWT, TABLEAU_JWT_FILE), from a +# `.env` file in the current working directory (or samples/, or repo root), +# or interactively via getpass. Prefer env or a .env file over CLI args so +# secrets do not end up in your shell history. #### import argparse -import getpass import logging -import os import tableauserverclient as TSC - -def get_env(key): - if key in os.environ: - return os.environ[key] - return None +from _shared import add_common_arguments, build_auth, resolve_credentials # If a sample has additional arguments, then it should copy this code and insert them after the call to -# sample_define_common_options -# If it has no additional arguments, it can just call this method +# add_common_arguments. If it has no additional arguments, it can just call this method. def set_up_and_log_in(): parser = argparse.ArgumentParser(description="Logs in to the server.") - sample_define_common_options(parser) + add_common_arguments(parser) args = parser.parse_args() - if not args.server: - args.server = get_env("SERVER") - if not args.site: - args.site = get_env("SITE") - if not args.token_name: - args.token_name = get_env("TOKEN_NAME") - if not args.token_value: - args.token_value = get_env("TOKEN_VALUE") - args.logging_level = "debug" + + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) server = sample_connect_to_server(args) print(server.server_info.get()) print(server.server_address, "site:", server.site_id, "user:", server.user_id) -def sample_define_common_options(parser): - # Common options; please keep these in sync across all samples by copying or calling this method directly - parser.add_argument("--server", "-s", help="server address") - parser.add_argument("--site", "-t", help="site name") - auth = parser.add_mutually_exclusive_group(required=False) - auth.add_argument("--token-name", "-tn", help="name of the personal access token used to sign into the server") - auth.add_argument("--username", "-u", help="username to sign into the server") - - parser.add_argument("--token-value", "-tv", help="value of the personal access token used to sign into the server") - parser.add_argument("--password", "-p", help="value of the password 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)", - ) - - def sample_connect_to_server(args): - if args.username: - # Trying to authenticate using username and password. - password = args.password or getpass.getpass("Password: ") - - tableau_auth = TSC.TableauAuth(args.username, password, site_id=args.site) - print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nUsername: {args.username}") - + tableau_auth = build_auth(args) + if isinstance(tableau_auth, TSC.JWTAuth): + identifier = "JWT (Connected App)" + elif isinstance(tableau_auth, TSC.PersonalAccessTokenAuth): + identifier = f"Token name: {args.token_name}" else: - # Trying to authenticate using personal access tokens. - token = args.token_value or getpass.getpass("Personal Access Token: ") - - tableau_auth = TSC.PersonalAccessTokenAuth( - token_name=args.token_name, personal_access_token=token, site_id=args.site - ) - print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\nToken name: {args.token_name}") - - if not tableau_auth: - raise TabError("Did not create authentication object. Check arguments.") + identifier = f"Username: {args.username}" + print(f"\nSigning in...\nServer: {args.server}\nSite: {args.site}\n{identifier}") # Only set this to False if you are running against a server you trust AND you know why the cert is broken check_ssl_certificate = True @@ -85,8 +50,6 @@ def sample_connect_to_server(args): # Make sure we use an updated version of the rest apis, and pass in our cert handling choice server = TSC.Server(args.server, use_server_version=True, http_options={"verify": check_ssl_certificate}) server.auth.sign_in(tableau_auth) - server.version = "3.19" - return server diff --git a/samples/manage_subscriptions.py b/samples/manage_subscriptions.py new file mode 100644 index 000000000..e8d094db3 --- /dev/null +++ b/samples/manage_subscriptions.py @@ -0,0 +1,135 @@ +#### +# This script demonstrates how to list, create, and delete subscriptions +# on a Tableau site. +# +# A subscription pairs a user, a schedule, and a target (workbook or view); +# the user is emailed a snapshot of the target on each schedule tick. +# See the REST API "Subscriptions" reference for full details. +# +# Examples: +# +# # List every subscription on the site. +# python samples/manage_subscriptions.py list +# +# # Create a subscription for the signed-in user against a view + schedule. +# python samples/manage_subscriptions.py create \ +# --target-type view \ +# --target-id \ +# --schedule-id \ +# --subject "Daily sales snapshot" +# +# # Delete an existing subscription. +# python samples/manage_subscriptions.py delete --id +# +# To run the script, you must have installed Python 3.10 or later. +#### + +import argparse +import logging + +import tableauserverclient as TSC + +from _shared import add_common_arguments, build_auth, resolve_credentials + + +def handle_list(server, args): + """List every subscription on the site, iterating every page.""" + # `server.subscriptions.get()` returns only the first page. Pass the + # endpoint to TSC.Pager to iterate every subscription without hand- + # rolling pagination logic. + count = 0 + for sub in TSC.Pager(server.subscriptions): + print( + f"{sub.id} subject={sub.subject!r} " + f"user_id={sub.user_id} schedule_id={sub.schedule_id} target={sub.target}" + ) + count += 1 + if count == 0: + print("No subscriptions found on this site.") + + +def handle_create(server, args): + """Create a new subscription for the signed-in user (unless --user-id given).""" + user_id = args.user_id or server.user_id + if not user_id: + raise SystemExit("Could not determine user_id. Pass --user-id or ensure sign-in succeeded.") + + # The REST API expects lowercase content types ("workbook" or "view"). + target = TSC.Target(args.target_id, args.target_type.lower()) + + new_sub = TSC.SubscriptionItem( + subject=args.subject, + schedule_id=args.schedule_id, + user_id=user_id, + target=target, + ) + if args.message: + new_sub.message = args.message + new_sub.attach_image = args.attach_image + new_sub.attach_pdf = args.attach_pdf + + created = server.subscriptions.create(new_sub) + print(f"Created subscription {created.id} for user {created.user_id} against {created.target}") + + +def handle_delete(server, args): + """Delete a subscription by ID.""" + server.subscriptions.delete(args.id) + print(f"Deleted subscription {args.id}.") + + +def main(): + parser = argparse.ArgumentParser(description="List, create, and delete Tableau subscriptions.") + add_common_arguments(parser) + + subcommands = parser.add_subparsers(dest="command", required=True) + + list_p = subcommands.add_parser("list", help="List every subscription on the site.") + list_p.set_defaults(func=handle_list) + + create_p = subcommands.add_parser("create", help="Create a new subscription.") + create_p.add_argument("--target-type", required=True, choices=["Workbook", "View", "workbook", "view"]) + create_p.add_argument("--target-id", required=True, help="ID of the workbook or view to subscribe to.") + create_p.add_argument( + "--schedule-id", required=True, help="ID of the schedule to attach to (see create_schedules.py)." + ) + create_p.add_argument("--subject", required=True, help="Email subject line.") + create_p.add_argument("--message", help="Optional email body message.") + create_p.add_argument( + "--user-id", + help="User to subscribe. Defaults to the signed-in user.", + ) + # BooleanOptionalAction (Python 3.9+) gives us --attach-image / --no-attach-image + # so users can opt out of the default PNG snapshot. Same for the PDF pair for + # symmetry, even though its default is False. + create_p.add_argument( + "--attach-image", + action=argparse.BooleanOptionalAction, + default=True, + help="Attach a PNG snapshot (default: on; pass --no-attach-image to disable).", + ) + create_p.add_argument( + "--attach-pdf", + action=argparse.BooleanOptionalAction, + default=False, + help="Also attach a PDF snapshot (default: off).", + ) + create_p.set_defaults(func=handle_create) + + delete_p = subcommands.add_parser("delete", help="Delete a subscription by ID.") + delete_p.add_argument("--id", required=True, help="Subscription ID to delete.") + delete_p.set_defaults(func=handle_delete) + + args = parser.parse_args() + + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) + + tableau_auth = build_auth(args) + server = TSC.Server(args.server, use_server_version=True) + with server.auth.sign_in(tableau_auth): + args.func(server, args) + + +if __name__ == "__main__": + main() diff --git a/samples/move_workbook_sites.py b/samples/move_workbook_sites.py index e82c75cf9..c255d1dfa 100644 --- a/samples/move_workbook_sites.py +++ b/samples/move_workbook_sites.py @@ -4,7 +4,7 @@ # a workbook that matches a given name, download the workbook, # and then publish it to the destination site. # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 or later. #### import argparse @@ -14,37 +14,27 @@ import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + def main(): parser = argparse.ArgumentParser( - description="Move one workbook from the" - "default project of the default site to" - "the default project of another site." - ) - # 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)", + description=( + "Move one workbook from the default project of the default site " "to the default project of another site." + ) ) + add_common_arguments(parser) # Options specific to this sample parser.add_argument("--workbook-name", "-w", help="name of workbook to move") parser.add_argument("--destination-site", "-d", help="name of site to move workbook into") 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) + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) # Step 1: Sign in to both sites on server - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) source_server = TSC.Server(args.server) dest_server = TSC.Server(args.server) @@ -65,10 +55,10 @@ def main(): try: workbook_path = source_server.workbooks.download(all_workbooks[0].id, tmpdir) - # Step 4: Check if destination site exists, then sign in to the site - all_sites, pagination_info = source_server.sites.get() + # Step 4: Check if destination site exists, then sign in to the site. + # Use TSC.Pager because `.get()` only returns the first page of sites. found_destination_site = any( - True for site in all_sites if args.destination_site.lower() == site.content_url.lower() + args.destination_site.lower() == site.content_url.lower() for site in TSC.Pager(source_server.sites) ) if not found_destination_site: error = f"No site named {args.destination_site} found." diff --git a/samples/publish_datasource.py b/samples/publish_datasource.py index c674e6882..753f5a5f9 100644 --- a/samples/publish_datasource.py +++ b/samples/publish_datasource.py @@ -11,41 +11,29 @@ # For more information, refer to the documentations: # (https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_datasources.htm#publish_data_source) # -# For signing into server, this script uses personal access tokens. For -# more information on personal access tokens, refer to the documentations: +# Sign-in delegates to `build_auth()` in samples/_shared.py, which accepts +# JWT (Connected App), personal access token, or username + password (JWT +# wins over PAT wins over username/password when more than one is set). +# For PATs specifically, see: # (https://help.tableau.com/current/server/en-us/security_personal_access_tokens.htm) # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 or later. #### import argparse import logging -import os import tableauserverclient as TSC import tableauserverclient.datetime_helpers - -def get_env(key): - if key in os.environ: - return os.environ[key] - return None +from _shared import add_common_arguments, build_auth, resolve_credentials def main(): parser = argparse.ArgumentParser(description="Publish a datasource to server.") - # 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)", - ) + # Common options -- credentials come from CLI args, env vars, a .env file, + # or an interactive prompt. See samples/_shared.py. + add_common_arguments(parser) # Options specific to this sample parser.add_argument("--file", "-f", help="filepath to the datasource to publish") parser.add_argument("--project", help="Project within which to publish the datasource") @@ -56,30 +44,18 @@ def main(): parser.add_argument("--conn-oauth", help="connection is configured to use oAuth", action="store_true") args = parser.parse_args() - if not args.server: - args.server = get_env("SERVER") - if not args.site: - args.site = get_env("SITE") - if not args.token_name: - args.token_name = get_env("TOKEN_NAME") - if not args.token_value: - args.token_value = get_env("TOKEN_VALUE") - args.logging = "debug" - args.file = "C:/dev/tab-samples/5M.tdsx" - args.async_ = True + + resolve_credentials(args) # Ensure that both the connection username and password are provided, or none at all if (args.conn_username and not args.conn_password) or (not args.conn_username and args.conn_password): parser.error("Both the connection username and password must be provided") # Set logging level based on user input, or error by default - - _logger = logging.getLogger(__name__) - _logger.setLevel(logging.DEBUG) - _logger.addHandler(logging.StreamHandler()) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) # Sign in to server - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Empty project_id field will default the publish to the site's default project @@ -92,6 +68,8 @@ def main(): TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.project) ) projects = list(TSC.Pager(server.projects, req_options)) + if not projects: + raise ValueError(f"No project named {args.project!r} on this site.") if len(projects) > 1: raise ValueError("The project name is not unique") project_id = projects[0].id @@ -123,11 +101,8 @@ def main(): new_datasource, args.file, publish_mode, connection_credentials=new_conn_creds ) print( - ( - "{}Datasource published. Datasource ID: {}".format( - new_datasource.id, tableauserverclient.datetime_helpers.timestamp() - ) - ) + f"[{tableauserverclient.datetime_helpers.timestamp()}] " + f"Datasource published. Datasource ID: {new_datasource.id}" ) print("\t\tClosing connection") diff --git a/samples/publish_workbook.py b/samples/publish_workbook.py index 077ddaddd..b83ad48cd 100644 --- a/samples/publish_workbook.py +++ b/samples/publish_workbook.py @@ -11,7 +11,7 @@ # For more information, refer to the documentations on 'Publish Workbook' # (https://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm) # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 or later. #### import argparse @@ -20,24 +20,20 @@ import tableauserverclient as TSC from tableauserverclient import ConnectionCredentials, ConnectionItem +from _shared import add_common_arguments, build_auth, resolve_credentials + def main(): parser = argparse.ArgumentParser(description="Publish a workbook to server.") - # 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)", - ) + # Common options -- credentials come from CLI args, env vars, a .env file, + # or an interactive prompt. See samples/_shared.py. + add_common_arguments(parser) # Options specific to this sample group = parser.add_mutually_exclusive_group(required=False) - group.add_argument("--thumbnails-user-id", "-u", help="User ID to use for thumbnails") + # `-u` is already taken by --username in add_common_arguments; use `-U` here + # so argparse does not raise a conflicting-option-string error when the + # parser is built at run time (inside main()). + group.add_argument("--thumbnails-user-id", "-U", help="User ID to use for thumbnails") group.add_argument("--thumbnails-group-id", "-g", help="Group ID to use for thumbnails") parser.add_argument("--workbook-name", "-n", help="Name with which to publish the workbook") @@ -49,13 +45,15 @@ def main(): args = parser.parse_args() + resolve_credentials(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) # Step 1: 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, http_options={"verify": False}) + tableau_auth = build_auth(args) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): # Step2: Retrieve the project id, if a project name was passed if args.project is not None: @@ -64,13 +62,18 @@ def main(): TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, args.project) ) projects = list(TSC.Pager(server.projects, req_options)) + if not projects: + raise ValueError(f"No project named {args.project!r} on this site.") if len(projects) > 1: raise ValueError("The project name is not unique") project_id = projects[0].id else: # Get all the projects on server, then look for the default one. - all_projects, pagination_item = server.projects.get() - project_id = next((project for project in all_projects if project.is_default()), None).id + # Use TSC.Pager because `.get()` only returns the first page. + default_project = next((project for project in TSC.Pager(server.projects) if project.is_default()), None) + if default_project is None: + raise LookupError("The destination project could not be found.") + project_id = default_project.id connection1 = ConnectionItem() connection1.server_address = "mssql.test.com" diff --git a/samples/refresh_tasks.py b/samples/refresh_tasks.py index c95000898..2fd33a1ec 100644 --- a/samples/refresh_tasks.py +++ b/samples/refresh_tasks.py @@ -2,7 +2,7 @@ # This script demonstrates how to use the Tableau Server Client # to query extract refresh tasks and run them as needed. # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 or later. #### import argparse @@ -10,6 +10,8 @@ import tableauserverclient as TSC +from _shared import add_common_arguments, build_auth, resolve_credentials + def handle_run(server, args): task = server.tasks.get_by_id(args.id) @@ -17,8 +19,8 @@ def handle_run(server, args): def handle_list(server, _): - tasks, pagination = server.tasks.get() - for task in tasks: + # Use TSC.Pager to iterate every task; `.get()` returns only the first page. + for task in TSC.Pager(server.tasks): print(f"{task}") @@ -29,20 +31,9 @@ def handle_info(server, args): def main(): parser = argparse.ArgumentParser(description="Get all of the refresh tasks available on a server") - # 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)", - ) + add_common_arguments(parser) # Options specific to this sample - subcommands = parser.add_subparsers() + subcommands = parser.add_subparsers(dest="command", required=True) list_arguments = subcommands.add_parser("list") list_arguments.set_defaults(func=handle_list) @@ -57,12 +48,10 @@ def main(): 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) + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) - # SIGN IN - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + tableau_auth = build_auth(args) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): args.func(server, args) diff --git a/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py index c23e3717f..297335200 100644 --- a/samples/update_workbook_data_freshness_policy.py +++ b/samples/update_workbook_data_freshness_policy.py @@ -2,7 +2,7 @@ # This script demonstrates how to update workbook data freshness policy using the Tableau # Server Client. # -# To run the script, you must have installed Python 3.7 or later. +# To run the script, you must have installed Python 3.10 or later. #### @@ -12,47 +12,39 @@ import tableauserverclient as TSC from tableauserverclient import IntervalItem +from _shared import add_common_arguments, build_auth, resolve_credentials + def main(): - parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") - # 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)", + parser = argparse.ArgumentParser( + description="Update a workbook's data freshness policy across the supported schedule types." ) + add_common_arguments(parser) # Options specific to this sample: # This sample has no additional options, yet. If you add some, please add them here 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) + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) - tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) - server = TSC.Server(args.server, use_server_version=False) - server.add_http_options({"verify": False}) - server.use_server_version() + tableau_auth = build_auth(args) + server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): - # Get workbook - all_workbooks, pagination_item = server.workbooks.get() + # Get workbooks. `.get()` only returns the first page; iterate with + # TSC.Pager to see every workbook on the site. + _, pagination_item = server.workbooks.get() print(f"\nThere are {pagination_item.total_available} workbooks on site: ") + all_workbooks = list(TSC.Pager(server.workbooks)) print([workbook.name for workbook in all_workbooks]) if all_workbooks: - # Pick 1 workbook that has live datasource connection. - # Assuming 1st workbook met the criteria for sample purposes - # Data Freshness Policy is not available on extract & file-based datasource. - sample_workbook = all_workbooks[2] + # Pick 1 workbook that has a live datasource connection. Data + # freshness policy is not available on extract or file-based + # datasources, so this sample will print a warning below if the + # chosen workbook has none. Adjust the index (or add a lookup by + # name) for a workbook on your site with a live connection. + sample_workbook = all_workbooks[0] # Get more info from the workbook selected # Troubleshoot: if sample_workbook_extended.data_freshness_policy.option returns with AttributeError From 6b2bd09484231d9322136453ffec05ecf22cacc9 Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 18 Sep 2026 10:15:29 -0700 Subject: [PATCH 214/219] chore: use stdlib typing for Concatenate/ParamSpec/overload; align Black target-version (#1839) --- pyproject.toml | 2 +- tableauserverclient/server/endpoint/endpoint.py | 3 ++- tableauserverclient/server/endpoint/jobs_endpoint.py | 3 ++- tableauserverclient/server/request_factory.py | 5 +---- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9c058c150..3ccce45d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ version = {attr = "versioneer.get_version"} [tool.black] line-length = 120 -target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] +target-version = ['py310', 'py311', 'py312', 'py313', 'py314'] force-exclude = "tableauserverclient/bin/*" [tool.mypy] diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 64601fcbc..10d3cba84 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -2,7 +2,6 @@ import io import os from contextlib import closing -from typing_extensions import Concatenate, ParamSpec from urllib.parse import urljoin, urlparse from tableauserverclient import datetime_helpers as datetime @@ -13,8 +12,10 @@ from typing import ( Any, Callable, + Concatenate, Generic, Optional, + ParamSpec, TYPE_CHECKING, TypeVar, Union, diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 655b7cc6f..d790c036d 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,5 +1,6 @@ import logging -from typing_extensions import Self, overload +from typing import overload +from typing_extensions import Self from tableauserverclient.models import JobItem, BackgroundJobItem, PaginationItem diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index b94659ca6..3301b3b89 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,12 +1,9 @@ import xml.etree.ElementTree as ET -from typing import Any, Callable, TypeVar, TYPE_CHECKING +from typing import Any, Callable, Concatenate, ParamSpec, TypeVar, TYPE_CHECKING from collections.abc import Iterable -from typing_extensions import ParamSpec - from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata -from typing_extensions import Concatenate from tableauserverclient.models import * From 81fa0e31ddab213ef654649304de4d5808769e99 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 13 Oct 2026 17:27:45 -0700 Subject: [PATCH 215/219] samples(list_jobs): also print JobItem.status_notes alongside notes (#1883) PR #1852 (parse into JobItem.status_notes) added a structured list[dict] surface for modern job types, but this sample still only printed the legacy list[str] `notes` attribute. Modern jobs (extract refresh v2 shapes and later) surface their diagnostics via `status_notes`; the sample now prints both so running `list_jobs.py --wait ` against a real server exercises the #1852 parser end to end. Zero library changes. --- samples/list_jobs.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/samples/list_jobs.py b/samples/list_jobs.py index b72d6a7b5..1c9bd9e8d 100644 --- a/samples/list_jobs.py +++ b/samples/list_jobs.py @@ -131,10 +131,14 @@ def _wait_for_job(server, job_id, timeout): raise SystemExit(2) except JobFailedException as exc: # The exception carries the failed JobItem so callers can inspect it. - print(f"Job {job_id} failed: notes={exc.job.notes}") + # `notes` is the legacy list[str] surface; `status_notes` is the structured + # list[dict] shape emitted for modern job types (see JobItem docstring). + print(f"Job {job_id} failed: notes={exc.job.notes} status_notes={exc.job.status_notes}") raise SystemExit(1) from exc - print(f"Job {job_id} finished. finish_code={job.finish_code} notes={job.notes}") + print( + f"Job {job_id} finished. finish_code={job.finish_code}" f" notes={job.notes} status_notes={job.status_notes}" + ) if __name__ == "__main__": From c46e757d0f5e707089d29ea37501b25274bc3d59 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 13 Oct 2026 22:04:05 -0700 Subject: [PATCH 216/219] feat: expose JobFailedException and JobCancelledException at TSC top level (#1870) * feat: expose JobFailedException and JobCancelledException at the top level Both were previously reachable only via `tableauserverclient.server.endpoint.exceptions`, which is an internal module path. Callers waiting on a background job with `server.jobs.wait_for_job()` need to catch these to distinguish a failed job from a cancelled one (JobCancelledException is a subclass of JobFailedException, so the order in an except-chain matters), and reaching into a `server.endpoint` sub-package to do so is a bad pattern to teach. Re-export them alongside the other exception types already exposed at the top level (FailedSignInError, MissingRequiredFieldError, NotSignedInError, ServerResponseError). Existing internal imports in tests and endpoints remain unchanged; this is purely additive. Co-Authored-By: Claude Opus 4.7 (1M context) * Also expose FlowRunFailedException and FlowRunCancelledException at top level Same shape and same private-path problem as the Job exceptions this PR is already fixing. flow_runs.wait_for_flow_run() raises these; users need to catch them without importing from server.endpoint.exceptions. Add regression tests for both pairs so a future refactor of __init__.py can't silently break the public surface. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Brian Cantoni --- tableauserverclient/__init__.py | 8 ++++++++ tableauserverclient/server/__init__.py | 13 ++++++++++++- test/test_import_surface.py | 16 ++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 test/test_import_surface.py diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 7241f23ca..cb197d47c 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -67,6 +67,10 @@ RequestOptions, MissingRequiredFieldError, FailedSignInError, + FlowRunCancelledException, + FlowRunFailedException, + JobCancelledException, + JobFailedException, NotSignedInError, ServerResponseError, Filter, @@ -98,6 +102,8 @@ "FileuploadItem", "Filter", "FlowItem", + "FlowRunCancelledException", + "FlowRunFailedException", "FlowRunItem", "get_versions", "GroupItem", @@ -105,6 +111,8 @@ "HourlyInterval", "ImageRequestOptions", "IntervalItem", + "JobCancelledException", + "JobFailedException", "JobItem", "JWTAuth", "LinkedTaskFlowRunItem", diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 55288fdc9..44020419b 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -12,7 +12,14 @@ from tableauserverclient.server.sort import Sort from tableauserverclient.server.server import Server from tableauserverclient.server.pager import Pager -from tableauserverclient.server.endpoint.exceptions import FailedSignInError, NotSignedInError +from tableauserverclient.server.endpoint.exceptions import ( + FailedSignInError, + FlowRunCancelledException, + FlowRunFailedException, + JobCancelledException, + JobFailedException, + NotSignedInError, +) from tableauserverclient.server.endpoint import ( Auth, @@ -60,6 +67,10 @@ "Server", "Pager", "FailedSignInError", + "FlowRunCancelledException", + "FlowRunFailedException", + "JobCancelledException", + "JobFailedException", "NotSignedInError", "Auth", "CustomViews", diff --git a/test/test_import_surface.py b/test/test_import_surface.py new file mode 100644 index 000000000..d2069aa64 --- /dev/null +++ b/test/test_import_surface.py @@ -0,0 +1,16 @@ +import tableauserverclient as TSC +from tableauserverclient.server.endpoint import exceptions + + +def test_job_exceptions_at_top_level(): + assert TSC.JobFailedException is exceptions.JobFailedException + assert TSC.JobCancelledException is exceptions.JobCancelledException + assert "JobFailedException" in TSC.__all__ + assert "JobCancelledException" in TSC.__all__ + + +def test_flow_run_exceptions_at_top_level(): + assert TSC.FlowRunFailedException is exceptions.FlowRunFailedException + assert TSC.FlowRunCancelledException is exceptions.FlowRunCancelledException + assert "FlowRunFailedException" in TSC.__all__ + assert "FlowRunCancelledException" in TSC.__all__ From 7bb9852bffed901ce50db942ef945102ddefcb2d Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 13 Oct 2026 22:05:43 -0700 Subject: [PATCH 217/219] fix(samples): three latent bugs in explore_workbook.py (#1881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix three latent bugs in samples/explore_workbook.py Fresh-eyes review on #1843 (2026-09-17) surfaced three pre-existing bugs that the samples/_shared.py migration in that PR did not introduce but did make more visible: 1. Line 127 used a bare type annotation `changed: TSC.CustomViewItem( id=c.id, name=...)` instead of an assignment, so `changed` was never bound and the following `server.custom_views.update(changed)` would NameError. 2. `c` was defined inside the custom-views loop and then referenced outside it. On a site with zero custom views the loop never runs and every subsequent reference NameErrors. 3. `if args.delete:` at line 145 referred to a flag that was never defined in the sample's argparse, so any invocation reaching that line raised AttributeError. Fixes: - Add `=` on line 127 to bind `changed`. - Collect the custom-views iterator into a list, wrap the update/ export block in `if custom_views:`, and pick the last entry explicitly (`c = custom_views[-1]`) — matches the intent of the original "for the last custom view in the list" comment. - Add a `--delete` argparse flag with `action="store_true"` and a clear help string. Guard the delete block so it prints a clear no-op message when the site has no custom views. No other behavior change. Sample compiles and imports cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) * Initialize custom views before workbook guard Co-authored-by: bcantoni <58372+bcantoni@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bcantoni <58372+bcantoni@users.noreply.github.com> --- samples/explore_workbook.py | 44 +++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index 033dbe594..34db59d05 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -30,6 +30,11 @@ def main(): parser.add_argument( "--powerpoint", "-ppt", metavar="FILENAME", help="filename (a .ppt file) to save the powerpoint deck" ) + parser.add_argument( + "--delete", + action="store_true", + help="delete the last custom view on the site after the update/export demo", + ) args = parser.parse_args() @@ -60,6 +65,7 @@ def main(): all_workbooks = list(TSC.Pager(server.workbooks)) print([workbook.name for workbook in all_workbooks]) + custom_views = [] if all_workbooks: # Pick one workbook from the list sample_workbook = all_workbooks[0] @@ -117,23 +123,26 @@ def main(): # Get custom views. `.get()` only returns the first page; # use TSC.Pager to iterate every custom view on the site. - for c in TSC.Pager(server.custom_views): + custom_views = list(TSC.Pager(server.custom_views)) + for c in custom_views: print(c) - # for the last custom view in the list + if custom_views: + # For the last custom view in the list. + c = custom_views[-1] - # update the name - # note that this will fail if the name is already changed to this value - changed: TSC.CustomViewItem(id=c.id, name="I was updated by tsc") - verified_change = server.custom_views.update(changed) - print(verified_change) + # update the name + # note that this will fail if the name is already changed to this value + changed = TSC.CustomViewItem(id=c.id, name="I was updated by tsc") + verified_change = server.custom_views.update(changed) + print(verified_change) - # export as image. Filters etc could be added here as usual - server.custom_views.populate_image(c) - filename = c.id + "-image-export.png" - with open(filename, "wb") as f: - f.write(c.image) - print("saved to " + filename) + # export as image. Filters etc could be added here as usual + server.custom_views.populate_image(c) + filename = c.id + "-image-export.png" + with open(filename, "wb") as f: + f.write(c.image) + print("saved to " + filename) if args.powerpoint: # Populate workbook preview image @@ -143,9 +152,12 @@ def main(): print(f"\nDownloaded powerpoint of workbook to {os.path.abspath(args.powerpoint)}") if args.delete: - print(f"deleting {c.id}") - unlucky = TSC.CustomViewItem(c.id) - server.custom_views.delete(unlucky.id) + if not custom_views: + print("--delete requested but no custom views on this site; nothing to delete.") + else: + print(f"deleting {c.id}") + unlucky = TSC.CustomViewItem(c.id) + server.custom_views.delete(unlucky.id) if __name__ == "__main__": From 6f171fa21f2eea71711f78618d88eb889f46fea8 Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 13 Oct 2026 22:17:03 -0700 Subject: [PATCH 218/219] feat(samples): sample for reading connector-specific connection attrs (#1880) * feat(samples): add inspect_connection_details.py for reading connector-specific attrs The REST API's element only surfaces generic fields (id, type, serverAddress, serverPort, userName, embedPassword, authenticationType, queryTaggingEnabled). Connector-specific attributes -- Oracle service, Snowflake schema, Teradata query-band, initial SQL, etc. -- live inside the workbook or datasource file itself. This sample downloads the file via TSC and parses it with the community `tableaudocumentapi` library to reach the extra attributes. Verified against a live Tableau Cloud site: on a SQL Server datasource, the sample returns dbname/schema values that are None on the REST ConnectionItem. Addresses the recurring backlog around connector-specific connection metadata (#1807, #1571, #569, #353, #160) by pointing users at the existing download-and-parse path. Co-Authored-By: Claude Opus 4.7 (1M context) * Adopt _shared.py credential helper in inspect_connection_details Fold the inline sign-in argparse block into the shared helper landed in #1843. Users of this sample now get env / .env / interactive-prompt credential resolution, JWT + username/password auth in addition to PAT, and a clear ValueError when --server is missing under non-TTY, instead of an opaque 400/401 inside TSC. Also update the header's Python-version claim from 3.7 to 3.10 to match `pyproject.toml`'s `requires-python = ">=3.10"`. Co-Authored-By: Claude Opus 4.7 (1M context) * Fix inspect connection sample review comments Co-authored-by: bcantoni <58372+bcantoni@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Brian Cantoni Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bcantoni <58372+bcantoni@users.noreply.github.com> --- samples/inspect_connection_details.py | 108 ++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 samples/inspect_connection_details.py diff --git a/samples/inspect_connection_details.py b/samples/inspect_connection_details.py new file mode 100644 index 000000000..3f6be2847 --- /dev/null +++ b/samples/inspect_connection_details.py @@ -0,0 +1,108 @@ +#### +# This script demonstrates how to read connector-specific connection attributes +# (Oracle service, Snowflake schema, Teradata query-band, initial SQL, etc.) that +# the Tableau REST API's `` element does NOT expose. +# +# The REST API's `.populate_connections()` surface only carries generic fields +# (id, type, server_address, server_port, username, embed_password, auth_type). +# The full set of connector-specific attributes lives inside the workbook or +# datasource file itself. This sample downloads the file via TSC, then parses it +# with the community `tableaudocumentapi` library to reach the extra attributes. +# +# Requires: +# pip install tableauserverclient tableaudocumentapi +# +# Related GitHub issues: +# - server-client-python#1807 (Oracle service on ConnectionItem) +# - server-client-python#1571 (database_name on ConnectionItem) +# - server-client-python#160 (queryband, initial_sql) +# - server-client-python#353 (SQL Server database + schema + table) +# +# To run the script, you must have installed Python 3.10 or later. +#### + +import argparse +import logging +import tempfile + +import tableauserverclient as TSC + +from _shared import add_common_arguments, build_auth, resolve_credentials + +try: + from tableaudocumentapi import Datasource, Workbook +except ImportError: + raise SystemExit( + "This sample requires the tableaudocumentapi package.\n" "Install it with: pip install tableaudocumentapi" + ) + + +def dump_connections_from_datasource_file(path): + """Print connector-specific attributes for every connection inside a .tds/.tdsx file.""" + ds = Datasource.from_file(path) + for i, c in enumerate(ds.connections, start=1): + _print_connection(f"datasource conn #{i}", c) + + +def dump_connections_from_workbook_file(path): + """Print connector-specific attributes for every connection across every + embedded datasource inside a .twb/.twbx file.""" + wb = Workbook(path) + for di, ds in enumerate(wb.datasources, start=1): + for ci, c in enumerate(ds.connections, start=1): + _print_connection(f"workbook datasource #{di}, conn #{ci}", c) + + +def _print_connection(label, c): + print(f"--- {label} ---") + print(f" dbclass: {c.dbclass}") + print(f" server: {c.server}") + print(f" port: {c.port}") + print(f" username: {c.username}") + print(f" dbname: {c.dbname}") + # Schema, service, query-band, and initial-SQL fields are connector-specific + # and are not exposed by TSC's ConnectionItem today. + print(f" schema: {c.schema}") + print(f" service: {c.service}") + print(f" authentication: {c.authentication}") + print(f" query_band: {c.query_band}") + print(f" initial_sql: {c.initial_sql}") + + +def main(): + parser = argparse.ArgumentParser( + description=( + "Inspect connector-specific connection attributes for a datasource or workbook. " + "Downloads the item via TSC, then parses its embedded XML with tableau-document-api." + ) + ) + # Common options -- credentials come from CLI args, env vars, a .env file, + # or an interactive prompt. See samples/_shared.py. + add_common_arguments(parser) + # Options specific to this sample + parser.add_argument("resource_type", choices=["workbook", "datasource"]) + parser.add_argument("resource_id", help="LUID of the workbook or datasource") + + args = parser.parse_args() + + resolve_credentials(args) + logging.basicConfig(level=getattr(logging, args.logging_level.upper())) + + tableau_auth = build_auth(args) + server = TSC.Server(args.server, use_server_version=True) + + with server.auth.sign_in(tableau_auth): + endpoint = server.workbooks if args.resource_type == "workbook" else server.datasources + with tempfile.TemporaryDirectory() as tmp: + # include_extract=False keeps downloads small; extract bytes are not + # needed to read the XML. + path = endpoint.download(args.resource_id, filepath=tmp, include_extract=False) + print(f"Downloaded to {path}") + if args.resource_type == "workbook": + dump_connections_from_workbook_file(path) + else: + dump_connections_from_datasource_file(path) + + +if __name__ == "__main__": + main() From ba7a8a7d25e2d765dc95c5da0c272eb64f57c7d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:03:52 -0700 Subject: [PATCH 219/219] chore(deps-dev): bump black from 26.5.1 to 26.10.1 (#1889) Bumps [black](https://github.com/psf/black) from 26.5.1 to 26.10.1. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/26.5.1...26.10.1) --- updated-dependencies: - dependency-name: black dependency-version: 26.10.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3ccce45d3..2cce7c61f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["black==26.5.1", "build", "mypy==2.3.1", "pytest>=7.0", "pytest-cov", "pytest-subtests", +test = ["black==26.10.1", "build", "mypy==2.3.1", "pytest>=7.0", "pytest-cov", "pytest-subtests", "pytest-xdist", "requests-mock>=1.0,<2.0", "types-requests>=2.32.4.20250913"] [tool.setuptools.package-data]