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"} diff --git a/samples/update_connections_auth.py b/samples/update_connections_auth.py index f0c8dd852..f99a866ef 100644 --- a/samples/update_connections_auth.py +++ b/samples/update_connections_auth.py @@ -22,9 +22,9 @@ def main(): # Resource-specific parser.add_argument("resource_type", choices=["workbook", "datasource"]) parser.add_argument("resource_id") - parser.add_argument("datasource_username") - parser.add_argument("authentication_type") - parser.add_argument("--datasource_password", default=None, help="Datasource password (optional)") + parser.add_argument("--datasource_username", help="Datasource username (optional)") + parser.add_argument("--authentication_type", help="Authentication type (optional)") + parser.add_argument("--datasource_password", help="Datasource password (optional)") parser.add_argument( "--embed_password", default="true", choices=["true", "false"], help="Embed password (default: true)" ) diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 8d78dca7a..a82f1fa67 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -121,7 +121,7 @@ def populate_image(self, view_item: CustomViewItem, req_options: Optional["Image view_item : CustomViewItem req_options : ImageRequestOptions, optional - Options to customize the image returned, by default None + Options to customize the image returned, including format (PNG or SVG), by default None Returns ------- @@ -139,6 +139,13 @@ def populate_image(self, view_item: CustomViewItem, req_options: Optional["Image def image_fetcher(): return self._get_view_image(view_item, req_options) + if req_options is not None: + if not self.parent_srv.check_at_least_version("3.29"): + if req_options.format: + from tableauserverclient.server.endpoint.exceptions import UnsupportedAttributeError + + raise UnsupportedAttributeError("format parameter is only supported in 3.29+") + view_item._set_image(image_fetcher) logger.info(f"Populated image for custom view (ID: {view_item.id})") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 6a734f7b3..7608f908e 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -379,7 +379,7 @@ def update_connections( self, datasource_item: DatasourceItem, connection_luids: Iterable[str], - authentication_type: str, + authentication_type: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, embed_password: Optional[bool] = None, @@ -387,6 +387,9 @@ def update_connections( """ Bulk updates one or more datasource connections by LUID. + This method allows updating authentication type, credentials, and other + connection properties for multiple connections at once. + Parameters ---------- datasource_item : DatasourceItem @@ -395,8 +398,9 @@ def update_connections( connection_luids : Iterable of str The connection LUIDs to update. - authentication_type : str - The authentication type to use (e.g., 'auth-keypair'). + authentication_type : str, optional + The authentication type to use (e.g., 'auth-keypair', 'AD Service Principal'). + If not provided, the existing authentication type is preserved. username : str, optional The username to set. diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 8984af407..99b83e646 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -298,12 +298,12 @@ def batch_update_state( @api(version="3.27") def batch_update_state(self, schedules, state, update_all=False) -> list[str]: """ - Batch update the status of one or more scheudles. If update_all is set, + Batch update the status of one or more schedules. If update_all is set, all schedules on the Tableau Server are affected. Parameters ---------- - schedules: Iterable[ScheudleItem | str] | Any + schedules: Iterable[ScheduleItem | str] | Any The schedules to be updated. If update_all=True, this is ignored. state: Literal["active", "suspended"] diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 162c04105..b95f3be0a 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -158,7 +158,7 @@ def populate_image(self, view_item: ViewItem, req_options: Optional["ImageReques req_options: Optional[ImageRequestOptions], default None Optional request options for the request. These options can include - parameters such as image resolution and max age. + parameters such as image resolution, max age, and format (PNG or SVG). Returns ------- @@ -171,9 +171,13 @@ def populate_image(self, view_item: ViewItem, req_options: Optional["ImageReques def image_fetcher(): return self._get_view_image(view_item, req_options) - if not self.parent_srv.check_at_least_version("3.23") and req_options is not None: - if req_options.viz_height or req_options.viz_width: - raise UnsupportedAttributeError("viz_height and viz_width are only supported in 3.23+") + if req_options is not None: + if not self.parent_srv.check_at_least_version("3.23"): + if req_options.viz_height or req_options.viz_width: + raise UnsupportedAttributeError("viz_height and viz_width are only supported in 3.23+") + if not self.parent_srv.check_at_least_version("3.29"): + if req_options.format: + raise UnsupportedAttributeError("format parameter is only supported in 3.29+") view_item._set_image(image_fetcher) logger.info(f"Populated image for view (ID: {view_item.id})") diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 5f9695829..7db0a3d67 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -14,6 +14,7 @@ from tableauserverclient.server.endpoint.exceptions import ( InternalServerError, MissingRequiredFieldError, + ServerResponseError, UnsupportedAttributeError, ) from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint @@ -125,7 +126,7 @@ def get_by_id(self, workbook_id: str) -> WorkbookItem: return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="2.8") - def refresh(self, workbook_item: Union[WorkbookItem, str], incremental: bool = False) -> JobItem: + def refresh(self, workbook_item: Union[WorkbookItem, str], incremental: bool = False) -> JobItem | None: """ Refreshes the extract of an existing workbook. @@ -138,13 +139,19 @@ def refresh(self, workbook_item: Union[WorkbookItem, str], incremental: bool = F Returns ------- - JobItem - The job item. + JobItem | None + The job item, or None if a refresh job is already queued for this workbook. """ id_ = getattr(workbook_item, "id", workbook_item) url = f"{self.baseurl}/{id_}/refresh" refresh_req = RequestFactory.Task.refresh_req(incremental, self.parent_srv) - server_response = self.post_request(url, refresh_req) + try: + server_response = self.post_request(url, refresh_req) + except ServerResponseError as e: + if e.code.startswith("409") and "already" in e.detail: + logger.warning(f"{e.summary} {e.detail}") + return None + raise new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] return new_job @@ -334,13 +341,16 @@ def update_connections( self, workbook_item: WorkbookItem, connection_luids: Iterable[str], - authentication_type: str, + authentication_type: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, embed_password: Optional[bool] = None, ) -> list[ConnectionItem]: """ - Bulk updates one or more workbook connections by LUID, including authenticationType, username, password, and embedPassword. + Bulk updates one or more workbook connections by LUID. + + This method allows updating authentication type, credentials, and other + connection properties for multiple connections at once. Parameters ---------- @@ -350,8 +360,9 @@ def update_connections( connection_luids : Iterable of str The connection LUIDs to update. - authentication_type : str - The authentication type to use (e.g., 'AD Service Principal'). + authentication_type : str, optional + The authentication type to use (e.g., 'AD Service Principal', 'auth-keypair'). + If not provided, the existing authentication type is preserved. username : str, optional The username to set (e.g., client ID for keypair auth). diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 57deb6e26..589b6beb4 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -254,7 +254,7 @@ def update_connections_req( self, element: ET.Element, connection_luids: Iterable[str], - authentication_type: str, + authentication_type: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, embed_password: Optional[bool] = None, @@ -264,7 +264,8 @@ def update_connections_req( ET.SubElement(conn_luids_elem, "connectionLUID").text = luid connection_elem = ET.SubElement(element, "connection") - connection_elem.set("authenticationType", authentication_type) + if authentication_type is not None: + connection_elem.set("authenticationType", authentication_type) if username is not None: connection_elem.set("userName", username) @@ -1172,7 +1173,7 @@ def update_connections_req( self, element: ET.Element, connection_luids: Iterable[str], - authentication_type: str, + authentication_type: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, embed_password: Optional[bool] = None, @@ -1182,7 +1183,8 @@ def update_connections_req( ET.SubElement(conn_luids_elem, "connectionLUID").text = luid connection_elem = ET.SubElement(element, "connection") - connection_elem.set("authenticationType", authentication_type) + if authentication_type is not None: + connection_elem.set("authenticationType", authentication_type) if username is not None: connection_elem.set("userName", username) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 70c85d140..870435eb0 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -497,6 +497,10 @@ class ImageRequestOptions(_ImagePDFCommonExportOptions): viz_width: int, optional The width of the viz in pixels. If specified, viz_height must also be specified. + format: str, optional + The format of the image to export. Use Format.PNG, Format.SVG, Format.png, or Format.svg. + Default is "PNG". Available in API version 3.29+. + """ extension = "png" @@ -505,14 +509,21 @@ class ImageRequestOptions(_ImagePDFCommonExportOptions): class Resolution: High = "high" - def __init__(self, imageresolution=None, maxage=-1, viz_height=None, viz_width=None): + class Format: + PNG = "PNG" + SVG = "SVG" + + def __init__(self, imageresolution=None, maxage=-1, viz_height=None, viz_width=None, format=None): super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) self.image_resolution = imageresolution + self.format = format def get_query_params(self): params = super().get_query_params() if self.image_resolution: params["resolution"] = self.image_resolution + if self.format: + params["format"] = self.format return params diff --git a/test/assets/datasource_connections_update_no_auth.xml b/test/assets/datasource_connections_update_no_auth.xml new file mode 100644 index 000000000..b9d1bf3f0 --- /dev/null +++ b/test/assets/datasource_connections_update_no_auth.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/test/assets/workbook_refresh_duplicate.xml b/test/assets/workbook_refresh_duplicate.xml new file mode 100644 index 000000000..eca4b4bcc --- /dev/null +++ b/test/assets/workbook_refresh_duplicate.xml @@ -0,0 +1,3 @@ + + +Resource ConflictJob for \'extract\' is already queued. Not queuing a duplicate. \ No newline at end of file diff --git a/test/assets/workbook_update_connections_no_auth.xml b/test/assets/workbook_update_connections_no_auth.xml new file mode 100644 index 000000000..21860fa06 --- /dev/null +++ b/test/assets/workbook_update_connections_no_auth.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/test/test_custom_view.py b/test/test_custom_view.py index 2a3932726..6cbe4b454 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -116,6 +116,54 @@ def test_populate_image_with_options(server: TSC.Server) -> None: assert response == single_view.image +def test_populate_image_svg_format(server: TSC.Server) -> None: + server.version = "3.29" + response = b"test" + with requests_mock.mock() as m: + m.get( + server.custom_views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?format=SVG", + content=response, + ) + single_view = TSC.CustomViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + req_option = TSC.ImageRequestOptions(format=TSC.ImageRequestOptions.Format.SVG) + server.custom_views.populate_image(single_view, req_option) + assert response == single_view.image + + +def test_populate_image_png_format(server: TSC.Server) -> None: + server.version = "3.29" + response = POPULATE_PREVIEW_IMAGE.read_bytes() + with requests_mock.mock() as m: + m.get( + server.custom_views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?format=PNG", + content=response, + ) + single_view = TSC.CustomViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + req_option = TSC.ImageRequestOptions(format=TSC.ImageRequestOptions.Format.PNG) + server.custom_views.populate_image(single_view, req_option) + assert response == single_view.image + + +def test_populate_image_format_unsupported_version(server: TSC.Server) -> None: + from tableauserverclient.server.endpoint.exceptions import UnsupportedAttributeError + + server.version = "3.28" + response = POPULATE_PREVIEW_IMAGE.read_bytes() + with requests_mock.mock() as m: + m.get( + server.custom_views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?format=SVG", + content=response, + ) + single_view = TSC.CustomViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + req_option = TSC.ImageRequestOptions(format=TSC.ImageRequestOptions.Format.SVG) + + with pytest.raises(UnsupportedAttributeError): + server.custom_views.populate_image(single_view, req_option) + + def test_populate_image_missing_id(server: TSC.Server) -> None: single_view = TSC.CustomViewItem() single_view._id = None diff --git a/test/test_database.py b/test/test_database.py index 8eb03c737..951eebe00 100644 --- a/test/test_database.py +++ b/test/test_database.py @@ -98,7 +98,7 @@ def test_populate_data_quality_warning(server): first_dqw = dqws.pop() assert first_dqw.id == "c2e0e406-84fb-4f4e-9998-f20dd9306710" assert first_dqw.warning_type == "WARNING" - assert first_dqw.message, "Hello == World!" + assert first_dqw.message == "Hello, World!" assert first_dqw.owner_id == "eddc8c5f-6af0-40be-b6b0-2c790290a43f" assert first_dqw.active assert first_dqw.severe diff --git a/test/test_datasource.py b/test/test_datasource.py index 56eb11ab7..a0890f3a5 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -35,6 +35,7 @@ UPDATE_HYPER_DATA_XML = TEST_ASSET_DIR / "datasource_data_update.xml" UPDATE_CONNECTION_XML = TEST_ASSET_DIR / "datasource_connection_update.xml" UPDATE_CONNECTIONS_XML = TEST_ASSET_DIR / "datasource_connections_update.xml" +UPDATE_CONNECTIONS_NO_AUTH_XML = TEST_ASSET_DIR / "datasource_connections_update_no_auth.xml" @pytest.fixture(scope="function") @@ -276,6 +277,44 @@ def test_update_connections(server) -> None: assert "auth-keypair" == connection_items[0].auth_type +def test_update_connections_without_auth_type(server) -> None: + """Test that update_connections works when authentication_type is not provided.""" + populate_xml = POPULATE_CONNECTIONS_XML.read_text() + response_xml = UPDATE_CONNECTIONS_NO_AUTH_XML.read_text() + + with requests_mock.Mocker() as m: + + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + connection_luids = ["be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", "a1b2c3d4-e5f6-7a8b-9c0d-123456789abc"] + + datasource = TSC.DatasourceItem(datasource_id) + datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + server.version = "3.26" + + m.get( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", + text=populate_xml, + ) + m.put( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", + text=response_xml, + ) + + # Update connections without specifying authentication_type + connection_items = server.datasources.update_connections( + datasource_item=datasource, + connection_luids=connection_luids, + username="user1", + embed_password=True, + ) + updated_ids = [conn.id for conn in connection_items] + + assert updated_ids == connection_luids + # Verify that the auth type from the response is preserved (UsernamePassword) + assert connection_items[0].auth_type == "UsernamePassword" + + def test_populate_permissions(server) -> None: response_xml = POPULATE_PERMISSIONS_XML.read_text() with requests_mock.mock() as m: diff --git a/test/test_view.py b/test/test_view.py index b16f47c72..a940e1d18 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -238,6 +238,52 @@ def test_populate_image_with_options(server: TSC.Server) -> None: assert response == single_view.image +def test_populate_image_svg_format(server: TSC.Server) -> None: + server.version = "3.29" + response = b"test" + with requests_mock.mock() as m: + m.get( + server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?format=SVG", + content=response, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + req_option = TSC.ImageRequestOptions(format=TSC.ImageRequestOptions.Format.SVG) + server.views.populate_image(single_view, req_option) + assert response == single_view.image + + +def test_populate_image_png_format(server: TSC.Server) -> None: + server.version = "3.29" + response = POPULATE_PREVIEW_IMAGE.read_bytes() + with requests_mock.mock() as m: + m.get( + server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?format=PNG", + content=response, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + req_option = TSC.ImageRequestOptions(format=TSC.ImageRequestOptions.Format.PNG) + server.views.populate_image(single_view, req_option) + assert response == single_view.image + + +def test_populate_image_format_unsupported_version(server: TSC.Server) -> None: + server.version = "3.28" + response = POPULATE_PREVIEW_IMAGE.read_bytes() + with requests_mock.mock() as m: + m.get( + server.views.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/image?format=SVG", + content=response, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + req_option = TSC.ImageRequestOptions(format=TSC.ImageRequestOptions.Format.SVG) + + with pytest.raises(UnsupportedAttributeError): + server.views.populate_image(single_view, req_option) + + def test_populate_pdf(server: TSC.Server) -> None: response = POPULATE_PDF.read_bytes() with requests_mock.mock() as m: diff --git a/test/test_workbook.py b/test/test_workbook.py index e6e807f89..c5c4f6662 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -34,10 +34,12 @@ PUBLISH_XML = TEST_ASSET_DIR / "workbook_publish.xml" PUBLISH_ASYNC_XML = TEST_ASSET_DIR / "workbook_publish_async.xml" REFRESH_XML = TEST_ASSET_DIR / "workbook_refresh.xml" +WORKBOOK_REFRESH_DUPLICATE_XML = TEST_ASSET_DIR / "workbook_refresh_duplicate.xml" REVISION_XML = TEST_ASSET_DIR / "workbook_revision.xml" UPDATE_XML = TEST_ASSET_DIR / "workbook_update.xml" UPDATE_PERMISSIONS = TEST_ASSET_DIR / "workbook_update_permissions.xml" UPDATE_CONNECTIONS_XML = TEST_ASSET_DIR / "workbook_update_connections.xml" +UPDATE_CONNECTIONS_NO_AUTH_XML = TEST_ASSET_DIR / "workbook_update_connections_no_auth.xml" @pytest.fixture(scope="function") @@ -178,6 +180,20 @@ def test_refresh_id(server: TSC.Server) -> None: server.workbooks.refresh("3cc6cd06-89ce-4fdc-b935-5294135d6d42") +def test_refresh_already_running(server: TSC.Server) -> None: + server.version = "2.8" + server.workbooks.baseurl + response_xml = WORKBOOK_REFRESH_DUPLICATE_XML.read_text() + with requests_mock.mock() as m: + m.post( + server.workbooks.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/refresh", + status_code=409, + text=response_xml, + ) + refresh_job = server.workbooks.refresh("3cc6cd06-89ce-4fdc-b935-5294135d6d42") + assert refresh_job is None + + def test_refresh_object(server: TSC.Server) -> None: server.version = "2.8" server.workbooks.baseurl @@ -1032,6 +1048,42 @@ def test_update_workbook_connections(server: TSC.Server) -> None: assert "AD Service Principal" == connection_items[0].auth_type +def test_update_workbook_connections_without_auth_type(server: TSC.Server) -> None: + """Test that update_connections works when authentication_type is not provided.""" + populate_xml = POPULATE_CONNECTIONS_XML.read_text() + response_xml = UPDATE_CONNECTIONS_NO_AUTH_XML.read_text() + + with requests_mock.Mocker() as m: + workbook_id = "1a2b3c4d-5e6f-7a8b-9c0d-112233445566" + connection_luids = ["abc12345-def6-7890-gh12-ijklmnopqrst", "1234abcd-5678-efgh-ijkl-0987654321mn"] + + workbook = TSC.WorkbookItem(workbook_id) + workbook._id = workbook_id + server.version = "3.26" + url = f"{server.baseurl}/{workbook_id}/connections" + m.get( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks/1a2b3c4d-5e6f-7a8b-9c0d-112233445566/connections", + text=populate_xml, + ) + m.put( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks/1a2b3c4d-5e6f-7a8b-9c0d-112233445566/connections", + text=response_xml, + ) + + # Update connections without specifying authentication_type + connection_items = server.workbooks.update_connections( + workbook_item=workbook, + connection_luids=connection_luids, + username="user1", + embed_password=True, + ) + updated_ids = [conn.id for conn in connection_items] + + assert updated_ids == connection_luids + # Verify that the auth type from the response is preserved (UsernamePassword) + assert connection_items[0].auth_type == "UsernamePassword" + + def test_get_workbook_all_fields(server: TSC.Server) -> None: server.version = "3.21" baseurl = server.workbooks.baseurl