From 046bf0de7c2a359237a6297b8e050dceeeaf95be Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Mon, 14 Jul 2025 21:07:55 -0700 Subject: [PATCH 01/56] 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 a0c46e22b29ddf847f4fa481e83f032921185e81 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 02/56] 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 979f49354a07572e95387f2a19a2019ed965b064 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 03/56] 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 74c9c6d80cf61c34706158d5f1fd7e2360b9c2e6 Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Mon, 21 Jul 2025 11:01:35 -0700 Subject: [PATCH 04/56] 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 74451a1a3c0767a94b2805262c25caa5ea867f1e Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Mon, 21 Jul 2025 11:06:25 -0700 Subject: [PATCH 05/56] 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 119d61bbf5f8b7bfe43938fda060fe7c3c451fdb Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Mon, 21 Jul 2025 13:47:55 -0700 Subject: [PATCH 06/56] 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 bc2d5846b66562c294fd9f1fa087da3f6323150e Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Mon, 21 Jul 2025 14:06:06 -0700 Subject: [PATCH 07/56] 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 cb3ed96b922e0130189829e094165cf06854b4cf Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Tue, 22 Jul 2025 16:53:31 -0700 Subject: [PATCH 08/56] 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 04a25e6144f521366d5852df71695fc327bbc21b Mon Sep 17 00:00:00 2001 From: Vineeth Sai Surya Chavatapalli Date: Mon, 21 Jul 2025 16:00:38 -0700 Subject: [PATCH 09/56] 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 bd05f1c597ee79d4e6ad50e3903eeec783126980 Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Wed, 23 Jul 2025 10:33:17 -0700 Subject: [PATCH 10/56] 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 43f52efc9fe24530b5f6c83badd66149b429eb4c Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Wed, 23 Jul 2025 10:38:10 -0700 Subject: [PATCH 11/56] 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 6c60a978d29fb6674b3659a3bab3a3fd8595d953 Mon Sep 17 00:00:00 2001 From: "SFDC\\vchavatapalli" Date: Thu, 24 Jul 2025 11:11:39 -0700 Subject: [PATCH 12/56] 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 f347a2e44de94d78262a46331e4ea94209f7cd63 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 1 Aug 2025 01:50:20 -0500 Subject: [PATCH 13/56] 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 a2d9514ef894c7bcb05a924c54c93186a2717598 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 1 Aug 2025 01:51:12 -0500 Subject: [PATCH 14/56] 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 73d967cd0d4ccb9a44fcae299088b13886383b55 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 1 Aug 2025 01:55:51 -0500 Subject: [PATCH 15/56] 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 0205f1748c72326b13e95f86912a0b02248dae9d Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 18:35:18 -0500 Subject: [PATCH 16/56] 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 5efa1b6ef262634a613bbe55a6301907c07d8eaa Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 22:51:56 -0500 Subject: [PATCH 17/56] 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 096ee35679c65b84fe023acdaf02f7c8a615c9f7 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 16 Oct 2025 22:57:42 -0500 Subject: [PATCH 18/56] 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 bf0882e4002d22598d553e0c7c60bffff6dc3372 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 19/56] 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 eca4d8414b17a9c8ab64cb241cd1f0fb824f6186 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 17 Oct 2025 00:54:48 -0500 Subject: [PATCH 20/56] 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 65de049a18c77b028dbc1da6a18fc81e277ff004 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 28 Oct 2025 23:27:02 -0500 Subject: [PATCH 21/56] 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 0345e9aa4e92dc5a2d348c59ed718f78ad9d8cf8 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 28 Oct 2025 23:35:14 -0500 Subject: [PATCH 22/56] 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 4cc875215f4ab0b4f391f72dd1a8ef2860f00e04 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 28 Oct 2025 23:38:44 -0500 Subject: [PATCH 23/56] 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 c1d655ec0ae80d11ce8e4027735e5b40c1a058a0 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 20 Nov 2025 15:11:32 -0800 Subject: [PATCH 24/56] 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 dbb43c4565afe2817e093771cb85891f5c73c0ad Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 20 Nov 2025 16:00:46 -0800 Subject: [PATCH 25/56] 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 fe719c108ce83c482861b7db800ebb22af2b39cb Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 15:32:42 -0600 Subject: [PATCH 26/56] 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 9924b403d6a3cc094bc0a51d8679e34db1bf457f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 16:34:59 -0600 Subject: [PATCH 27/56] 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 24f945fcab2725f8048096004cc550f2eef82599 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 16:39:48 -0600 Subject: [PATCH 28/56] 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 abff9d335b24df5319aeb91353e365966f9c772d Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 17:05:40 -0600 Subject: [PATCH 29/56] 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 63cd98c991404fc6f0c2d88ef7691da730d1ae33 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 17:10:23 -0600 Subject: [PATCH 30/56] 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 f12014e50437b90a901ddd241043439c9406714b Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Mon, 22 Dec 2025 17:15:19 -0600 Subject: [PATCH 31/56] 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 ee5e2c89e1466630c6dff61d270a53f9dd693b2f Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 13:03:00 -0600 Subject: [PATCH 32/56] fix: black ci errors (#1713) Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com> From b1dd68a28a1aeb03e553665838e9927aca7fb54e Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:19:15 -0600 Subject: [PATCH 33/56] 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 f29abc47df81a0f5300a94672d398d34ff04c8a8 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:21:17 -0600 Subject: [PATCH 34/56] 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 301ea256cbc73693f5cb2bd72a3bbe81b440f7a0 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:21:42 -0600 Subject: [PATCH 35/56] 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 ce8d0dcebc01d3c4af9f145ea15a9fdd37dd7f15 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:21:54 -0600 Subject: [PATCH 36/56] 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 c8c200a4fbf1e38dacc5c06a49cf5fcae9185ace Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:22:16 -0600 Subject: [PATCH 37/56] 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 63be947894495e9989a3048bd6c1efcfe266b6ea Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:23:09 -0600 Subject: [PATCH 38/56] 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 5ff14cb90a308e1c0bd5c0ccfa7d1c05a2b8939b Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:24:22 -0600 Subject: [PATCH 39/56] 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 799520687e4e955806ced4445e172f36d4df2969 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 14:26:10 -0600 Subject: [PATCH 40/56] 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 cff30c05d7c02643110351250b58e31578b621ad Mon Sep 17 00:00:00 2001 From: Jac Date: Tue, 23 Dec 2025 13:41:01 -0800 Subject: [PATCH 41/56] 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 bd1c3e27cc761449692d9175aee624929eea2c7e Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 15:42:11 -0600 Subject: [PATCH 42/56] 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 35745e858e94cef37e67fc8a4961e35127283aee Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 15:55:18 -0600 Subject: [PATCH 43/56] 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 48e94560cd19f93a1fd481416ba2e99b7332c07d Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 23 Dec 2025 16:45:52 -0600 Subject: [PATCH 44/56] 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 88a6768086b3b70c6686d31abbee98b68ac4664e Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 23 Dec 2025 14:47:14 -0800 Subject: [PATCH 45/56] 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 d1664854083837d5c69843dcdc66be23b9d9a1a2 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Fri, 26 Dec 2025 14:30:12 -0600 Subject: [PATCH 46/56] 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 392fbc90839f4b19de1448ace08a8c96c54bf685 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Sat, 27 Dec 2025 01:02:06 -0600 Subject: [PATCH 47/56] 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 9802269c72822b45effedeff0afca618dcbecc60 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 6 Jan 2026 09:53:33 -0600 Subject: [PATCH 48/56] 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 7c76bc4c18011b3d192b7802ed5fd62ce4ffc7ba Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Thu, 8 Jan 2026 14:22:53 -0600 Subject: [PATCH 49/56] 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 b326f7964984bef509760fb9df317e5d10b0ee90 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 13 Jan 2026 17:01:29 -0800 Subject: [PATCH 50/56] 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 d5523ed78fa10b3601b8a01102159eb53f408759 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Wed, 14 Jan 2026 03:56:49 -0600 Subject: [PATCH 51/56] 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 efee6337d67aa08a863df631c26ba91e1e7f41c8 Mon Sep 17 00:00:00 2001 From: Jac Date: Wed, 21 Jan 2026 14:04:08 -0800 Subject: [PATCH 52/56] 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 1359fe04c670e0bb0858aa5cf67230a97b7f63a0 Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 22 Jan 2026 14:43:39 -0800 Subject: [PATCH 53/56] 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 5f55b3011c36e94a811dc618b983fd4ba9452f0d Mon Sep 17 00:00:00 2001 From: Jac Date: Sat, 31 Jan 2026 21:20:37 -0800 Subject: [PATCH 54/56] 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 ca6fdfeb394003ca705cdae031bd6d6a1b9b8255 Mon Sep 17 00:00:00 2001 From: Jordan Woods Date: Tue, 3 Feb 2026 12:12:12 -0600 Subject: [PATCH 55/56] 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 13204d28e6dc8ef2452eeb71015edf431bfca5db Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 3 Feb 2026 11:32:33 -0800 Subject: [PATCH 56/56] 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)