10000 Multi-Credential Support in TSC (#276) · Tuatan/server-client-python@48df0ef · GitHub
[go: up one dir, main page]

Skip to content

Commit 48df0ef

Browse files
authored
Multi-Credential Support in TSC (tableau#276)
Taking @marianotn 's PR and updating it to retain backwards compatibility. Pre-implemented for datasources as well, though those don't support this server-side yet, I've set that version to 99.99. When support lands we can update it to the version it's introduced in.
1 parent 18372ba commit 48df0ef

File tree

9 files changed

+234
-33
lines changed

9 files changed

+234
-33
lines changed

samples/publish_workbook.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import logging
2020

2121
import tableauserverclient as TSC
22+
from tableauserverclient import ConnectionCredentials, ConnectionItem
2223

2324

2425
def main():
@@ -50,10 +51,26 @@ def main():
5051
all_projects, pagination_item = server.projects.get()
5152
default_project = next((project for project in all_projects if project.is_default()), None)
5253

54+
connection1 = ConnectionItem()
55+
connection1.server_address = "mssql.test.com"
56+
connection1.connection_credentials = ConnectionCredentials("test", "password", True)
57+
58+
connection2 = ConnectionItem()
59+
connection2.server_address = "postgres.test.com"
60+
connection2.server_port = "5432"
61+
connection2.connection_credentials = ConnectionCredentials("test", "password", True)
62+
63+
all_connections = list()
64+
all_connections.append(connection1)
65+
all_connections.append(connection2)
66+
5367
# Step 3: If default project is found, form a new workbook item and publish.
5468
if default_project is not None:
5569
new_workbook = TSC.WorkbookItem(default_project.id)
56-
new_workbook = server.workbooks.publish(new_workbook, args.filepath, overwrite_true)
70+
new_workbook = server.workbooks.publish(new_workbook,
71+
args.filepath,
72+
overwrite_true,
73+
connections=all_connections)
5774
print("Workbook published. ID: {0}".format(new_workbook.id))
5875
else:
5976
error = "The default project could not be found."

tableauserverclient/models/connection_credentials.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,15 @@ def oauth(self):
3232
@property_is_boolean
3333
def oauth(self, value):
3434
self._oauth = value
35+
36+
@classmethod
37+
def from_xml_element(cls, parsed_response, ns):
38+
connection_creds_xml = parsed_response.find('.//t:connectionCredentials', namespaces=ns)
39+
40+
name = connection_creds_xml.get('name', None)
41+
password = connection_creds_xml.get('password', None)
42+
embed = connection_creds_xml.get('embed', None)
43+
oAuth = connection_creds_xml.get('oAuth', None)
44+
45+
connection_creds = cls(name, password, embed, oAuth)
46+
return connection_creds

tableauserverclient/models/connection_item.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import xml.etree.ElementTree as ET
2+
from .connection_credentials import ConnectionCredentials
23

34

45
class ConnectionItem(object):
@@ -12,6 +13,7 @@ def __init__(self):
1213
self.server_address = None
1314
self.server_port = None
1415
self.username = None
16+
self.connection_credentials = None
1517

1618
@property
1719
def datasource_id(self):
@@ -51,3 +53,32 @@ def from_response(cls, resp, ns):
5153
connection_item._datasource_name = datasource_elem.get('name', None)
5254
all_connection_items.append(connection_item)
5355
return all_connection_items
56+
57+
@classmethod
58+
def from_xml_element(cls, parsed_response, ns):
59+
'''
60+
<connections>
61+
<connection serverAddress="mysql.test.com">
62+
<connectionCredentials embed="true" name="test" password="secret" />
63+
</connection>
64+
<connection serverAddress="pgsql.test.com">
65+
<connectionCredentials embed="true" name="test" password="secret" />
66+
</connection>
67+
</connections>
68+
'''
69+
all_connection_items = list()
70+
all_connection_xml = parsed_response.findall('.//t:connection', namespaces=ns)
71+
72+
for connection_xml in all_connection_xml:
73+ connection_item = cls()
74+
75+
connection_item.server_address = connection_xml.get('serverAddress', None)
76+
connection_item.server_port = connection_xml.get('serverPort', None)
77+
78+
connection_credentials = connection_xml.find('.//t:connectionCredentials', namespaces=ns)
79+
80+
if connection_credentials is not None:
81+
82+
connection_item.connection_credentials = ConnectionCredentials.from_xml_element(connection_credentials)
83+
84+
return all_connection_items

tableauserverclient/server/endpoint/datasources_endpoint.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,8 @@ def refresh(self, datasource_item):
151151

152152
# Publish datasource
153153
@api(version="2.0")
154-
def publish(self, datasource_item, file_path, mode, connection_credentials=None):
154+
@parameter_added_in(connections="99.99")
155+
def publish(self, datasource_item, file_path, mode, connection_credentials=None, connections=None):
155156
if not os.path.isfile(file_path):
156157
error = "File path does not lead to an existing file."
157158
raise IOError(error)
@@ -180,15 +181,17 @@ def publish(self, datasource_item, file_path, mode, connection_credentials=None)
180181
upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file_path)
181182
url = "{0}&uploadSessionId={1}".format(url, upload_session_id)
182183
xml_request, content_type = RequestFactory.Datasource.publish_req_chunked(datasource_item,
183-
connection_credentials)
184+
connection_credentials,
185+
connections)
184186
else:
185187
logger.info('Publishing {0} to server'.format(filename))
186188
with open(file_path, 'rb') as f:
187189
file_contents = f.read()
188190
xml_request, content_type = RequestFactory.Datasource.publish_req(datasource_item,
189191
filename,
190192
file_contents,
191-
connection_credentials)
193+
connection_credentials,
194+
connections)
192195
server_response = self.post_request(url, xml_request, content_type)
193196
new_datasource = DatasourceItem.from_response(server_response.content, self.parent_srv.namespace)[0]
194197
logger.info('Published {0} (ID: {1})'.format(filename, new_datasource.id))

tableauserverclient/server/endpoint/endpoint.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def _safe_to_log(server_response):
3232
'''Checks if the server_response content is not xml (eg binary image or zip)
3333
and and replaces it with a constant
3434
'''
35-
ALLOWED_CONTENT_TYPES = ('application/xml',)
35+
ALLOWED_CONTENT_TYPES = ('application/xml', 'application/xml;charset=utf-8')
3636
if server_response.headers.get('Content-Type', None) not in ALLOWED_CONTENT_TYPES:
3737
return '[Truncated File Contents]'
3838
else:

tableauserverclient/server/endpoint/workbooks_endpoint.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,14 @@ def _get_wb_preview_image(self, workbook_item):
199199

200200
# Publishes workbook. Chunking method if file over 64MB
201201
@api(version="2.0")
202-
def publish(self, workbook_item, file_path, mode, connection_credentials=None):
202+
@parameter_added_in(connections='2.8')
203+
def publish(self, workbook_item, file_path, mode, connection_credentials=None, connections=None):
204+
205+
if connection_credentials is not None:
206+
import warnings
207+
warnings.warn("connection_credentials is being deprecated. Use connections instead",
208+
DeprecationWarning)
209+
203210
if not os.path.isfile(file_path):
204211
error = "File path does not lead to an existing file."
205212
raise IOError(error)
@@ -230,16 +237,21 @@ def publish(self, workbook_item, file_path, mode, connection_credentials=None):
230237
logger.info('Publishing {0} to server with chunking method (workbook over 64MB)'.format(filename))
231238
upload_session_id = Fileuploads.upload_chunks(self.parent_srv, file_path)
232239
url = "{0}&uploadSessionId={1}".format(url, upload_session_id)
240+
conn_creds = connection_credentials
233241
xml_request, content_type = RequestFactory.Workbook.publish_req_chunked(workbook_item,
234-
connection_credentials)
242+
connection_credentials=conn_creds,
243+
connections=connections)
235244
else:
236245
logger.info('Publishing {0} to server'.format(filename))
237246
with open(file_path, 'rb') as f:
238247
file_contents = f.read()
248+
conn_creds = connection_credentials
239249
xml_request, content_type = RequestFactory.Workbook.publish_req(workbook_item,
240250
filename,
241251
file_contents,
242-
connection_credentials)
252+
connection_credentials=conn_creds,
253+
connections=connections)
254+
logger.debug('Request xml: {0} '.format(xml_request[:1000]))
243255
server_response = self.post_request(url, xml_request, content_type)
244256
new_workbook = WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0]
245257
logger.info('Published {0} (ID: {1})'.format(filename, new_workbook.id))

tableauserverclient/server/request_factory.py

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,25 @@ def wrapper(self, *args, **kwargs):
2525
return wrapper
2626

2727

28+
def _add_connections_element(connections_element, < 6851 span class=pl-s1>connection):
29+
connection_element = ET.SubElement(connections_element, 'connection')
30+
connection_element.attrib['serverAddress'] = connection.server_address
31+
if connection.server_port:
32+
connection_element.attrib['serverPort'] = connection.server_port
33+
if connection.connection_credentials:
34+
connection_credentials = connection.connection_credentials
35+
_add_credentials_element(connection_element, connection_credentials)
36+
37+
38+
def _add_credentials_element(parent_element, connection_credentials):
39+
credentials_element = ET.SubElement(parent_element, 'connectionCredentials')
40+
credentials_element.attrib['name'] = connection_credentials.name
41+
credentials_element.attrib['password'] = connection_credentials.password
42+
credentials_element.attrib['embed'] = 'true' if connection_credentials.embed else 'false'
43+
if connection_credentials.oauth:
44+
credentials_element.attrib['oAuth'] = 'true'
45+
46+
2847
class AuthRequest(object):
2948
def signin_req(self, auth_item):
3049
xml_request = ET.Element('tsRequest')
@@ -40,20 +59,23 @@ def signin_req(self, auth_item):
4059

4160

4261
class DatasourceRequest(object):
43-
def _generate_xml(self, datasource_item, connection_credentials=None):
62+
def _generate_xml(self, datasource_item, connection_credentials=None, connections=None):
4463
xml_request = ET.Element('tsRequest')
4564
datasource_element = ET.SubElement(xml_request, 'datasource')
4665
datasource_element.attrib['name'] = datasource_item.name
4766
project_element = ET.SubElement(datasource_element, 'project')
4867
project_element.attrib['id'] = datasource_item.project_id
49-
if connection_credentials:
50-
credentials_element = ET.SubElement(datasource_element, 'connectionCredentials')
51-
credentials_element.attrib['name'] = connection_credentials.name
52-
credentials_element.attrib['password'] = connection_credentials.password
53-
credentials_element.attrib['embed'] = 'true' if connection_credentials.embed else 'false'
54-
55-
if connection_credentials.oauth:
56-
credentials_element.attrib['oAuth'] = 'true'
68+
69+
if connection_credentials is not None and connections is not None:
70+
raise RuntimeError('You cannot set both `connections` and `connection_credentials`')
71+
72+
if connection_credentials is not None:
73+
_add_credentials_element(datasource_element, connection_credentials)
74+
75+
if connections is not None:
76+
connections_element = ET.SubElement(datasource_element, 'connections')
77+
for connection in connections:
78+
_add_connections_element(connections_element, connection)
5779
return ET.tostring(xml_request)
5880

5981
def update_req(self, datasource_item):
@@ -73,15 +95,15 @@ def update_req(self, datasource_item):
7395

7496
return ET.tostring(xml_request)
7597

76-
def publish_req(self, datasource_item, filename, file_contents, connection_credentials=None):
77-
xml_request = self._generate_xml(datasource_item, connection_credentials)
98+
def publish_req(self, datasource_item, filename, file_contents, connection_credentials=None, connections=None):
99+
xml_request = self._generate_xml(datasource_item, connection_credentials, connections)
78100

79101
parts = {'request_payload': ('', xml_request, 'text/xml'),
80102
'tableau_datasource': (filename, file_contents, 'application/octet-stream')}
81103
return _add_multipart(parts)
82104

83105
def publish_req_chunked(self, datasource_item, connection_credentials=None):
84-
xml_request = self._generate_xml(datasource_item, connection_credentials)
106+
xml_request = self._generate_xml(datasource_item, connection_credentials, connections)
85107

86108
parts = {'request_payload': ('', xml_request, 'text/xml')}
87109
return _add_multipart(parts)
@@ -324,22 +346,25 @@ def add_req(self, user_item):
324346

325347

326348
class WorkbookRequest(object):
327-
def _generate_xml(self, workbook_item, connection_credentials=None):
349+
def _generate_xml(self, workbook_item, connection_credentials=None, connections=None):
328350
xml_request = ET.Element('tsRequest')
329351
workbook_element = ET.SubElement(xml_request, 'workbook')
330352
workbook_element.attrib['name'] = workbook_item.name
331353
if workbook_item.show_tabs:
332354
workbook_element.attrib['showTabs'] = str(workbook_item.show_tabs).lower()
333355
project_element = ET.SubElement(workbook_element, 'project')
334356
project_element.attrib['id'] = workbook_item.project_id
335-
if connection_credentials:
336-
credentials_element = ET.SubElement(workbook_element, 'connectionCredentials')
337-
credentials_element.attrib['name'] = connection_credentials.name
338-
credentials_element.attrib['password'] = connection_credentials.password
339-
credentials_element.attrib['embed'] = 'true' if connection_credentials.embed else 'false'
340-
341-
if connection_credentials.oauth:
342-
credentials_element.attrib['oAuth'] = 'true'
357+
358+
if connection_credentials is not None and connections is not None:
359+
raise RuntimeError('You cannot set both `connections` and `connection_credentials`')
360+
361+
if connection_credentials is not None:
362+
_add_credentials_element(workbook_element, connection_credentials)
363+
364+
if connections is not None:
365+
connections_element = ET.SubElement(workbook_element, 'connections')
366+
for connection in connections:
367+
_add_connections_element(connections_element, connection)
343368
return ET.tostring(xml_request)
344369

345370
def update_req(self, workbook_item):
@@ -357,15 +382,19 @@ def update_req(self, workbook_item):
357382
owner_element.attrib['id'] = workbook_item.owner_id
358383
return ET.tostring(xml_request)
359384

360-
def publish_req(self, workbook_item, filename, file_contents, connection_credentials=None):
361-
xml_request = self._generate_xml(workbook_item, connection_credentials)
385+
def publish_req(self, workbook_item, filename, file_contents, connection_credentials=None, connections=None):
386+
xml_request = self._generate_xml(workbook_item,
387+
connection_credentials=connection_credentials,
388+
connections=connections)
362389

363390
parts = {'request_payload': ('', xml_request, 'text/xml'),
364391
'tableau_workbook': (filename, file_contents, 'application/octet-stream')}
365392
return _add_multipart(parts)
366393

367-
def publish_req_chunked(self, workbook_item, connection_credentials=None):
368-
xml_request = self._generate_xml(workbook_item, connection_credentials)
394+
def publish_req_chunked(self, workbook_item, connections=None):
395+
xml_request = self._generate_xml(workbook_item,
396+
connection_credentials=connection_credentials,
397+
connections=connections)
369398

370399
parts = {'request_payload': ('', xml_request, 'text/xml')}
371400
return _add_multipart(parts)

test/test_datasource.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import unittest
22
import os
33
import requests_mock
4+
import xml.etree.ElementTree as ET
45
import tableauserverclient as TSC
56
from tableauserverclient.datetime_helpers import format_datetime
7+
from tableauserverclient.server.request_factory import RequestFactory
68
from ._utils import read_xml_asset, read_xml_assets, asset
79

810
ADD_TAGS_XML = 'datasource_add_tags.xml'
@@ -245,3 +247,48 @@ def test_publish_invalid_file_type(self):
245247
new_datasource = TSC.DatasourceItem('test', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760')
246248
self.assertRaises(ValueError, self.server.datasources.publish, new_datasource,
247249
asset('SampleWB.twbx'), self.server.PublishMode.Append)
250+
251+
def test_publish_multi_connection(self):
252+
new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760')
253+
connection1 = TSC.ConnectionItem()
254+
connection1.server_address = 'mysql.test.com'
255+
connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True)
256+
connection2 = TSC.ConnectionItem()
257+
connection2.server_address = 'pgsql.test.com'
258+
connection2.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True)
259+
260+
response = RequestFactory.Datasource._generate_xml(new_datasource, connections=[connection1, connection2])
261+
# Can't use ConnectionItem parser due to xml namespace problems
262+
connection_results = ET.fromstring(response).findall('.//connection')
263+
264+
self.assertEqual(connection_results[0].get('serverAddress', None), 'mysql.test.com')
265+
self.assertEqual(connection_results[0].find('connectionCredentials').get('name', None), 'test')
266+
self.assertEqual(connection_results[1].get('serverAddress', None), 'pgsql.test.com')
267+
self.assertEqual(connection_results[1].find('connectionCredentials').get('password', None), 'secret')
268+
269+
def test_publish_single_connection(self):
270+
new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760')
271+
connection_creds = TSC.ConnectionCredentials('test', 'secret', True)
272+
273+
response = RequestFactory.Datasource._generate_xml(new_datasource, connection_credentials=connection_creds)
274+
# Can't use ConnectionItem parser due to xml namespace problems
275+
credentials = ET.fromstring(response).findall('.//connectionCredentials')
276+
277+
self.assertEqual(len(credentials), 1)
278+
self.assertEqual(credentials[0].get('name', None), 'test')
279+
self.assertEqual(credentials[0].get('password', None), 'secret')
280+
self.assertEqual(credentials[0].get('embed', None), 'true')
281+
282+
def test_credentials_and_multi_connect_raises_exception(self):
283+
new_datasource = TSC.DatasourceItem(name='Sample', project_id='ee8c6e70-43b6-11e6-af4f-f7b0d8e20760')
284+
285+
connection_creds = TSC.ConnectionCredentials('test', 'secret', True)
286+
287+
connection1 = TSC.ConnectionItem()
288+
connection1.server_address = 'mysql.test.com'
289+
connection1.connection_credentials = TSC.ConnectionCredentials('test', 'secret', True)
290+
291+
with self.assertRaises(RuntimeError):
292+
response = RequestFactory.Datasource._generate_xml(new_datasource,
293+
connection_credentials=connection_creds,
294+
connections=[connection1])

0 commit comments

Comments
 (0)
0