Skip to content

Commit da501d6

Browse files
committed
feat: publish custom views
1 parent c84f921 commit da501d6

File tree

3 files changed

+177
-0
lines changed

3 files changed

+177
-0
lines changed

tableauserverclient/server/endpoint/custom_views_endpoint.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import io
22
import logging
33
import os
4+
from pathlib import Path
45
from typing import List, Optional, Tuple, Union
56

7+
from tableauserverclient.config import BYTES_PER_MB, FILESIZE_LIMIT_MB
8+
from tableauserverclient.filesys_helpers import get_file_object_size
69
from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api
710
from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError
811
from tableauserverclient.models import CustomViewItem, PaginationItem
@@ -130,3 +133,33 @@ def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW:
130133
f.write(server_response.content)
131134

132135
return file
136+
137+
@api(version="3.21")
138+
def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[CustomViewItem]:
139+
url = self.expurl
140+
if isinstance(file, io_types_r):
141+
size = get_file_object_size(file)
142+
elif isinstance(file, (str, Path)) and (p := Path(file)).is_file():
143+
size = p.stat().st_size
144+
else:
145+
raise ValueError("File path or file object required for publishing custom view.")
146+
147+
if size >= FILESIZE_LIMIT_MB * BYTES_PER_MB:
148+
upload_session_id = self.parent_srv.fileuploads.upload(file)
149+
url = f"{url}?uploadSessionId={upload_session_id}"
150+
xml_request, content_type = RequestFactory.CustomView.publish_req_chunked(view_item)
151+
else:
152+
if isinstance(file, io_types_r):
153+
file.seek(0)
154+
contents = file.read()
155+
if view_item.name is None:
156+
raise MissingRequiredFieldError("Custom view item missing name.")
157+
filename = view_item.name
158+
elif isinstance(file, (str, Path)):
159+
filename = Path(file).name
160+
contents = Path(file).read_bytes()
161+
162+
xml_request, content_type = RequestFactory.CustomView.publish_req(view_item, filename, contents)
163+
164+
server_response = self.post_request(url, xml_request, content_type)
165+
return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace)

tableauserverclient/server/request_factory.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import xml.etree.ElementTree as ET
2+
23
from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING, Union
34

45
from requests.packages.urllib3.fields import RequestField
@@ -1267,6 +1268,49 @@ def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem):
12671268
if custom_view_item.name is not None:
12681269
updating_element.attrib["name"] = custom_view_item.name
12691270

1271+
@_tsrequest_wrapped
1272+
def _publish_xml(self, xml_request: ET.Element, custom_view_item: CustomViewItem) -> bytes:
1273+
custom_view_element = ET.SubElement(xml_request, "customView")
1274+
if (name := custom_view_item.name) is not None:
1275+
custom_view_element.attrib["name"] = name
1276+
else:
1277+
raise ValueError(f"Custom View Item missing name: {custom_view_item}")
1278+
if (shared := custom_view_item.shared) is not None:
1279+
custom_view_element.attrib["shared"] = str(shared).lower()
1280+
else:
1281+
raise ValueError(f"Custom View Item missing shared: {custom_view_item}")
1282+
if (owner := custom_view_item.owner) is not None:
1283+
owner_element = ET.SubElement(custom_view_element, "owner")
1284+
if (owner_id := owner.id) is not None:
1285+
owner_element.attrib["id"] = owner_id
1286+
else:
1287+
raise ValueError(f"Custom View Item owner missing id: {owner}")
1288+
else:
1289+
raise ValueError(f"Custom View Item missing owner: {custom_view_item}")
1290+
if (workbook := custom_view_item.workbook) is not None:
1291+
workbook_element = ET.SubElement(custom_view_element, "workbook")
1292+
if (workbook_id := workbook.id) is not None:
1293+
workbook_element.attrib["id"] = workbook_id
1294+
else:
1295+
raise ValueError(f"Custom View Item workbook missing id: {workbook}")
1296+
else:
1297+
raise ValueError(f"Custom View Item missing workbook: {custom_view_item}")
1298+
1299+
return ET.tostring(xml_request)
1300+
1301+
def publish_req_chunked(self, custom_view_item: CustomViewItem):
1302+
xml_request = self._publish_xml(custom_view_item)
1303+
parts = {"request_payload": ("", xml_request, "text/xml")}
1304+
return _add_multipart(parts)
1305+
1306+
def publish_req(self, custom_view_item: CustomViewItem, filename: str, file_contents: bytes):
1307+
xml_request = self._publish_xml(custom_view_item)
1308+
parts = {
1309+
"request_payload": ("", xml_request, "text/xml"),
1310+
"tableau_customview": (filename, file_contents, "application/octet-stream"),
1311+
}
1312+
return _add_multipart(parts)
1313+
12701314

12711315
class GroupSetRequest:
12721316
@_tsrequest_wrapped

test/test_custom_view.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
from contextlib import ExitStack
12
import io
23
import os
34
from pathlib import Path
5+
from tempfile import TemporaryDirectory
46
import unittest
57

68
import requests_mock
79

810
import tableauserverclient as TSC
11+
from tableauserverclient.config import BYTES_PER_MB
912
from tableauserverclient.datetime_helpers import format_datetime
13+
from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError
1014

1115
TEST_ASSET_DIR = Path(__file__).parent / "assets"
1216

@@ -15,6 +19,8 @@
1519
POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png")
1620
CUSTOM_VIEW_UPDATE_XML = os.path.join(TEST_ASSET_DIR, "custom_view_update.xml")
1721
CUSTOM_VIEW_DOWNLOAD = TEST_ASSET_DIR / "custom_view_download.json"
22+
FILE_UPLOAD_INIT = TEST_ASSET_DIR / "fileupload_initialize.xml"
23+
FILE_UPLOAD_APPEND = TEST_ASSET_DIR / "fileupload_append.xml"
1824

1925

2026
class CustomViewTests(unittest.TestCase):
@@ -146,3 +152,97 @@ def test_download(self) -> None:
146152
self.server.custom_views.download(cv, data)
147153

148154
assert data.getvalue() == content
155+
156+
def test_publish_filepath(self) -> None:
157+
cv = TSC.CustomViewItem(name="test")
158+
cv._owner = TSC.UserItem()
159+
cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
160+
cv._workbook = TSC.WorkbookItem()
161+
cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
162+
with requests_mock.mock() as m:
163+
m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text())
164+
view = self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD)
165+
166+
assert view is not None
167+
assert isinstance(view, TSC.CustomViewItem)
168+
assert view.id is not None
169+
assert view.name is not None
170+
171+
def test_publish_file_str(self) -> None:
172+
cv = TSC.CustomViewItem(name="test")
173+
cv._owner = TSC.UserItem()
174+
cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
175+
cv._workbook = TSC.WorkbookItem()
176+
cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
177+
with requests_mock.mock() as m:
178+
m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text())
179+
view = self.server.custom_views.publish(cv, str(CUSTOM_VIEW_DOWNLOAD))
180+
181+
assert view is not None
182+
assert isinstance(view, TSC.CustomViewItem)
183+
assert view.id is not None
184+
assert view.name is not None
185+
186+
def test_publish_file_io(self) -> None:
187+
cv = TSC.CustomViewItem(name="test")
188+
cv._owner = TSC.UserItem()
189+
cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
190+
cv._workbook = TSC.WorkbookItem()
191+
cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
192+
data = io.BytesIO(CUSTOM_VIEW_DOWNLOAD.read_bytes())
193+
with requests_mock.mock() as m:
194+
m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text())
195+
view = self.server.custom_views.publish(cv, data)
196+
197+
assert view is not None
198+
assert isinstance(view, TSC.CustomViewItem)
199+
assert view.id is not None
200+
assert view.name is not None
201+
202+
def test_publish_missing_owner_id(self) -> None:
203+
cv = TSC.CustomViewItem(name="test")
204+
cv._owner = TSC.UserItem()
205+
cv._workbook = TSC.WorkbookItem()
206+
cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
207+
with requests_mock.mock() as m:
208+
m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text())
209+
with self.assertRaises(ValueError):
210+
self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD)
211+
212+
def test_publish_missing_wb_id(self) -> None:
213+
cv = TSC.CustomViewItem(name="test")
214+
cv._owner = TSC.UserItem()
215+
cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
216+
cv._workbook = TSC.WorkbookItem()
217+
with requests_mock.mock() as m:
218+
m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text())
219+
with self.assertRaises(ValueError):
220+
self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD)
221+
222+
def test_large_publish(self):
223+
cv = TSC.CustomViewItem(name="test")
224+
cv._owner = TSC.UserItem()
225+
cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794"
226+
cv._workbook = TSC.WorkbookItem()
227+
cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2"
228+
with ExitStack() as stack:
229+
temp_dir = stack.enter_context(TemporaryDirectory())
230+
file_path = Path(temp_dir) / "test_file"
231+
file_path.write_bytes(os.urandom(65 * BYTES_PER_MB))
232+
mock = stack.enter_context(requests_mock.mock())
233+
# Mock initializing upload
234+
mock.post(self.server.fileuploads.baseurl, status_code=201, text=FILE_UPLOAD_INIT.read_text())
235+
# Mock the upload
236+
mock.put(
237+
f"{self.server.fileuploads.baseurl}/7720:170fe6b1c1c7422dadff20f944d58a52-1:0",
238+
text=FILE_UPLOAD_APPEND.read_text(),
239+
)
240+
# Mock the publish
241+
mock.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text())
242+
243+
view = self.server.custom_views.publish(cv, file_path)
244+
245+
assert view is not None
246+
assert isinstance(view, TSC.CustomViewItem)
247+
assert view.id is not None
248+
assert view.name is not None

0 commit comments

Comments
 (0)