diff --git a/.travis.yml b/.travis.yml index cc261b20c..68cee02ad 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ dist: xenial language: python python: - - "2.7" - "3.5" - "3.6" - "3.7" diff --git a/samples/list.py b/samples/list.py index 090d7dfdf..e103eb862 100644 --- a/samples/list.py +++ b/samples/list.py @@ -7,6 +7,8 @@ import argparse import getpass import logging +import os +import sys import tableauserverclient as TSC @@ -14,28 +16,27 @@ def main(): parser = argparse.ArgumentParser(description='List out the names and LUIDs for different resource types') parser.add_argument('--server', '-s', required=True, help='server address') - parser.add_argument('--site', '-S', default=None, help='site to log into, do not specify for default site') - parser.add_argument('--username', '-u', required=True, help='username to sign into server') - parser.add_argument('--password', '-p', default=None, help='password for the user') + parser.add_argument('--site', '-S', default="", help='site to log into, do not specify for default site') + parser.add_argument('--token-name', '-n', required=True, help='username to signin under') + parser.add_argument('--token', '-t', help='personal access token for logging in') parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', help='desired logging level (set to error by default)') - parser.add_argument('resource_type', choices=['workbook', 'datasource', 'project', 'view', 'job']) + parser.add_argument('resource_type', choices=['workbook', 'datasource', 'project', 'view', 'job', 'webhooks']) args = parser.parse_args() - - if args.password is None: - password = getpass.getpass("Password: ") - else: - password = args.password + token = os.environ.get('TOKEN', args.token) + if not token: + print("--token or TOKEN environment variable needs to be set") + sys.exit(1) # Set logging level based on user input, or error by default logging_level = getattr(logging, args.logging_level.upper()) logging.basicConfig(level=logging_level) # SIGN IN - tableau_auth = TSC.TableauAuth(args.username, password, args.site) + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, token, site_id=args.site) server = TSC.Server(args.server, use_server_version=True) with server.auth.sign_in(tableau_auth): endpoint = { @@ -44,6 +45,7 @@ def main(): 'view': server.views, 'job': server.jobs, 'project': server.projects, + 'webhooks': server.webhooks, }.get(args.resource_type) for resource in TSC.Pager(endpoint.get): diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index eb647ed25..bb938c8fa 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -3,7 +3,8 @@ GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem,\ SiteItem, TableauAuth, PersonalAccessTokenAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError,\ HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem,\ - SubscriptionItem, Target, PermissionsRule, Permission, DatabaseItem, TableItem, ColumnItem, FlowItem + SubscriptionItem, Target, PermissionsRule, Permission, DatabaseItem, TableItem, ColumnItem, FlowItem, \ + WebhookItem, PersonalAccessTokenAuth from .server import RequestOptions, CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, Filter, Sort, \ Server, ServerResponseError, MissingRequiredFieldError, NotSignedInError, Pager from ._version import get_versions diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index a3517e13f..172877060 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -23,3 +23,5 @@ from .workbook_item import WorkbookItem from .subscription_item import SubscriptionItem from .permissions_item import PermissionsRule, Permission +from .webhook_item import WebhookItem +from .personal_access_token_auth import PersonalAccessTokenAuth diff --git a/tableauserverclient/models/pagination_item.py b/tableauserverclient/models/pagination_item.py index 545679945..98d6b42f9 100644 --- a/tableauserverclient/models/pagination_item.py +++ b/tableauserverclient/models/pagination_item.py @@ -29,3 +29,12 @@ def from_response(cls, resp, ns): pagination_item._page_size = int(pagination_xml.get('pageSize', '-1')) pagination_item._total_available = int(pagination_xml.get('totalAvailable', '-1')) return pagination_item + + @classmethod + def from_single_page_list(cls, l): + item = cls() + item._page_number = 1 + item._page_size = len(l) + item._total_available = len(l) + + return item diff --git a/tableauserverclient/models/personal_access_token_auth.py b/tableauserverclient/models/personal_access_token_auth.py index c9b892cf6..875f68c48 100644 --- a/tableauserverclient/models/personal_access_token_auth.py +++ b/tableauserverclient/models/personal_access_token_auth.py @@ -9,3 +9,6 @@ def __init__(self, token_name, personal_access_token, site_id=''): @property def credentials(self): return {'personalAccessTokenName': self.token_name, 'personalAccessTokenSecret': self.personal_access_token} + + def __repr__(self): + return "".format(self.token_name, self.personal_access_token) diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py new file mode 100644 index 000000000..4b1a350ee --- /dev/null +++ b/tableauserverclient/models/webhook_item.py @@ -0,0 +1,89 @@ +import xml.etree.ElementTree as ET +from .exceptions import UnpopulatedPropertyError +from .property_decorators import property_not_nullable, property_is_boolean, property_is_materialized_views_config +from .tag_item import TagItem +from .view_item import ViewItem +from .permissions_item import PermissionsRule +from ..datetime_helpers import parse_datetime +import re + + +NAMESPACE_RE = re.compile(r'^{.*}') + + +def _parse_event(events): + event = events[0] + # Strip out the namespace from the tag name + return NAMESPACE_RE.sub('', event.tag) + + +class WebhookItem(object): + def __init__(self): + self._id = None + self.name = None + self.url = None + self._event = None + self.owner_id = None + + def _set_values(self, id, name, url, event, owner_id): + if id is not None: + self._id = id + if name: + self.name = name + if url: + self.url = url + if event: + self.event = event + if owner_id: + self.owner_id = owner_id + + @property + def id(self): + return self._id + + @property + def event(self): + if self._event: + return self._event.replace("webhook-source-event-", "") + return None + + @event.setter + def event(self, value): + self._event = "webhook-source-event-{}".format(value) + + @classmethod + def from_response(cls, resp, ns): + all_webhooks_items = list() + parsed_response = ET.fromstring(resp) + all_webhooks_xml = parsed_response.findall('.//t:webhook', namespaces=ns) + for webhook_xml in all_webhooks_xml: + values = cls._parse_element(webhook_xml, ns) + + webhook_item = cls() + webhook_item._set_values(*values) + all_webhooks_items.append(webhook_item) + return all_webhooks_items + + @staticmethod + def _parse_element(webhook_xml, ns): + id = webhook_xml.get('id', None) + name = webhook_xml.get('name', None) + + url = None + url_tag = webhook_xml.find('.//t:webhook-destination-http', namespaces=ns) + if url_tag is not None: + url = url_tag.get('url', None) + + event = webhook_xml.findall('.//t:webhook-source/*', namespaces=ns) + if event is not None and len(event) > 0: + event = _parse_event(event) + + owner_id = None + owner_tag = webhook_xml.find('.//t:owner', namespaces=ns) + if owner_tag is not None: + owner_id = owner_tag.get('id', None) + + return id, name, url, event, owner_id + + def __repr__(self): + return "".format(self.id, self.name, self.url, self.event) diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index a76fd3246..f382d0dba 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -5,7 +5,7 @@ from .. import ConnectionItem, DatasourceItem, DatabaseItem, JobItem, BackgroundJobItem, \ GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\ UserItem, ViewItem, WorkbookItem, TableItem, TaskItem, SubscriptionItem, \ - PermissionsRule, Permission, ColumnItem, FlowItem + PermissionsRule, Permission, ColumnItem, FlowItem, WebhookItem from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ Sites, Tables, Users, Views, Workbooks, Subscriptions, ServerResponseError, \ MissingRequiredFieldError, Flows diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index dbf501fe3..34c45a89a 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -11,9 +11,10 @@ from .schedules_endpoint import Schedules from .server_info_endpoint import ServerInfo from .sites_endpoint import Sites +from .subscriptions_endpoint import Subscriptions from .tables_endpoint import Tables from .tasks_endpoint import Tasks from .users_endpoint import Users from .views_endpoint import Views +from .webhooks_endpoint import Webhooks from .workbooks_endpoint import Workbooks -from .subscriptions_endpoint import Subscriptions diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py new file mode 100644 index 000000000..c1e188982 --- /dev/null +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -0,0 +1,53 @@ +from .endpoint import Endpoint, api, parameter_added_in +from ...models import WebhookItem, PaginationItem +from .. import RequestFactory + +import logging +logger = logging.getLogger('tableau.endpoint.webhooks') + + +class Webhooks(Endpoint): + def __init__(self, parent_srv): + super(Webhooks, self).__init__(parent_srv) + + @property + def baseurl(self): + return "{0}/sites/{1}/webhooks".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + @api(version="3.6") + def get(self, req_options=None): + logger.info('Querying all Webhooks on site') + url = self.baseurl + server_response = self.get_request(url, req_options) + all_webhook_items = WebhookItem.from_response(server_response.content, self.parent_srv.namespace) + pagination_item = PaginationItem.from_single_page_list(all_webhook_items) + return all_webhook_items, pagination_item + + @api(version="3.6") + def get_by_id(self, webhook_id): + if not webhook_id: + error = "Webhook ID undefined." + raise ValueError(error) + logger.info('Querying single webhook (ID: {0})'.format(webhook_id)) + url = "{0}/{1}".format(self.baseurl, webhook_id) + server_response = self.get_request(url) + return WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.6") + def delete(self, webhook_id): + if not webhook_id: + error = "Webhook ID undefined." + raise ValueError(error) + url = "{0}/{1}".format(self.baseurl, webhook_id) + self.delete_request(url) + logger.info('Deleted single webhook (ID: {0})'.format(webhook_id)) + + @api(version="3.6") + def create(self, webhook_item): + url = self.baseurl + create_req = RequestFactory.Webhook.create_req(webhook_item) + server_response = self.post_request(url, create_req) + new_webhook = WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + logger.info('Created new webhook (ID: {0})'.format(new_webhook.id)) + return new_webhook diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 80bc38a7c..a6a49fedf 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -1,6 +1,5 @@ from .endpoint import Endpoint, api, parameter_added_in from .exceptions import InternalServerError, MissingRequiredFieldError -from .endpoint import api, parameter_added_in, Endpoint from .permissions_endpoint import _PermissionsEndpoint from .exceptions import MissingRequiredFieldError from .fileuploads_endpoint import Fileuploads diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index ad484e6a8..8001a1e6c 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -555,6 +555,23 @@ def empty_req(self, xml_request): pass +class WebhookRequest(object): + @_tsrequest_wrapped + def create_req(self, xml_request, webhook_item): + webhook = ET.SubElement(xml_request, 'webhook') + webhook.attrib['name'] = webhook_item.name + + source = ET.SubElement(webhook, 'webhook-source') + event = ET.SubElement(source, webhook_item._event) + + destination = ET.SubElement(webhook, 'webhook-destination') + post = ET.SubElement(destination, 'webhook-destination-http') + post.attrib['method'] = 'POST' + post.attrib['url'] = webhook_item.url + + return ET.tostring(xml_request) + + class RequestFactory(object): Auth = AuthRequest() Connection = Connection() @@ -569,9 +586,10 @@ class RequestFactory(object): Project = ProjectRequest() Schedule = ScheduleRequest() Site = SiteRequest() + Subscription = SubscriptionRequest() Table = TableRequest() Tag = TagRequest() Task = TaskRequest() User = UserRequest() Workbook = WorkbookRequest() - Subscription = SubscriptionRequest() + Webhook = WebhookRequest() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index b11f55d17..6c36482fd 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -4,7 +4,7 @@ from ..namespace import Namespace from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \ Schedules, ServerInfo, Tasks, ServerInfoEndpointNotFoundError, Subscriptions, Jobs, Metadata,\ - Databases, Tables, Flows + Databases, Tables, Flows, Webhooks from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError import requests @@ -55,6 +55,7 @@ def __init__(self, server_address, use_server_version=False): self.metadata = Metadata(self) self.databases = Databases(self) self.tables = Tables(self) + self.webhooks = Webhooks(self) self._namespace = Namespace() if use_server_version: diff --git a/test/assets/webhook_create.xml b/test/assets/webhook_create.xml new file mode 100644 index 000000000..24a5ca99b --- /dev/null +++ b/test/assets/webhook_create.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/webhook_create_request.xml b/test/assets/webhook_create_request.xml new file mode 100644 index 000000000..0578c2c48 --- /dev/null +++ b/test/assets/webhook_create_request.xml @@ -0,0 +1 @@ + diff --git a/test/assets/webhook_get.xml b/test/assets/webhook_get.xml new file mode 100644 index 000000000..7d527fc00 --- /dev/null +++ b/test/assets/webhook_get.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index fdf3c2e51..c90cf4601 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -178,8 +178,8 @@ def test_update_connection(self): new_connection = self.server.datasources.update_connection(single_datasource, connection) self.assertEqual(connection.id, new_connection.id) self.assertEqual(connection.connection_type, new_connection.connection_type) - self.assertEquals('bar', new_connection.server_address) - self.assertEquals('9876', new_connection.server_port) + self.assertEqual('bar', new_connection.server_address) + self.assertEqual('9876', new_connection.server_port) self.assertEqual('foo', new_connection.username) def test_populate_permissions(self): @@ -230,9 +230,11 @@ def test_publish(self): self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_datasource.owner_id) def test_publish_async(self): + self.server.version = "3.0" + baseurl = self.server.datasources.baseurl response_xml = read_xml_asset(PUBLISH_XML_ASYNC) with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) + m.post(baseurl, text=response_xml) new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') publish_mode = self.server.PublishMode.CreateNew @@ -355,6 +357,6 @@ def test_synchronous_publish_timeout_error(self): new_datasource = TSC.DatasourceItem(project_id='') publish_mode = self.server.PublishMode.CreateNew - self.assertRaisesRegexp(InternalServerError, 'Please use asynchronous publishing to avoid timeouts.', - self.server.datasources.publish, new_datasource, - asset('SampleDS.tds'), publish_mode) + self.assertRaisesRegex(InternalServerError, 'Please use asynchronous publishing to avoid timeouts.', + self.server.datasources.publish, new_datasource, + asset('SampleDS.tds'), publish_mode) diff --git a/test/test_job.py b/test/test_job.py index 5da0f76fa..ee8316168 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -32,14 +32,14 @@ def test_get(self): started_at = datetime(2018, 5, 22, 13, 0, 37, tzinfo=utc) ended_at = datetime(2018, 5, 22, 13, 0, 45, tzinfo=utc) - self.assertEquals(1, pagination_item.total_available) - self.assertEquals('2eef4225-aa0c-41c4-8662-a76d89ed7336', job.id) - self.assertEquals('Success', job.status) - self.assertEquals('50', job.priority) - self.assertEquals('single_subscription_notify', job.type) - self.assertEquals(created_at, job.created_at) - self.assertEquals(started_at, job.started_at) - self.assertEquals(ended_at, job.ended_at) + self.assertEqual(1, pagination_item.total_available) + self.assertEqual('2eef4225-aa0c-41c4-8662-a76d89ed7336', job.id) + self.assertEqual('Success', job.status) + self.assertEqual('50', job.priority) + self.assertEqual('single_subscription_notify', job.type) + self.assertEqual(created_at, job.created_at) + self.assertEqual(started_at, job.started_at) + self.assertEqual(ended_at, job.ended_at) def test_get_before_signin(self): self.server._auth_token = None diff --git a/test/test_regression_tests.py b/test/test_regression_tests.py index 932e993de..281f3fbca 100644 --- a/test/test_regression_tests.py +++ b/test/test_regression_tests.py @@ -55,9 +55,9 @@ def test_make_download_path(self): has_file_path_folder = ('/root/folder/', 'file.ext') has_file_path_file = ('out', 'file.ext') - self.assertEquals('file.ext', make_download_path(*no_file_path)) - self.assertEquals('out.ext', make_download_path(*has_file_path_file)) + self.assertEqual('file.ext', make_download_path(*no_file_path)) + self.assertEqual('out.ext', make_download_path(*has_file_path_file)) with mock.patch('os.path.isdir') as mocked_isdir: mocked_isdir.return_value = True - self.assertEquals('/root/folder/file.ext', make_download_path(*has_file_path_folder)) + self.assertEqual('/root/folder/file.ext', make_download_path(*has_file_path_folder)) diff --git a/test/test_requests.py b/test/test_requests.py index 67282b6f9..d064e080e 100644 --- a/test/test_requests.py +++ b/test/test_requests.py @@ -54,7 +54,7 @@ def test_internal_server_error(self): server_response = "500: Internal Server Error" with requests_mock.mock() as m: m.register_uri('GET', self.server.server_info.baseurl, status_code=500, text=server_response) - self.assertRaisesRegexp(InternalServerError, server_response, self.server.server_info.get) + self.assertRaisesRegex(InternalServerError, server_response, self.server.server_info.get) # Test that non-xml server errors are handled properly def test_non_xml_error(self): @@ -62,4 +62,4 @@ def test_non_xml_error(self): server_response = "this is not xml" with requests_mock.mock() as m: m.register_uri('GET', self.server.server_info.baseurl, status_code=499, text=server_response) - self.assertRaisesRegexp(NonXMLResponseError, server_response, self.server.server_info.get) + self.assertRaisesRegex(NonXMLResponseError, server_response, self.server.server_info.get) diff --git a/test/test_webhook.py b/test/test_webhook.py new file mode 100644 index 000000000..bdf25bb19 --- /dev/null +++ b/test/test_webhook.py @@ -0,0 +1,77 @@ +import unittest +import os +import requests_mock +import tableauserverclient as TSC +from tableauserverclient.server import RequestFactory, WebhookItem + +from ._utils import read_xml_asset, read_xml_assets, asset + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), 'assets') + +GET_XML = asset('webhook_get.xml') +CREATE_XML = asset('webhook_create.xml') +CREATE_REQUEST_XML = asset('webhook_create_request.xml') + + +class WebhookTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server('http://test') + self.server.version = "3.6" + + # Fake signin + self.server._site_id = 'dad65087-b08b-4603-af4e-2887b8aafc67' + self.server._auth_token = 'j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM' + + self.baseurl = self.server.webhooks.baseurl + + def test_get(self): + with open(GET_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + webhooks, _ = self.server.webhooks.get() + self.assertEqual(len(webhooks), 1) + webhook = webhooks[0] + + self.assertEqual(webhook.url, "url") + self.assertEqual(webhook.event, "datasource-created") + self.assertEqual(webhook.owner_id, "webhook_owner_luid") + self.assertEqual(webhook.name, "webhook-name") + self.assertEqual(webhook.id, "webhook-id") + + def test_get_before_signin(self): + self.server._auth_token = None + self.assertRaises(TSC.NotSignedInError, self.server.webhooks.get) + + def test_delete(self): + with requests_mock.mock() as m: + m.delete(self.baseurl + '/ee8c6e70-43b6-11e6-af4f-f7b0d8e20760', status_code=204) + self.server.webhooks.delete('ee8c6e70-43b6-11e6-af4f-f7b0d8e20760') + + def test_delete_missing_id(self): + self.assertRaises(ValueError, self.server.webhooks.delete, '') + + def test_create(self): + with open(CREATE_XML, 'rb') as f: + response_xml = f.read().decode('utf-8') + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + new_webhook = TSC.WebhookItem() + new_webhook.name = "Test Webhook" + new_webhook.url = "http://ifttt.com/maker-url" + new_webhook.event = "webhook-source-event-datasource-created" + + new_webhook = self.server.webhooks.create(new_webhook) + + self.assertNotEqual(new_webhook.id, None) + + def test_request_factory(self): + with open(CREATE_REQUEST_XML, 'rb') as f: + webhook_request_expected = f.read().decode('utf-8') + + webhook_item = WebhookItem() + webhook_item._set_values("webhook-id", "webhook-name", "url", "api-event-name", + None) + webhook_request_actual = '{}\n'.format(RequestFactory.Webhook.create_req(webhook_item).decode('utf-8')) + self.maxDiff = None + self.assertEqual(webhook_request_expected, webhook_request_actual) diff --git a/test/test_workbook.py b/test/test_workbook.py index 0317ba115..714f28941 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -409,10 +409,12 @@ def test_publish(self): self.assertEqual('RESTAPISample_0/sheets/GDPpercapita', new_workbook.views[0].content_url) def test_publish_async(self): + self.server.version = '3.0' + baseurl = self.server.workbooks.baseurl with open(PUBLISH_ASYNC_XML, 'rb') as f: response_xml = f.read().decode('utf-8') with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) + m.post(baseurl, text=response_xml) new_workbook = TSC.WorkbookItem(name='Sample', show_tabs=False, @@ -497,5 +499,5 @@ def test_synchronous_publish_timeout_error(self): new_workbook = TSC.WorkbookItem(project_id='') publish_mode = self.server.PublishMode.CreateNew - self.assertRaisesRegexp(InternalServerError, 'Please use asynchronous publishing to avoid timeouts', - self.server.workbooks.publish, new_workbook, asset('SampleWB.twbx'), publish_mode) + self.assertRaisesRegex(InternalServerError, 'Please use asynchronous publishing to avoid timeouts', + self.server.workbooks.publish, new_workbook, asset('SampleWB.twbx'), publish_mode)