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/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/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..218a4016f 100644
--- a/tableauserverclient/server/endpoint/workbooks_endpoint.py
+++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py
@@ -14,6 +14,7 @@
from tableauserverclient.server.endpoint.exceptions import (
InternalServerError,
MissingRequiredFieldError,
+ ServerResponseError,
UnsupportedAttributeError,
)
from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint
@@ -125,7 +126,7 @@ def get_by_id(self, workbook_id: str) -> WorkbookItem:
return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0]
@api(version="2.8")
- def refresh(self, workbook_item: Union[WorkbookItem, str], incremental: bool = False) -> JobItem:
+ def refresh(self, workbook_item: Union[WorkbookItem, str], incremental: bool = False) -> JobItem | None:
"""
Refreshes the extract of an existing workbook.
@@ -138,13 +139,19 @@ def refresh(self, workbook_item: Union[WorkbookItem, str], incremental: bool = F
Returns
-------
- JobItem
- The job item.
+ JobItem | None
+ The job item, or None if a refresh job is already queued for this workbook.
"""
id_ = getattr(workbook_item, "id", workbook_item)
url = f"{self.baseurl}/{id_}/refresh"
refresh_req = RequestFactory.Task.refresh_req(incremental, self.parent_srv)
- server_response = self.post_request(url, refresh_req)
+ try:
+ server_response = self.post_request(url, refresh_req)
+ except ServerResponseError as e:
+ if e.code.startswith("409") and "already" in e.detail:
+ logger.warning(f"{e.summary} {e.detail}")
+ return None
+ raise
new_job = JobItem.from_response(server_response.content, self.parent_srv.namespace)[0]
return new_job
diff --git a/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/workbook_refresh_duplicate.xml b/test/assets/workbook_refresh_duplicate.xml
new file mode 100644
index 000000000..eca4b4bcc
--- /dev/null
+++ b/test/assets/workbook_refresh_duplicate.xml
@@ -0,0 +1,3 @@
+
+
+Resource ConflictJob for \'extract\' is already queued. Not queuing a duplicate.
\ No newline at end of file
diff --git a/test/test_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""
+ 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_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""
+ 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..b210e8402 100644
--- a/test/test_workbook.py
+++ b/test/test_workbook.py
@@ -34,6 +34,7 @@
PUBLISH_XML = TEST_ASSET_DIR / "workbook_publish.xml"
PUBLISH_ASYNC_XML = TEST_ASSET_DIR / "workbook_publish_async.xml"
REFRESH_XML = TEST_ASSET_DIR / "workbook_refresh.xml"
+WORKBOOK_REFRESH_DUPLICATE_XML = TEST_ASSET_DIR / "workbook_refresh_duplicate.xml"
REVISION_XML = TEST_ASSET_DIR / "workbook_revision.xml"
UPDATE_XML = TEST_ASSET_DIR / "workbook_update.xml"
UPDATE_PERMISSIONS = TEST_ASSET_DIR / "workbook_update_permissions.xml"
@@ -178,6 +179,20 @@ def test_refresh_id(server: TSC.Server) -> None:
server.workbooks.refresh("3cc6cd06-89ce-4fdc-b935-5294135d6d42")
+def test_refresh_already_running(server: TSC.Server) -> None:
+ server.version = "2.8"
+ server.workbooks.baseurl
+ response_xml = WORKBOOK_REFRESH_DUPLICATE_XML.read_text()
+ with requests_mock.mock() as m:
+ m.post(
+ server.workbooks.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42/refresh",
+ status_code=409,
+ text=response_xml,
+ )
+ refresh_job = server.workbooks.refresh("3cc6cd06-89ce-4fdc-b935-5294135d6d42")
+ assert refresh_job is None
+
+
def test_refresh_object(server: TSC.Server) -> None:
server.version = "2.8"
server.workbooks.baseurl