diff --git a/samples/export.py b/samples/export.py index e33710468..b2506cf46 100644 --- a/samples/export.py +++ b/samples/export.py @@ -41,6 +41,7 @@ def main(): "--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en" ) parser.add_argument("--workbook", action="store_true") + parser.add_argument("--custom_view", action="store_true") parser.add_argument("--file", "-f", help="filename to store the exported data") parser.add_argument("--filter", "-vf", metavar="COLUMN:VALUE", help="View filter to apply to the view") @@ -58,6 +59,8 @@ def main(): print("Connected") if args.workbook: item = server.workbooks.get_by_id(args.resource_id) + elif args.custom_view: + item = server.custom_views.get_by_id(args.resource_id) else: item = server.views.get_by_id(args.resource_id) @@ -74,6 +77,8 @@ def main(): populate = getattr(server.views, populate_func_name) if args.workbook: populate = getattr(server.workbooks, populate_func_name) + elif args.custom_view: + populate = getattr(server.custom_views, populate_func_name) option_factory = getattr(TSC, option_factory_name) options: TSC.PDFRequestOptions = option_factory() diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index de917bf4a..a0c0a9844 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -3,6 +3,7 @@ from defusedxml import ElementTree from defusedxml.ElementTree import fromstring, tostring from typing import Callable, Optional +from collections.abc import Iterator from .exceptions import UnpopulatedPropertyError from .user_item import UserItem @@ -17,6 +18,8 @@ def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None self._created_at: Optional["datetime"] = None self._id: Optional[str] = id self._image: Optional[Callable[[], bytes]] = None + self._pdf: Optional[Callable[[], bytes]] = None + self._csv: Optional[Callable[[], Iterator[bytes]]] = None self._name: Optional[str] = name self._shared: Optional[bool] = False self._updated_at: Optional["datetime"] = None @@ -40,6 +43,12 @@ def __repr__(self: "CustomViewItem"): def _set_image(self, image): self._image = image + def _set_pdf(self, pdf): + self._pdf = pdf + + def _set_csv(self, csv): + self._csv = csv + @property def content_url(self) -> Optional[str]: return self._content_url @@ -55,10 +64,24 @@ def id(self) -> Optional[str]: @property def image(self) -> bytes: if self._image is None: - error = "View item must be populated with its png image first." + error = "Custom View item must be populated with its png image first." raise UnpopulatedPropertyError(error) return self._image() + @property + def pdf(self) -> bytes: + if self._pdf is None: + error = "Custom View item must be populated with its pdf first." + raise UnpopulatedPropertyError(error) + return self._pdf() + + @property + def csv(self) -> Iterator[bytes]: + if self._csv is None: + error = "Custom View item must be populated with its csv first." + raise UnpopulatedPropertyError(error) + return self._csv() + @property def name(self) -> Optional[str]: return self._name diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 63899ba0c..b02b05d78 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -1,15 +1,23 @@ import io import logging import os +from contextlib import closing from pathlib import Path from typing import Optional, Union +from collections.abc import Iterator from tableauserverclient.config import BYTES_PER_MB, config from tableauserverclient.filesys_helpers import get_file_object_size from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.models import CustomViewItem, PaginationItem -from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions +from tableauserverclient.server import ( + RequestFactory, + RequestOptions, + ImageRequestOptions, + PDFRequestOptions, + CSVRequestOptions, +) from tableauserverclient.helpers.logging import logger @@ -91,9 +99,45 @@ def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["Imag image = server_response.content return image - """ - Not yet implemented: pdf or csv exports - """ + @api(version="3.23") + def populate_pdf(self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None: + if not custom_view_item.id: + error = "Custom View item missing ID." + raise MissingRequiredFieldError(error) + + def pdf_fetcher(): + return self._get_custom_view_pdf(custom_view_item, req_options) + + custom_view_item._set_pdf(pdf_fetcher) + logger.info(f"Populated pdf for custom view (ID: {custom_view_item.id})") + + def _get_custom_view_pdf( + self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] + ) -> bytes: + url = f"{self.baseurl}/{custom_view_item.id}/pdf" + server_response = self.get_request(url, req_options) + pdf = server_response.content + return pdf + + @api(version="3.23") + def populate_csv(self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None: + if not custom_view_item.id: + error = "Custom View item missing ID." + raise MissingRequiredFieldError(error) + + def csv_fetcher(): + return self._get_custom_view_csv(custom_view_item, req_options) + + custom_view_item._set_csv(csv_fetcher) + logger.info(f"Populated csv for custom view (ID: {custom_view_item.id})") + + def _get_custom_view_csv( + self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] + ) -> Iterator[bytes]: + url = f"{self.baseurl}/{custom_view_item.id}/data" + + with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response: + yield from server_response.iter_content(1024) @api(version="3.18") def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]: diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 0d47abfcc..d79ac7f73 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -213,6 +213,46 @@ def _append_view_filters(self, params) -> None: params[name] = value +class _ImagePDFCommonExportOptions(_DataExportOptions): + def __init__(self, maxage=-1, viz_height=None, viz_width=None): + super().__init__(maxage=maxage) + self.viz_height = viz_height + self.viz_width = viz_width + + @property + def viz_height(self): + return self._viz_height + + @viz_height.setter + @property_is_int(range=(0, sys.maxsize), allowed=(None,)) + def viz_height(self, value): + self._viz_height = value + + @property + def viz_width(self): + return self._viz_width + + @viz_width.setter + @property_is_int(range=(0, sys.maxsize), allowed=(None,)) + def viz_width(self, value): + self._viz_width = value + + def get_query_params(self) -> dict: + params = super().get_query_params() + + # XOR. Either both are None or both are not None. + if (self.viz_height is None) ^ (self.viz_width is None): + raise ValueError("viz_height and viz_width must be specified together") + + if self.viz_height is not None: + params["vizHeight"] = self.viz_height + + if self.viz_width is not None: + params["vizWidth"] = self.viz_width + + return params + + class CSVRequestOptions(_DataExportOptions): extension = "csv" @@ -221,15 +261,15 @@ class ExcelRequestOptions(_DataExportOptions): extension = "xlsx" -class ImageRequestOptions(_DataExportOptions): +class ImageRequestOptions(_ImagePDFCommonExportOptions): extension = "png" # if 'high' isn't specified, the REST API endpoint returns an image with standard resolution class Resolution: High = "high" - def __init__(self, imageresolution=None, maxage=-1): - super().__init__(maxage=maxage) + def __init__(self, imageresolution=None, maxage=-1, viz_height=None, viz_width=None): + super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) self.image_resolution = imageresolution def get_query_params(self): @@ -239,7 +279,7 @@ def get_query_params(self): return params -class PDFRequestOptions(_DataExportOptions): +class PDFRequestOptions(_ImagePDFCommonExportOptions): class PageType: A3 = "a3" A4 = "a4" @@ -261,29 +301,9 @@ class Orientation: Landscape = "landscape" def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): - super().__init__(maxage=maxage) + super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width) self.page_type = page_type self.orientation = orientation - self.viz_height = viz_height - self.viz_width = viz_width - - @property - def viz_height(self): - return self._viz_height - - @viz_height.setter - @property_is_int(range=(0, sys.maxsize), allowed=(None,)) - def viz_height(self, value): - self._viz_height = value - - @property - def viz_width(self): - return self._viz_width - - @viz_width.setter - @property_is_int(range=(0, sys.maxsize), allowed=(None,)) - def viz_width(self, value): - self._viz_width = value def get_query_params(self) -> dict: params = super().get_query_params() @@ -293,14 +313,4 @@ def get_query_params(self) -> dict: if self.orientation: params["orientation"] = self.orientation - # XOR. Either both are None or both are not None. - if (self.viz_height is None) ^ (self.viz_width is None): - raise ValueError("viz_height and viz_width must be specified together") - - if self.viz_height is not None: - params["vizHeight"] = self.viz_height - - if self.viz_width is not None: - params["vizWidth"] = self.viz_width - return params diff --git a/test/test_custom_view.py b/test/test_custom_view.py index 80800c86b..6e863a863 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -18,6 +18,8 @@ GET_XML_ID = os.path.join(TEST_ASSET_DIR, "custom_view_get_id.xml") POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png") CUSTOM_VIEW_UPDATE_XML = os.path.join(TEST_ASSET_DIR, "custom_view_update.xml") +CUSTOM_VIEW_POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") +CUSTOM_VIEW_POPULATE_CSV = os.path.join(TEST_ASSET_DIR, "populate_csv.csv") CUSTOM_VIEW_DOWNLOAD = TEST_ASSET_DIR / "custom_view_download.json" FILE_UPLOAD_INIT = TEST_ASSET_DIR / "fileupload_initialize.xml" FILE_UPLOAD_APPEND = TEST_ASSET_DIR / "fileupload_append.xml" @@ -246,3 +248,73 @@ def test_large_publish(self): assert isinstance(view, TSC.CustomViewItem) assert view.id is not None assert view.name is not None + + def test_populate_pdf(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_PDF, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait&maxAge=5", + content=response, + ) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + size = TSC.PDFRequestOptions.PageType.Letter + orientation = TSC.PDFRequestOptions.Orientation.Portrait + req_option = TSC.PDFRequestOptions(size, orientation, 5) + + self.server.custom_views.populate_pdf(custom_view, req_option) + self.assertEqual(response, custom_view.pdf) + + def test_populate_csv(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data?maxAge=1", content=response) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + request_option = TSC.CSVRequestOptions(maxage=1) + self.server.custom_views.populate_csv(custom_view, request_option) + + csv_file = b"".join(custom_view.csv) + self.assertEqual(response, csv_file) + + def test_populate_csv_default_maxage(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data", content=response) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + self.server.custom_views.populate_csv(custom_view) + + csv_file = b"".join(custom_view.csv) + self.assertEqual(response, csv_file) + + def test_pdf_height(self) -> None: + self.server.version = "3.23" + self.baseurl = self.server.custom_views.baseurl + with open(CUSTOM_VIEW_POPULATE_PDF, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?vizHeight=1080&vizWidth=1920", + content=response, + ) + custom_view = TSC.CustomViewItem() + custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.PDFRequestOptions( + viz_height=1080, + viz_width=1920, + ) + + self.server.custom_views.populate_pdf(custom_view, req_option) + self.assertEqual(response, custom_view.pdf)